From 74476e4e8d08ced37765f5020604bede2331a09c Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Wed, 30 Sep 2026 16:42:08 +0530 Subject: [PATCH 01/75] Relay installer strips control characters from secrets --- packaging/relay/install-relay.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packaging/relay/install-relay.sh b/packaging/relay/install-relay.sh index 0132734c..0d0cf513 100644 --- a/packaging/relay/install-relay.sh +++ b/packaging/relay/install-relay.sh @@ -222,7 +222,7 @@ guided_setup() { # token, and SteamGridDB answers that with a bare 401 indistinguishable # from a key that was never valid. Pasting is the normal way to enter # this, which makes stray whitespace the normal way to get it wrong. - STEAMGRIDDB_KEY="$(ask_secret 'SteamGridDB key (blank to skip)' | tr -d '[:space:]')" + STEAMGRIDDB_KEY="$(ask_secret 'SteamGridDB key (blank to skip)' | tr -d '[:space:][:cntrl:]')" if [ -n "$STEAMGRIDDB_KEY" ]; then HAVE_STEAMGRID=1; fi printf ' ' @@ -235,7 +235,7 @@ guided_setup() { " # Same reasoning: whitespace here reaches Google as part of the secret, # and the only thing it says back is "invalid_client". - GOOGLE_SECRET="$(ask_secret 'Google client secret (blank to skip)' | tr -d '[:space:]')" + GOOGLE_SECRET="$(ask_secret 'Google client secret (blank to skip)' | tr -d '[:space:][:cntrl:]')" if [ -n "$GOOGLE_SECRET" ]; then HAVE_GOOGLE=1; fi printf ' @@ -310,7 +310,7 @@ if [ -n "$GOOGLE_SECRET_FILE" ]; then # Read once, here, and never printed. Whitespace and the trailing newline # a text editor adds are stripped, because they would be sent to Google as # part of the secret and the failure that causes says only "invalid_client". - GOOGLE_SECRET="$(tr -d '[:space:]' < "$GOOGLE_SECRET_FILE")" + GOOGLE_SECRET="$(tr -d '[:space:][:cntrl:]' < "$GOOGLE_SECRET_FILE")" [ -n "$GOOGLE_SECRET" ] || die "$GOOGLE_SECRET_FILE is empty" HAVE_GOOGLE=1 fi @@ -319,8 +319,10 @@ if [ -n "$STEAMGRIDDB_KEY_FILE" ]; then [ -r "$STEAMGRIDDB_KEY_FILE" ] || die "cannot read $STEAMGRIDDB_KEY_FILE" # Stripped for the same reason: a trailing newline would be sent as part of # the bearer token, and SteamGridDB answers that with a bare 401 that looks - # exactly like a key that was never valid. - STEAMGRIDDB_KEY="$(tr -d '[:space:]' < "$STEAMGRIDDB_KEY_FILE")" + # exactly like a key that was never valid. Control characters too: a key + # typed at a hidden `read -s` prompt on the production relay arrived with a + # Ctrl+A and a backspace in front of it, and SteamGridDB answered 400. + STEAMGRIDDB_KEY="$(tr -d '[:space:][:cntrl:]' < "$STEAMGRIDDB_KEY_FILE")" [ -n "$STEAMGRIDDB_KEY" ] || die "$STEAMGRIDDB_KEY_FILE is empty" HAVE_STEAMGRID=1 fi From 1c56822ad918f97593868a1ee80f605e0cf82f6d Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Wed, 30 Sep 2026 17:08:39 +0530 Subject: [PATCH 02/75] No false emptied hold for a location given its first folder --- CHANGELOG.md | 11 +++ internal/p2p/syncengine/hold.go | 32 +++++++- internal/p2p/syncengine/hold_test.go | 78 +++++++++++++++++++ internal/store/game_roots.go | 52 ++++++++++++- internal/store/game_roots_test.go | 66 +++++++++++++++- .../migrations/0037_game_root_mapped.sql | 18 +++++ 6 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 internal/store/migrations/0037_game_root_mapped.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9f5915..80a45ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to OpenSave are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Fixed + +- **Restoring onto a new PC no longer holds a game back for nothing.** After + a backup from the old PC was restored, giving one of a game's other save + locations its folder on the new machine read as every file there deleted: + the game was held back, asking whether that was meant, instead of fetching + the files from your other devices. What a location held before now counts + only from when it got the folder it has. + ## [2.4.0] — 2026-09-30 Everything from the four 2.4 betas below, now stable. Coming from 2.3.1, it diff --git a/internal/p2p/syncengine/hold.go b/internal/p2p/syncengine/hold.go index 6aec88d8..7060c241 100644 --- a/internal/p2p/syncengine/hold.go +++ b/internal/p2p/syncengine/hold.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sort" + "strconv" "strings" "time" @@ -38,7 +39,9 @@ import ( // // What the location "held before" is the files the other devices are // recorded as having in common with this one, together with the files in -// this device's newest snapshot that has any. Not the first alone: that +// this device's newest snapshot that has any — from a snapshot taken since +// the location has had the folder it has now (lastSavedFiles). Not the first +// alone: that // record lags — a file pushed to another device joins it only once that // device has said it has the file — while the other device's own record, // which is what it acts on, already has it. The snapshot is there before the @@ -154,17 +157,32 @@ func (e *Engine) emptiedLocations(game store.Game) (x emptiness, err error) { // lastSavedFiles is what the game's newest snapshot with any files held, by // save location: what its save was just before it was emptied. +// +// A location's files count only from a snapshot taken since it has had the +// folder it has now. An older one's came from somewhere else — another +// folder, or another machine whose backup was restored here, which leaves out +// the files of any location this machine had no folder for yet. Counted, they +// made a location just given its folder, empty until the files arrived, read +// as emptied, and the game was held back instead of fetching them. func (e *Engine) lastSavedFiles(game store.Game) map[string]map[string]struct{} { snaps, err := e.Store.ListSnapshots(game.ID, game.ActiveBranch) // newest first if err != nil { return nil } + mappedSince, err := e.Store.GameRootsMappedSince(game.ID) + if err != nil { + mappedSince = nil // every snapshot counts, as it did before this was recorded + } for _, s := range snaps { + taken, known := snapshotTakenMs(s) out := map[string]map[string]struct{}{} add := func(root, path string) { if path == "" || strings.HasSuffix(path, "/") || dotted(path) { return } + if root != delta.PrimaryRoot && known && taken < mappedSince[root] { + return + } if out[root] == nil { out[root] = map[string]struct{}{} } @@ -188,6 +206,18 @@ func (e *Engine) lastSavedFiles(game store.Game) map[string]map[string]struct{} return nil } +// snapshotTakenMs is when a snapshot was taken, from its timestamp or, failing +// that, its id (snap_). Not known means it counts for every location. +func snapshotTakenMs(s store.Snapshot) (int64, bool) { + if t, err := time.Parse(time.RFC3339Nano, s.Timestamp); err == nil { + return t.UnixMilli(), true + } + if ms, err := strconv.ParseInt(strings.TrimPrefix(s.ID, "snap_"), 10, 64); err == nil && strings.HasPrefix(s.ID, "snap_") { + return ms, true + } + return 0, false +} + // dotted says whether a path is inside a dot-folder or is a dot-file, which // a manifest leaves out. func dotted(path string) bool { diff --git a/internal/p2p/syncengine/hold_test.go b/internal/p2p/syncengine/hold_test.go index 7173f2ec..26996f22 100644 --- a/internal/p2p/syncengine/hold_test.go +++ b/internal/p2p/syncengine/hold_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/opensave/opensave/internal/store" ) @@ -88,3 +89,80 @@ func TestFetchingSyncsHereAndServesNothing(t *testing.T) { t.Error("another device was shown the folder while files were still to come") } } + +// A save location that has only just been given a folder here held nothing +// here before, whatever an older snapshot says it held somewhere else. +// +// The case is a restore onto a replacement PC: the old PC's backup comes in +// as a snapshot holding files for a location this machine has no folder for +// yet, so the restore leaves them out. When the location is then given its +// (empty) folder, that snapshot read as what the location "held before", the +// folder read as every file deleted, and the game was held back — asking +// whether the deletion was meant instead of fetching the files. Found in CI +// (TestJourney_RestoreOntoAFreshMachineAfterALoss, step 5). +func TestNoHoldForALocationGivenItsFirstFolderAfterARestore(t *testing.T) { + env := setupEngine(t) + write(t, env.localDir, "save.dat", "80 hours in") + if err := env.store.SetSyncState("game1", env.peer.ID, []string{"save.dat"}, nil); err != nil { + t.Fatal(err) + } + + // The backup's snapshot: it has the location's files, from a folder that + // is not this machine's. + oldPC := t.TempDir() + write(t, oldPC, "settings.ini", "my keybinds") + if err := env.store.AddGameRoot("game1", "config", oldPC); err != nil { + t.Fatal(err) + } + if _, err := env.engine.Snapshots.Create("game1", "", true); err != nil { + t.Fatal(err) + } + // Known here by name only, as a restore leaves it. + if err := env.store.AddGameRoot("game1", "config", ""); err != nil { + t.Fatal(err) + } + time.Sleep(5 * time.Millisecond) + + // Given its folder here, empty until the files arrive from the other PC. + if err := env.store.AddGameRoot("game1", "config", t.TempDir()); err != nil { + t.Fatal(err) + } + held, err := env.engine.CheckHold("game1", false) + if err != nil || held { + t.Fatalf("CheckHold = %v, %v; want nothing held — the location never had files here", held, err) + } + if h, has, _ := env.store.GetDeletionHold("game1"); has { + t.Errorf("a hold was recorded for %v", h.Files()) + } +} + +// The other side of the above: a location whose own folder was snapshotted +// with files, and is empty now, is held back like any other. +func TestHoldForALocationEmptiedAfterASnapshotOfItsFolder(t *testing.T) { + env := setupEngine(t) + write(t, env.localDir, "save.dat", "80 hours in") + if err := env.store.SetSyncState("game1", env.peer.ID, []string{"save.dat"}, nil); err != nil { + t.Fatal(err) + } + cfg := t.TempDir() + if err := env.store.AddGameRoot("game1", "config", cfg); err != nil { + t.Fatal(err) + } + time.Sleep(5 * time.Millisecond) + write(t, cfg, "settings.ini", "my keybinds") + if _, err := env.engine.Snapshots.Create("game1", "", true); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(cfg, "settings.ini")); err != nil { + t.Fatal(err) + } + + held, err := env.engine.CheckHold("game1", false) + if err != nil || !held { + t.Fatalf("CheckHold = %v, %v; want held", held, err) + } + h, _, _ := env.store.GetDeletionHold("game1") + if got := h.Files()["config"]; len(got) != 1 || got[0] != "settings.ini" { + t.Errorf("the hold covers %v; want the config location's settings.ini", h.Files()) + } +} diff --git a/internal/store/game_roots.go b/internal/store/game_roots.go index 103db968..ed25f80e 100644 --- a/internal/store/game_roots.go +++ b/internal/store/game_roots.go @@ -1,10 +1,13 @@ package store import ( + "database/sql" + "errors" "fmt" slashpath "path" "path/filepath" "strings" + "time" "github.com/opensave/opensave/internal/logging" ) @@ -175,15 +178,53 @@ func (s *Store) AddGameRoot(gameID, name, path string) error { } } } - _, err := s.db.Exec(` + tx, err := s.db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + var before string + if err := tx.Get(&before, `SELECT path FROM game_roots WHERE game_id = ? AND name = ?`, gameID, n); err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("add game root: %w", err) + } + if _, err := tx.Exec(` INSERT INTO game_roots (game_id, name, path, ordinal) VALUES (?, ?, ?, COALESCE((SELECT MAX(ordinal) + 1 FROM game_roots WHERE game_id = ?), 0)) ON CONFLICT(game_id, name) DO UPDATE SET path = excluded.path`, - gameID, n, path, gameID) - if err != nil { + gameID, n, path, gameID); err != nil { return fmt.Errorf("add game root: %w", err) } - return nil + // A different folder from the one it had, or its first: from now on is + // when this folder's contents began (migration 0037). The same folder + // saved again is not a new one. + if strings.TrimSpace(path) != "" && normalizeLocationPath(path) != normalizeLocationPath(before) { + if _, err := tx.Exec(` + INSERT INTO game_root_mapped (game_id, name, mapped_ms) VALUES (?, ?, ?) + ON CONFLICT(game_id, name) DO UPDATE SET mapped_ms = excluded.mapped_ms`, + gameID, n, time.Now().UnixMilli()); err != nil { + return fmt.Errorf("add game root: %w", err) + } + } + return tx.Commit() +} + +// GameRootsMappedSince is when each of a game's extra locations got the +// folder it has now, in Unix milliseconds, for the locations that record it — +// those given a folder since migration 0037. A snapshot older than that holds +// nothing that was ever in this folder. +func (s *Store) GameRootsMappedSince(gameID string) (map[string]int64, error) { + var rows []struct { + Name string `db:"name"` + MappedMs int64 `db:"mapped_ms"` + } + if err := s.db.Select(&rows, `SELECT name, mapped_ms FROM game_root_mapped WHERE game_id = ?`, gameID); err != nil { + return nil, fmt.Errorf("game roots mapped since: %w", err) + } + out := make(map[string]int64, len(rows)) + for _, r := range rows { + out[r.Name] = r.MappedMs + } + return out, nil } // RemoveGameRoot forgets an extra location. The files it pointed at are left @@ -206,6 +247,9 @@ func (s *Store) RemoveGameRoot(gameID, name string) error { if _, err := tx.Exec(`DELETE FROM game_root_sync_state WHERE game_id = ? AND root = ?`, gameID, n); err != nil { return fmt.Errorf("remove game root lineage: %w", err) } + if _, err := tx.Exec(`DELETE FROM game_root_mapped WHERE game_id = ? AND name = ?`, gameID, n); err != nil { + return fmt.Errorf("remove game root: %w", err) + } return tx.Commit() } diff --git a/internal/store/game_roots_test.go b/internal/store/game_roots_test.go index 1bed12a6..07a281ed 100644 --- a/internal/store/game_roots_test.go +++ b/internal/store/game_roots_test.go @@ -1,6 +1,9 @@ package store -import "testing" +import ( + "testing" + "time" +) func rootsTestStore(t *testing.T) *Store { t.Helper() @@ -294,3 +297,64 @@ func TestLearningANewLocationRecordsItUnmapped(t *testing.T) { t.Error("a location learned by name alone was recorded as mapped") } } + +// When a location got the folder it has now: set by its first folder and by a +// different one, left alone by the same folder saved again (the settings +// screen saves every field at once) and by learning the name, and gone with +// the location. +func TestAGameRootRemembersWhenItGotItsFolder(t *testing.T) { + s := rootsTestStore(t) + since := func() (int64, bool) { + t.Helper() + m, err := s.GameRootsMappedSince("elden-ring") + if err != nil { + t.Fatal(err) + } + ms, ok := m["config"] + return ms, ok + } + + if err := s.NoteGameRoot("elden-ring", "config"); err != nil { + t.Fatal(err) + } + if _, ok := since(); ok { + t.Error("a location known by name only has a folder time") + } + + before := time.Now().UnixMilli() + if err := s.AddGameRoot("elden-ring", "config", `C:\Users\me\Documents\ER`); err != nil { + t.Fatal(err) + } + first, ok := since() + if !ok || first < before { + t.Fatalf("first folder: time %d (recorded %v), want at least %d", first, ok, before) + } + + time.Sleep(5 * time.Millisecond) + if err := s.AddGameRoot("elden-ring", "config", `c:/users/me/documents/ER`); err != nil { + t.Fatal(err) + } + if again, _ := since(); again != first { + t.Errorf("the same folder saved again moved the time from %d to %d", first, again) + } + if err := s.NoteGameRoot("elden-ring", "config"); err != nil { + t.Fatal(err) + } + if again, _ := since(); again != first { + t.Errorf("learning the name again moved the time from %d to %d", first, again) + } + + if err := s.AddGameRoot("elden-ring", "config", `D:\Elsewhere\ER`); err != nil { + t.Fatal(err) + } + if moved, _ := since(); moved <= first { + t.Errorf("a different folder left the time at %d, want later than %d", moved, first) + } + + if err := s.RemoveGameRoot("elden-ring", "config"); err != nil { + t.Fatal(err) + } + if _, ok := since(); ok { + t.Error("the folder time outlived the location") + } +} diff --git a/internal/store/migrations/0037_game_root_mapped.sql b/internal/store/migrations/0037_game_root_mapped.sql new file mode 100644 index 00000000..e11a94b1 --- /dev/null +++ b/internal/store/migrations/0037_game_root_mapped.sql @@ -0,0 +1,18 @@ +-- When each extra save location got the folder it has now. A snapshot taken +-- before that says nothing about what this folder held: its files for the +-- location came from another folder, or from another machine — a backup +-- restored onto a replacement PC. Read as what the location held before, +-- they made a location just given its folder, still empty until the files +-- arrived, look emptied, and the game was held back instead of fetching them +-- (internal/p2p/syncengine/hold.go). +-- +-- A table of its own rather than a column on game_roots: older versions read +-- game_roots rows strictly and a new column breaks them; a table they do not +-- know is simply ignored. A location with no row here has had its folder +-- since before this was recorded. +CREATE TABLE game_root_mapped ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + name TEXT NOT NULL, + mapped_ms INTEGER NOT NULL, + PRIMARY KEY (game_id, name) +); From da0706cc472cff757a04a33639e791ab3e7992d4 Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Wed, 30 Sep 2026 17:13:36 +0530 Subject: [PATCH 03/75] Window waits for startup before asking for the service address (#17) --- CHANGELOG.md | 6 ++++ cmd/opensave-app/app.go | 40 ++++++++++++++++++++-- cmd/opensave-app/startup_wait_test.go | 48 +++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 cmd/opensave-app/startup_wait_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a45ed3..596a7721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ All notable changes to OpenSave are documented here. This project adheres to the game was held back, asking whether that was meant, instead of fetching the files from your other devices. What a location held before now counts only from when it got the folder it has. +- **The first launch after boot no longer fails to reach OpenSave.** With a + large library, starting up takes several seconds on a cold disk, and the + window asked for OpenSave's address before there was one: it said it + "can't reach OpenSave's background service at http://" and Retry worked. + The window now waits for startup to finish. Reported with the cause + pinned down in GitHub #17. ## [2.4.0] — 2026-09-30 diff --git a/cmd/opensave-app/app.go b/cmd/opensave-app/app.go index 990ce7f7..10a62ac9 100644 --- a/cmd/opensave-app/app.go +++ b/cmd/opensave-app/app.go @@ -38,16 +38,38 @@ type App struct { reallyQuit bool updatedFrom string // previous version when this run is the first on a new build notifyErr error // why desktop notifications cannot be shown, where known (notify_other.go) + // ready is closed when startup has finished, however it finished. The + // window loads while startup is still running, and what startup sets — + // the address, the boot error, the version updated from — is read only + // after this, never while it is being written (see started). + ready chan struct{} } // NewApp creates the App shell (daemon boots in startup). func NewApp() *App { - return &App{} + return &App{ready: make(chan struct{})} +} + +// startupWait is how long the window waits for startup before saying it is +// taking too long. Generous: a large library on a cold disk takes a while to +// watch, and every second of it is better spent waiting than showing an error +// that Retry would clear. +var startupWait = 3 * time.Minute + +// started waits for startup to finish, and says whether it did in time. +func (a *App) started() bool { + select { + case <-a.ready: + return true + case <-time.After(startupWait): + return false + } } // startup boots the daemon + local API server once the webview exists. func (a *App) startup(ctx context.Context) { a.ctx = ctx + defer close(a.ready) // Asked to stop a running OpenSave, and we ARE the only one: there is // nothing to stop. Exit without booting a daemon, claiming a port or @@ -224,7 +246,7 @@ func (a *App) ChangelogReleases() []changelog.Release { // Empty when this isn't the first run on a new build, so the UI has a single // condition to check rather than reimplementing the comparison. func (a *App) WhatsNew() []changelog.Release { - if a.updatedFrom == "" { + if !a.started() || a.updatedFrom == "" { return nil } return changelog.Since(changelog.Parse(opensave.Changelog), a.updatedFrom) @@ -233,12 +255,26 @@ func (a *App) WhatsNew() []changelog.Release { // UpdateGreeting reports the version this install was just updated FROM // ("" normally) so the UI can announce the update and offer the changelog. func (a *App) UpdateGreeting() map[string]string { + if !a.started() { + return map[string]string{"updatedFrom": ""} + } return map[string]string{"updatedFrom": a.updatedFrom} } // DaemonAddr returns the local API address (host:port) or an error string // if the daemon failed to boot. +// +// It waits for startup to finish. The window asks as soon as it loads, and +// with many games to watch startup is still running then; answering at once +// gave it an empty address, and the first launch after boot failed with +// "can't reach OpenSave's background service at http://" while Retry worked +// (GitHub #17). Bound methods are called asynchronously, so the wait only +// holds the window's first request back until there is an answer to give. func (a *App) DaemonAddr() map[string]string { + if !a.started() { + return map[string]string{"error": "OpenSave's background service is still starting after " + + startupWait.String() + " — its log (opensave.log in the .opensave folder) says what it is doing"} + } if a.bootErr != "" { return map[string]string{"error": a.bootErr} } diff --git a/cmd/opensave-app/startup_wait_test.go b/cmd/opensave-app/startup_wait_test.go new file mode 100644 index 00000000..710a554c --- /dev/null +++ b/cmd/opensave-app/startup_wait_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// The window asks for the service's address as soon as it loads, which is +// usually before startup has finished: with many games to watch, starting +// takes several seconds on a cold disk. Answering straight away handed it an +// empty address — "can't reach OpenSave's background service at http://", +// on the first launch after boot, and Retry worked (GitHub #17). The answer +// waits for startup instead. +func TestDaemonAddrWaitsForStartupToFinish(t *testing.T) { + a := NewApp() + answer := make(chan map[string]string, 1) + go func() { answer <- a.DaemonAddr() }() + + select { + case got := <-answer: + t.Fatalf("DaemonAddr answered %v while startup was still running", got) + case <-time.After(150 * time.Millisecond): + } + + a.addr = "127.0.0.1:8383" + close(a.ready) + select { + case got := <-answer: + if got["addr"] != "127.0.0.1:8383" || got["error"] != "" { + t.Errorf("DaemonAddr = %v, want the address startup found", got) + } + case <-time.After(5 * time.Second): + t.Fatal("DaemonAddr did not answer once startup finished") + } +} + +// A startup that never finishes ends in a reason, not a wait forever. +func TestDaemonAddrGivesUpOnAStartupThatNeverFinishes(t *testing.T) { + was := startupWait + startupWait = 50 * time.Millisecond + defer func() { startupWait = was }() + + got := NewApp().DaemonAddr() + if got["addr"] != "" || !strings.Contains(got["error"], "still starting") { + t.Errorf("DaemonAddr = %v, want an error saying the service is still starting", got) + } +} From b7c0ab25ba5b4912815d13882c27df1438f2014e Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Wed, 30 Sep 2026 17:17:35 +0530 Subject: [PATCH 04/75] Auto-tracked games get a first snapshot and a watch (#16) --- CHANGELOG.md | 7 +++ e2e/autotrack_first_snapshot_test.go | 56 ++++++++++++++++++ internal/daemon/autotrack.go | 85 ++++++++++++++++++++++++++++ internal/daemon/daemon.go | 2 + internal/p2p/engine.go | 7 +++ internal/p2p/routes.go | 3 + 6 files changed, 160 insertions(+) create mode 100644 e2e/autotrack_first_snapshot_test.go create mode 100644 internal/daemon/autotrack.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 596a7721..c2197751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ All notable changes to OpenSave are documented here. This project adheres to "can't reach OpenSave's background service at http://" and Retry worked. The window now waits for startup to finish. Reported with the cause pinned down in GitHub #17. +- **A game another device brings over gets a history here too.** A game + tracked because a paired device synced it got no first snapshot and no + watch. Where the save was already the same on both devices, as Steam Cloud + often leaves them, nothing was ever pulled, so that device never had a + snapshot of the game at all; and until the periodic check came round, a + change made there was not sent on. It now gets both, as a game tracked by + hand does (GitHub #16). ## [2.4.0] — 2026-09-30 diff --git a/e2e/autotrack_first_snapshot_test.go b/e2e/autotrack_first_snapshot_test.go new file mode 100644 index 00000000..b67e3e35 --- /dev/null +++ b/e2e/autotrack_first_snapshot_test.go @@ -0,0 +1,56 @@ +package e2e + +import ( + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/opensave/opensave/testutil" +) + +// A game tracked here because another device asked about it gets what +// tracking it by hand gives: a first snapshot of what is already in its +// folder, and a watch. +// +// Reported in GitHub #16: auto-tracking created the game and nothing else. +// When the save was already the same on both devices — Steam Cloud keeps many +// of them so — every sync after that found nothing to do, so no snapshot was +// ever taken there, and the game had no history on that device at all. It +// was not watched either until the periodic check came round, so a change +// made there in the meantime was not sent on. +func TestAutoTrack_TakesAFirstSnapshotAndWatches(t *testing.T) { + a := testutil.NewTestDaemon(t, "AutoSnap-A") + b := testutil.NewTestDaemon(t, "AutoSnap-B") + a.PairWith(b) + rootA, rootB := t.TempDir(), t.TempDir() + saveA, saveB := filepath.Join(rootA, "Detroit"), filepath.Join(rootB, "Detroit") + writeAt(t, filepath.Join(saveA, "slot1.sav"), "chapter 12") + writeAt(t, filepath.Join(saveB, "slot1.sav"), "chapter 12") + b.API(http.MethodPost, "/api/settings", map[string]any{ + "pathTranslations": []map[string]string{{"fromPattern": rootA, "toPattern": rootB}}, + }, nil) + + var tracked gameRow + a.API(http.MethodPost, "/api/games", map[string]string{"name": "Detroit", "savePath": saveA}, &tracked) + a.API(http.MethodPost, "/api/games/"+tracked.ID+"/sync", nil, nil) + + if !testutil.WaitFor(30*time.Second, func() bool { return gamesOf(b)[tracked.ID].SavePath == saveB }) { + t.Fatalf("B never auto-tracked the game at its own folder; B has %+v", gamesOf(b)) + } + if !testutil.WaitFor(30*time.Second, func() bool { + snaps, _ := b.Daemon.Store.ListSnapshots(tracked.ID, "main") + return len(snaps) > 0 + }) { + t.Fatal("the auto-tracked game has no snapshot on B, though its folder held the save") + } + if !testutil.WaitFor(30*time.Second, func() bool { + _, watching := b.Daemon.Watcher.Watching(tracked.ID) + return watching + }) { + t.Error("the auto-tracked game is not watched on B") + } + if got := readAt(filepath.Join(saveB, "slot1.sav")); got != "chapter 12" { + t.Errorf("B's save changed to %q", got) + } +} diff --git a/internal/daemon/autotrack.go b/internal/daemon/autotrack.go new file mode 100644 index 00000000..039e1326 --- /dev/null +++ b/internal/daemon/autotrack.go @@ -0,0 +1,85 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/opensave/opensave/internal/store" + "github.com/opensave/opensave/internal/watcher" +) + +// adoptAutoTracked gives a game tracked because a paired device asked for it +// what TrackGame gives one tracked by hand: a first snapshot of what its +// folder already holds, and a watch. +// +// Auto-tracking used to create the game and nothing else (GitHub #16). Where +// the save was already the same on both devices — Steam Cloud keeps many of +// them so — no sync ever pulled anything, so no snapshot was ever taken, and +// the game had no history on this device at all. Nor was it watched until the +// periodic check came round, so a change made here meanwhile went nowhere. +// +// In the background: this is called inside the other device's manifest +// request, which must not wait on a snapshot of a large save. Counted with +// TrackGame's own, so Stop waits for it. +// +// Only a folder that already has a file is snapshotted. An empty one has +// nothing to keep, and what a sync brings into it is snapshotted as it +// arrives. The snapshot is taken while no sync is writing the folder: this +// runs just as the other device starts syncing it, and a snapshot of a pull +// half done would be a copy of a save that never existed. +func (d *Daemon) adoptAutoTracked(game store.Game) { + d.initialSnapshots.Add() + go func() { + defer d.initialSnapshots.Done() + if saveHasAFile(game.SavePath) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + readDone, err := d.P2P.Sync.Reading(ctx, game.ID) + cancel() + if err == nil { + _, err = d.Snapshots.Create(game.ID, "Initial snapshot", true) + readDone() + } + if err != nil { + d.Log.Log("warn", fmt.Sprintf("initial snapshot for %q failed: %v", game.Name, err)) + } + } + if _, err := d.Store.GetGame(game.ID); err != nil { + return // untracked meanwhile: no watch for a game that is gone + } + if err := d.watchGame(game.ID, game.SavePath); err != nil && !errors.Is(err, watcher.ErrStopped) { + d.Log.Log("warn", fmt.Sprintf("could not watch %q: %v", game.Name, err)) + } + }() +} + +// errFoundAFile ends saveHasAFile's walk at the first file. +var errFoundAFile = errors.New("found a file") + +// saveHasAFile says whether a save — a folder, or a single file — holds any +// file at all. A folder of empty folders does not: a snapshot of it would be +// empty. Stops at the first file, so a save of a quarter of a million files +// costs no more than one of ten. +func saveHasAFile(savePath string) bool { + info, err := os.Stat(savePath) + if err != nil { + return false + } + if !info.IsDir() { + return true + } + err = filepath.WalkDir(savePath, func(_ string, entry fs.DirEntry, err error) error { + if err != nil { + return nil // an unreadable corner is not a file + } + if !entry.IsDir() { + return errFoundAFile + } + return nil + }) + return errors.Is(err, errFoundAFile) +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index e15b3508..659876f4 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -166,6 +166,8 @@ func New(opts Options) (*Daemon, error) { // A paired peer untracking/re-tracking a game mirrors here. d.P2P.OnUntrackRequest = d.untrackFromPeer d.P2P.OnRetrackRequest = d.retrackFromPeer + // A game tracked because a peer asked for it is set up as TrackGame would. + d.P2P.OnAutoTracked = d.adoptAutoTracked // A Switch save a peer syncs goes into this device's own emulator profile. d.P2P.SwitchSaveFolder = d.Scanner.SwitchSaveFolder diff --git a/internal/p2p/engine.go b/internal/p2p/engine.go index 4f7e5122..f59caa03 100644 --- a/internal/p2p/engine.go +++ b/internal/p2p/engine.go @@ -65,6 +65,13 @@ type Engine struct { OnUntrackRequest func(gameID string) OnRetrackRequest func(gameID string) + // OnAutoTracked fires when a game has just been tracked here because a + // paired device asked for it (ensureManifestGame), so the daemon can give + // it what tracking by hand does: a watch, and a first snapshot of what is + // already in its folder. Without it the game had neither (GitHub #16). + // Wired by the daemon. May be nil. + OnAutoTracked func(game store.Game) + // SwitchSaveFolder picks where a Switch save arriving from a peer belongs // on this device (presets.Scanner.SwitchSaveFolder). Wired by the daemon. // May be nil. diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index 97dedad9..e6bf83fe 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -594,6 +594,9 @@ func (e *Engine) ensureManifestGame(gameID string, q manifestGameQuery, peerID s _ = os.MkdirAll(localPath, 0o777) } e.Log("info", fmt.Sprintf("auto-tracked %q at %s from peer manifest request", q.Name, logging.Quote(localPath))) + if e.OnAutoTracked != nil { + e.OnAutoTracked(game) + } e.notifyGamesUpdate() return game, nil } From 66ca090bc34a55ef6c3be41995b04db59582d2d1 Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Wed, 30 Sep 2026 17:20:38 +0530 Subject: [PATCH 05/75] A peer coming back online starts one sync of everything (#15) --- internal/p2p/engine.go | 5 ++ internal/p2p/reconnect_storm_test.go | 75 ++++++++++++++++++++++++++++ internal/p2p/routes.go | 21 ++++++-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 internal/p2p/reconnect_storm_test.go diff --git a/internal/p2p/engine.go b/internal/p2p/engine.go index f59caa03..428ccb5b 100644 --- a/internal/p2p/engine.go +++ b/internal/p2p/engine.go @@ -100,6 +100,11 @@ type Engine struct { farewellMu sync.Mutex farewells map[string]*farewell + // Held while a request decides whether its peer has just come online, so + // the several requests a returning device sends at once start one sync of + // everything between them, not one each (requirePairedPeer). + onlineMu sync.Mutex + // Live per-peer app build info (version + build time) learned from // pings/hellos, powering the "update from this device" flow. buildMu sync.Mutex diff --git a/internal/p2p/reconnect_storm_test.go b/internal/p2p/reconnect_storm_test.go new file mode 100644 index 00000000..57771945 --- /dev/null +++ b/internal/p2p/reconnect_storm_test.go @@ -0,0 +1,75 @@ +package p2p + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/opensave/opensave/internal/store" +) + +// A device coming back online syncs everything once, not once per request. +// +// Reported in GitHub #15: a device that was offline sends several requests at +// once when it comes back, and each of them read the stored "offline" before +// any had written "online" — so each one started a sync of every game. The +// log showed some twenty "connected; triggering auto-sync for all games" +// within a second for one device. +func TestAPeerComingBackOnlineStartsOneSyncOfEverything(t *testing.T) { + s, err := store.Open(filepath.Join(t.TempDir(), "opensave.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + if err := s.EnsureDefaultSettings(t.TempDir(), t.TempDir()); err != nil { + t.Fatal(err) + } + // Paired before keys were exchanged, so matched by address. + if err := s.UpsertPeer(store.Peer{ID: "peer-back", Name: "Back Again", Address: "192.0.2.10", Status: "offline"}); err != nil { + t.Fatal(err) + } + + var triggered atomic.Int32 + e := &Engine{Store: s, Log: func(level, msg string) { + if strings.Contains(msg, "triggering auto-sync for all games") { + triggered.Add(1) + } + }} + handler := e.requirePairedPeer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + const requests = 20 + var ready, done sync.WaitGroup + start := make(chan struct{}) + ready.Add(requests) + done.Add(requests) + for i := 0; i < requests; i++ { + go func() { + defer done.Done() + req := httptest.NewRequest(http.MethodGet, "/manifest/g1", nil) + req.RemoteAddr = "192.0.2.10:40000" + rec := httptest.NewRecorder() + ready.Done() + <-start + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("request refused with %d: %s", rec.Code, rec.Body.String()) + } + }() + } + ready.Wait() + close(start) + done.Wait() + + if got := triggered.Load(); got != 1 { + t.Errorf("coming back online started %d syncs of every game, want 1", got) + } + if p, _ := s.GetPeer("peer-back"); p.Status != "online" { + t.Errorf("the peer is %q after its requests, want online", p.Status) + } +} diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index e6bf83fe..fcdd6456 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -143,13 +143,26 @@ func (e *Engine) requirePairedPeer(next http.Handler) http.Handler { // Throttled online-status refresh (10s), with auto-sync on // offline->online transition. + // + // Decided under onlineMu, on the peer as it is stored now rather than + // as the list above read it. A device coming back sends several + // requests at once; each read "offline" before any had written + // "online", so each started a sync of every game — twenty in a second + // for one device (GitHub #15). The lock is taken only when the list + // says an update is due, so the ordinary request does not wait on it. const lastSeenLimit = 10_000 now := time.Now().UnixMilli() if matched.Status != "online" || now-matched.LastSeenMs > lastSeenLimit { - wasOffline := matched.Status != "online" - matched.Status = "online" - matched.LastSeenMs = now - _ = e.Store.UpdatePeer(*matched) + e.onlineMu.Lock() + wasOffline := false + if current, err := e.Store.GetPeer(matched.ID); err == nil && + (current.Status != "online" || now-current.LastSeenMs > lastSeenLimit) { + wasOffline = current.Status != "online" + current.Status = "online" + current.LastSeenMs = now + _ = e.Store.UpdatePeer(current) + } + e.onlineMu.Unlock() if wasOffline { e.Log("info", fmt.Sprintf("peer %q connected; triggering auto-sync for all games", matched.Name)) e.GoSync(func(ctx context.Context) { e.SyncAllGames(ctx) }) From 2849c8a7b42b2a58eaa194d83f4de7235c88d633 Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Wed, 30 Sep 2026 17:20:38 +0530 Subject: [PATCH 06/75] Reuse hashing read buffers (#15) --- internal/delta/hash_buffer_test.go | 41 ++++++++++++++++++++++++++++++ internal/delta/manifest.go | 27 +++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 internal/delta/hash_buffer_test.go diff --git a/internal/delta/hash_buffer_test.go b/internal/delta/hash_buffer_test.go new file mode 100644 index 00000000..e56f9fc5 --- /dev/null +++ b/internal/delta/hash_buffer_test.go @@ -0,0 +1,41 @@ +package delta + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// Hashing a file does not allocate a whole block buffer each time. +// +// Reported in GitHub #15: a Project Zomboid save is some 238,000 small files, +// and a fresh buffer of a full block per file — 64 KB or more whatever the +// file's size — made one manifest build allocate 15 GB. The garbage collector +// kept up with none of it on a busy machine. +func TestHashFileReusesItsReadBuffer(t *testing.T) { + path := filepath.Join(t.TempDir(), "map_1162_1180.bin") + if err := os.WriteFile(path, make([]byte, 300), 0o666); err != nil { + t.Fatal(err) + } + if _, err := HashFile(path); err != nil { // warm up + t.Fatal(err) + } + + const files = 200 + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + for i := 0; i < files; i++ { + if _, err := HashFile(path); err != nil { + t.Fatal(err) + } + } + runtime.ReadMemStats(&after) + + perFile := (after.TotalAlloc - before.TotalAlloc) / files + if perFile >= defaultBlockSize/2 { + t.Errorf("hashing a 300-byte file allocates %d bytes each time, most of a %d-byte block buffer", + perFile, defaultBlockSize) + } +} diff --git a/internal/delta/manifest.go b/internal/delta/manifest.go index bf1fb173..f1293ec9 100644 --- a/internal/delta/manifest.go +++ b/internal/delta/manifest.go @@ -15,6 +15,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/opensave/opensave/internal/fsx" @@ -153,6 +154,28 @@ func hashBytes(b []byte) string { return hex.EncodeToString(sum[:]) } +// hashBuffers holds HashFile's read buffers for reuse, one pool per block +// size. A buffer of a whole block per file, whatever the file's size, made a +// save of 238,000 small files allocate 15 GB per manifest build (GitHub #15). +// Nothing keeps a buffer past the call: each chunk is hashed where it lies. +var hashBuffers = map[int]*sync.Pool{ + defaultBlockSize: newHashBufferPool(defaultBlockSize), + mediumBlockSize: newHashBufferPool(mediumBlockSize), + largeBlockSize: newHashBufferPool(largeBlockSize), +} + +func newHashBufferPool(size int) *sync.Pool { + return &sync.Pool{New: func() any { + b := make([]byte, size) + return &b + }} +} + +// hashBuffer is a read buffer of one block, from its pool. +func hashBuffer(blockSize int) *[]byte { + return hashBuffers[blockSize].Get().(*[]byte) +} + // HashFile computes the whole-file SHA-256 and per-block SHA-256 list for // the file at path, using the block size dictated by its size. func HashFile(path string) (FileEntry, error) { @@ -169,7 +192,9 @@ func HashFile(path string) (FileEntry, error) { defer f.Close() whole := sha256.New() - buf := make([]byte, blockSize) + pooled := hashBuffer(blockSize) + defer hashBuffers[blockSize].Put(pooled) + buf := *pooled var blocks []Block index := 0 for { From 2b79d183d50e10d1b0d55ab945d7800fdad3f638 Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Wed, 30 Sep 2026 17:25:54 +0530 Subject: [PATCH 07/75] Coalesce overlapping manifest builds of one folder (#15) --- internal/delta/manifest.go | 6 ++ internal/delta/manifest_builds.go | 143 +++++++++++++++++++++++++ internal/delta/manifest_builds_test.go | 83 ++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 internal/delta/manifest_builds.go create mode 100644 internal/delta/manifest_builds_test.go diff --git a/internal/delta/manifest.go b/internal/delta/manifest.go index f1293ec9..f70016e5 100644 --- a/internal/delta/manifest.go +++ b/internal/delta/manifest.go @@ -228,7 +228,13 @@ func HashFile(path string) (FileEntry, error) { // BuildManifest walks root (a directory or a single file, per // ResolveLocalSaveFilePath) and returns a full Manifest of its contents. +// Builds of one folder that overlap are coalesced (manifest_builds.go); the +// walk itself is buildManifest. func BuildManifest(root string) (Manifest, error) { + return builds.build(root) +} + +func buildManifest(root string) (Manifest, error) { // Never scan profile/system-level folders. A mis-tracked game pointing // at e.g. C:\Users\ would otherwise try to hash (and sync!) the // whole user profile — and die on the first legacy junction anyway. diff --git a/internal/delta/manifest_builds.go b/internal/delta/manifest_builds.go new file mode 100644 index 00000000..ce4ed148 --- /dev/null +++ b/internal/delta/manifest_builds.go @@ -0,0 +1,143 @@ +package delta + +import ( + "path/filepath" + "sync" + "sync/atomic" +) + +// Builds of one folder's manifest that overlap are coalesced. +// +// A manifest is asked for from many places at once: a sync of the game here, +// each paired device asking for it, the check for an emptied save, the +// watcher, cloud backup. For a save of a quarter of a million files each of +// those walked the whole folder, and on a busy machine the walks piled up +// faster than they finished (GitHub #15). +// +// What must not happen is handing out a walk that began before the caller +// asked. A sync started because a file changed would be given a manifest from +// before the change, find nothing to send, and the change would wait for the +// next periodic check. So a caller that arrives while a build is running does +// not share that build: it waits for the next one, which starts once the +// running one is done and is shared by everyone who arrived meanwhile. Every +// caller's manifest comes from a walk that started after it asked, and a +// folder is walked at most twice at a time however many ask. +type manifestBuilds struct { + mu sync.Mutex + folders map[string]*folderBuilds +} + +// folderBuilds is one folder's builds: whether one is running, and the call +// the next one will answer, if anyone is waiting for it. +type folderBuilds struct { + running bool + next *manifestCall +} + +// manifestCall is one build's result, for the callers sharing it. +type manifestCall struct { + done chan struct{} + m Manifest + err error + takers atomic.Int32 +} + +var builds = &manifestBuilds{folders: map[string]*folderBuilds{}} + +// Test hooks: after a build's walk, before its result is handed out; and when +// a caller settles down to wait for the next build. +var ( + testHookBuilt func(root string) + testHookJoined func(root string) +) + +func (b *manifestBuilds) build(root string) (Manifest, error) { + key := filepath.Clean(root) + b.mu.Lock() + f := b.folders[key] + if f == nil { + f = &folderBuilds{} + b.folders[key] = f + } + if f.running { + if f.next == nil { + f.next = &manifestCall{done: make(chan struct{})} + } + call := f.next + call.takers.Add(1) + b.mu.Unlock() + if testHookJoined != nil { + testHookJoined(key) + } + <-call.done + return call.take() + } + f.running = true + b.mu.Unlock() + + m, err := buildManifest(root) + if testHookBuilt != nil { + testHookBuilt(key) + } + b.runNext(key, f, root) + return m, err +} + +// runNext starts the build the callers who arrived during the last one are +// waiting for, or marks the folder idle if nobody is. The build runs on its +// own: the caller that finished the last one has its answer and goes. +func (b *manifestBuilds) runNext(key string, f *folderBuilds, root string) { + b.mu.Lock() + call := f.next + f.next = nil + if call == nil { + f.running = false + delete(b.folders, key) + b.mu.Unlock() + return + } + b.mu.Unlock() + go func() { + call.m, call.err = buildManifest(root) + if testHookBuilt != nil { + testHookBuilt(key) + } + close(call.done) + b.runNext(key, f, root) + }() +} + +// take hands one caller the shared result. Every caller but the last gets its +// own copy of the file lists: nothing here edits a manifest in place today, +// and a shared one is where a change that starts to would go wrong quietly. +func (c *manifestCall) take() (Manifest, error) { + if c.err != nil { + return Manifest{}, c.err + } + if c.takers.Add(-1) == 0 { + return c.m, nil + } + return c.m.clone(), nil +} + +// clone copies a manifest's maps and lists. File entries are values; their +// block lists are shared, as the hash cache already shares them. +func (m Manifest) clone() Manifest { + c := m + c.Files = make(map[string]FileEntry, len(m.Files)) + for p, e := range m.Files { + c.Files[p] = e + } + c.Dirs = append([]string(nil), m.Dirs...) + if m.Extra != nil { + c.Extra = make(map[string]RootManifest, len(m.Extra)) + for name, r := range m.Extra { + files := make(map[string]FileEntry, len(r.Files)) + for p, e := range r.Files { + files[p] = e + } + c.Extra[name] = RootManifest{Files: files, Dirs: append([]string(nil), r.Dirs...)} + } + } + return c +} diff --git a/internal/delta/manifest_builds_test.go b/internal/delta/manifest_builds_test.go new file mode 100644 index 00000000..a084fe75 --- /dev/null +++ b/internal/delta/manifest_builds_test.go @@ -0,0 +1,83 @@ +package delta + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +// Callers who ask for a folder's manifest while it is being built share one +// build between them — the next one, which starts after they asked, so each +// sees what the folder held when it asked (manifest_builds.go, GitHub #15). +func TestOverlappingBuildsOfOneFolderShareTheNextWalk(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.sav"), []byte("a"), 0o666); err != nil { + t.Fatal(err) + } + + var walks atomic.Int32 + firstWalked, release := make(chan struct{}), make(chan struct{}) + joined := make(chan struct{}, 16) + testHookBuilt = func(string) { + if walks.Add(1) == 1 { + close(firstWalked) + <-release // the first build's answer is held back while others arrive + } + } + testHookJoined = func(string) { joined <- struct{}{} } + defer func() { testHookBuilt, testHookJoined = nil, nil }() + + type answer struct { + m Manifest + err error + } + first := make(chan answer, 1) + go func() { m, err := BuildManifest(dir); first <- answer{m, err} }() + <-firstWalked + + // Written after the first walk: a caller asking from now on must see it. + if err := os.WriteFile(filepath.Join(dir, "b.sav"), []byte("b"), 0o666); err != nil { + t.Fatal(err) + } + const late = 9 + later := make(chan answer, late) + for i := 0; i < late; i++ { + go func() { m, err := BuildManifest(dir); later <- answer{m, err} }() + } + for i := 0; i < late; i++ { + select { + case <-joined: + case <-time.After(10 * time.Second): + close(release) + t.Fatalf("only %d of %d callers waited for the next build; the rest walked the folder themselves (%d walks)", + i, late, walks.Load()) + } + } + close(release) + + if a := <-first; a.err != nil || len(a.m.Files) != 1 { + t.Errorf("the first caller got %d files (%v), want the folder as it was: a.sav", len(a.m.Files), a.err) + } + for i := 0; i < late; i++ { + a := <-later + if a.err != nil { + t.Fatal(a.err) + } + if _, ok := a.m.Files["b.sav"]; !ok { + t.Errorf("a caller who asked after b.sav was written got a manifest without it: %v", a.m.Files) + } + } + if got := walks.Load(); got != 2 { + t.Errorf("the folder was walked %d times for %d callers, want 2", got, late+1) + } + + // Each caller has a manifest of its own. + one, _ := BuildManifest(dir) + two, _ := BuildManifest(dir) + delete(one.Files, "a.sav") + if _, ok := two.Files["a.sav"]; !ok { + t.Error("two callers' manifests share one file map") + } +} From a68fc1420b01a225ee9658487f4dfd85e12a55c5 Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Wed, 30 Sep 2026 17:26:27 +0530 Subject: [PATCH 08/75] Changelog for the #15 fixes --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2197751..9f79e10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ All notable changes to OpenSave are documented here. This project adheres to snapshot of the game at all; and until the periodic check came round, a change made there was not sent on. It now gets both, as a game tracked by hand does (GitHub #16). +- **A save of a great many files no longer wears the machine down.** A + device coming back online started a sync of every game once for each + request it sent — some twenty at once — instead of once; a folder was walked + again by everything that asked about it at the same moment, where they now + share one walk; and hashing allocated a fresh buffer for every file, which + for a Project Zomboid save of 238,000 files came to gigabytes per pass. + 2.4's hash cache had already stopped most of the re-reading behind the + report (GitHub #15). ## [2.4.0] — 2026-09-30 From 9f7b4f01d11fbbe5dd0e4d42ec29a8d71258d5e6 Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Thu, 1 Oct 2026 19:45:27 +0530 Subject: [PATCH 09/75] Peers can no longer have arbitrary folders tracked; unsigned requests refused (CVE-2026-103398) --- internal/daemon/daemon.go | 9 ++ internal/p2p/cve_2026_103398_test.go | 159 +++++++++++++++++++++++++++ internal/p2p/engine.go | 5 + internal/p2p/reconnect_storm_test.go | 37 ++++--- internal/p2p/routes.go | 63 +++++++++-- internal/p2p/wanclient_handlers.go | 19 +++- internal/presets/knownroots.go | 45 ++++++++ 7 files changed, 305 insertions(+), 32 deletions(-) create mode 100644 internal/p2p/cve_2026_103398_test.go create mode 100644 internal/presets/knownroots.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 659876f4..d8779b0a 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -170,6 +170,15 @@ func New(opts Options) (*Daemon, error) { d.P2P.OnAutoTracked = d.adoptAutoTracked // A Switch save a peer syncs goes into this device's own emulator profile. d.P2P.SwitchSaveFolder = d.Scanner.SwitchSaveFolder + // What a game arriving from a peer may be tracked at without asking: + // a folder this device's own scanner noted as a save, or one inside an + // emulator's save folder here. Anything else is offered to the user. + d.P2P.KnownSaveLocation = func(path string) bool { + if known, err := d.Store.IsKnownSave(path); err == nil && known { + return true + } + return d.Scanner.InsideEmulatorSaveRoot(path) + } // Every new snapshot mirrors to the configured cloud provider in the // background; failures are logged, never fatal. diff --git a/internal/p2p/cve_2026_103398_test.go b/internal/p2p/cve_2026_103398_test.go new file mode 100644 index 00000000..bd2315b4 --- /dev/null +++ b/internal/p2p/cve_2026_103398_test.go @@ -0,0 +1,159 @@ +package p2p + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/opensave/opensave/internal/store" +) + +// CVE-2026-103398: a paired device — or anyone able to pose as one — named a +// folder in a manifest request, and this device tracked it as a game and +// served its files. Here the folder holds an SSH key. +// +// The attacker signs its requests properly: what is tested is the path, not +// the authentication (see the unsigned tests below for that). + +type cveFixture struct { + *authFixture + router http.Handler + secret string + home string +} + +func newCVEFixture(t *testing.T) *cveFixture { + t.Helper() + // Auto-tracking creates folders under the home a path translates to. + home := t.TempDir() + t.Setenv("USERPROFILE", home) + t.Setenv("HOME", home) + + f := newAuthFixture(t, true) + peer := f.peer + peer.Address = "192.0.2.10" + if err := f.store.UpdatePeer(peer); err != nil { + t.Fatal(err) + } + f.peer = peer + + secret := filepath.Join(t.TempDir(), "ssh") + if err := os.MkdirAll(secret, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(secret, "id_ed25519"), []byte("-----BEGIN OPENSSH PRIVATE KEY-----"), 0o600); err != nil { + t.Fatal(err) + } + + r := chi.NewRouter() + f.engine.RegisterRoutes(r) + return &cveFixture{authFixture: f, router: r, secret: secret, home: home} +} + +// askManifest is the attacker's request: a game it makes up, at the folder it +// wants. +func (c *cveFixture) askManifest(t *testing.T, gameID, savePath string) *httptest.ResponseRecorder { + t.Helper() + q := url.Values{"name": {"Totally A Game"}, "savePath": {savePath}} + req := c.lanRequest(t, http.MethodGet, "/api/p2p/manifest/"+gameID+"?"+q.Encode(), nil) + req.RemoteAddr = "192.0.2.10:40000" + rec := httptest.NewRecorder() + c.router.ServeHTTP(rec, req) + return rec +} + +func TestCVE_2026_103398_APeerCannotHaveAnyFolderTracked(t *testing.T) { + c := newCVEFixture(t) + + rec := c.askManifest(t, "totally-a-game", c.secret) + + body, _ := io.ReadAll(rec.Body) + if strings.Contains(string(body), "id_ed25519") { + t.Fatalf("the folder's files were listed to the peer: HTTP %d %s", rec.Code, body) + } + if rec.Code == http.StatusOK { + t.Fatalf("the manifest was served: HTTP %d %s", rec.Code, body) + } + if g, err := c.store.GetGame("totally-a-game"); err == nil { + t.Fatalf("the folder was tracked as a game at %s", g.SavePath) + } + // Not dropped: offered, for the user to place on this device if it is + // a real game. + offered, err := c.store.ListOfferedGames() + if err != nil { + t.Fatal(err) + } + if len(offered) != 1 || offered[0].GameID != "totally-a-game" { + t.Errorf("offered games = %+v, want the one asked for", offered) + } +} + +// What this device's own scanner knows as a save folder still syncs by +// itself, as before. +func TestCVE_2026_103398_AKnownSaveFolderStillAutoTracks(t *testing.T) { + c := newCVEFixture(t) + // Where Hollow Knight keeps its save under this device's home: the folder + // the other PC's path names, once translated. + save := filepath.Join(c.home, "AppData", "LocalLow", "Team Cherry", "Hollow Knight") + if err := os.MkdirAll(save, 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(save, "user1.dat"), []byte("save"), 0o666); err != nil { + t.Fatal(err) + } + same := func(a, b string) bool { return strings.EqualFold(filepath.Clean(a), filepath.Clean(b)) } + c.engine.KnownSaveLocation = func(p string) bool { return same(p, save) } + peerPath := `C:\Users\alice\AppData\LocalLow\Team Cherry\Hollow Knight` + + // The tracking step itself: serving the manifest needs a whole sync + // engine, which this fixture does not build. + g, err := c.engine.ensureManifestGame("hollow-knight", manifestGameQuery{Name: "Hollow Knight", SavePath: peerPath}, c.peer.ID) + if err != nil { + t.Fatalf("a known save folder was refused: %v", err) + } + if !same(g.SavePath, save) { + t.Errorf("tracked at %s, want the known folder %s", g.SavePath, save) + } +} + +// The second half of the report: requests that were never signed with the +// pairing's key are not served, over the network or the relay. +func TestCVE_2026_103398_UnsignedLANRequestIsRefused(t *testing.T) { + c := newCVEFixture(t) + // A device that has not signed anything yet, so the old latch had not + // closed: the window the advisory's spoofing walked through. + if err := c.store.MarkPeerAuthVerified(c.peer.ID, 0); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/api/p2p/games", nil) + req.RemoteAddr = "192.0.2.10:40000" + rec := httptest.NewRecorder() + c.router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("an unsigned request from the paired address was served: HTTP %d", rec.Code) + } +} + +func TestCVE_2026_103398_UnsignedRelayRequestFromAKeylessPairingIsRefused(t *testing.T) { + f := newAuthFixture(t, false) // paired over the relay before 2.4.0: no key + w := &WanClient{engine: f.engine} + for _, route := range []string{"/games", "/manifest/g1", "/blocks/g1", "/delete-file/g1"} { + status, _ := w.routeRequest(t.Context(), RelayMessage{Type: "request", From: f.peer.ID, Route: route, Method: http.MethodGet}) + if status != http.StatusUnauthorized { + t.Errorf("%s from a keyless pairing: HTTP %d, want 401", route, status) + } + } + // Unpairing still works, so such a pair can be cleared up. + if status, _ := w.routeRequest(t.Context(), RelayMessage{Type: "request", From: f.peer.ID, Route: "/unpair", Method: http.MethodPost}); status == http.StatusUnauthorized { + t.Error("unpairing a keyless pairing was refused") + } +} + +var _ = store.Peer{} diff --git a/internal/p2p/engine.go b/internal/p2p/engine.go index 428ccb5b..b1777e2e 100644 --- a/internal/p2p/engine.go +++ b/internal/p2p/engine.go @@ -76,6 +76,11 @@ type Engine struct { // on this device (presets.Scanner.SwitchSaveFolder). Wired by the daemon. // May be nil. SwitchSaveFolder func(titleID, translated string) string + // KnownSaveLocation reports whether a folder is one this device's own + // scanner recognises as a save folder: the only kind a game arriving + // from a peer may be tracked at without the user's say-so (see + // ensureManifestGame). Wired by the daemon; nil knows of nothing. + KnownSaveLocation func(path string) bool // Failsafe: games whose last sync was interrupted (network error mid- // transfer) are queued here and retried automatically, no prompt, until diff --git a/internal/p2p/reconnect_storm_test.go b/internal/p2p/reconnect_storm_test.go index 57771945..68a0899d 100644 --- a/internal/p2p/reconnect_storm_test.go +++ b/internal/p2p/reconnect_storm_test.go @@ -1,15 +1,16 @@ package p2p import ( + "fmt" "net/http" "net/http/httptest" - "path/filepath" + "strconv" "strings" "sync" "sync/atomic" "testing" - "github.com/opensave/opensave/internal/store" + "github.com/opensave/opensave/internal/e2ee" ) // A device coming back online syncs everything once, not once per request. @@ -20,25 +21,23 @@ import ( // log showed some twenty "connected; triggering auto-sync for all games" // within a second for one device. func TestAPeerComingBackOnlineStartsOneSyncOfEverything(t *testing.T) { - s, err := store.Open(filepath.Join(t.TempDir(), "opensave.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { s.Close() }) - if err := s.EnsureDefaultSettings(t.TempDir(), t.TempDir()); err != nil { - t.Fatal(err) - } - // Paired before keys were exchanged, so matched by address. - if err := s.UpsertPeer(store.Peer{ID: "peer-back", Name: "Back Again", Address: "192.0.2.10", Status: "offline"}); err != nil { + // A device paired with keys, as every 2.4 pairing is, and offline. Its + // requests are signed: an unsigned one is no longer served at all. + f := newAuthFixture(t, true) + s := f.store + peer := f.peer + peer.Address, peer.Status = "192.0.2.10", "offline" + if err := s.UpdatePeer(peer); err != nil { t.Fatal(err) } var triggered atomic.Int32 - e := &Engine{Store: s, Log: func(level, msg string) { + e := f.engine + e.Log = func(level, msg string) { if strings.Contains(msg, "triggering auto-sync for all games") { triggered.Add(1) } - }} + } handler := e.requirePairedPeer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -49,10 +48,14 @@ func TestAPeerComingBackOnlineStartsOneSyncOfEverything(t *testing.T) { ready.Add(requests) done.Add(requests) for i := 0; i < requests; i++ { + req := f.lanRequest(t, http.MethodGet, "/manifest/g1", nil) + req.Header.Set(lanAuthNonceHeader, fmt.Sprintf("storm-%d", i)) + local, _ := s.GetSettings() + at, _ := strconv.ParseInt(req.Header.Get(lanAuthTimeHeader), 10, 64) + req.Header.Set(lanAuthHeader, e2ee.RequestMAC(f.authKey, peer.ID, local.NodeID, req.URL.RequestURI(), http.MethodGet, nil, fmt.Sprintf("storm-%d", i), at)) + req.RemoteAddr = "192.0.2.10:40000" go func() { defer done.Done() - req := httptest.NewRequest(http.MethodGet, "/manifest/g1", nil) - req.RemoteAddr = "192.0.2.10:40000" rec := httptest.NewRecorder() ready.Done() <-start @@ -69,7 +72,7 @@ func TestAPeerComingBackOnlineStartsOneSyncOfEverything(t *testing.T) { if got := triggered.Load(); got != 1 { t.Errorf("coming back online started %d syncs of every game, want 1", got) } - if p, _ := s.GetPeer("peer-back"); p.Status != "online" { + if p, _ := s.GetPeer(peer.ID); p.Status != "online" { t.Errorf("the peer is %q after its requests, want online", p.Status) } } diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index fcdd6456..67d11a5c 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -63,6 +63,11 @@ func clientIP(r *http.Request) string { return host } +// unsignedRefusal is what a paired device is told when its request was not +// signed with the pairing's key, over the network or the relay. +const unsignedRefusal = "Unauthorized: this request was not signed with the key from pairing. " + + "Update both devices to OpenSave 2.4.1 or later; if they were paired over the internet before 2.4.0, unpair and pair them again." + // requirePairedPeer allows localhost (dashboard/CLI) plus IPs matching a // paired peer. A valid request from a paired peer also refreshes its // online status, and a peer coming back online triggers a full auto-sync @@ -121,6 +126,19 @@ func (e *Engine) requirePairedPeer(next http.Handler) http.Handler { jsonError(w, http.StatusUnauthorized, "Unauthorized: Requesting peer is not paired.") return } + // Unsigned, the request has only its source address to say who sent + // it, and an address on a shared network can be taken. So nothing + // that reads or writes saves is served on it (CVE-2026-103398) — + // only unpairing, which gives nothing away and is how a pairing too + // old to sign is cleared up. + if provenID == "" && matched.AuthVerifiedMs == 0 && r.URL.Path != "/api/p2p/unpair" { + e.Log("warn", fmt.Sprintf( + "refused an unsigned request from %q: it was not signed with the key from pairing. "+ + "If it runs a version before 2.4.0, update it; if they were paired long ago, unpair and pair the two again.", + matched.Name)) + jsonError(w, http.StatusUnauthorized, unsignedRefusal) + return + } // A peer that has proved itself before must keep doing so, or an // attacker simply omits the headers and falls back to the address // check this exists to replace. @@ -550,15 +568,7 @@ func (e *Engine) ensureManifestGame(gameID string, q manifestGameQuery, peerID s // a game deliberately untracked, so the user would be told nothing is // wrong while their save quietly stopped syncing. if settings.ShouldAskBeforeTracking() { - if err := e.Store.RecordOfferedGame(store.OfferedGame{ - GameID: gameID, PeerID: peerID, Name: q.Name, - AppID: q.AppID, CoverURL: q.CoverURL, PeerPath: q.SavePath, - }); err != nil { - e.Log("warn", fmt.Sprintf("could not record %q as an offered game: %v", q.Name, err)) - } else { - e.notifyGamesUpdate() - } - return store.Game{}, fmt.Errorf("%s: %q", syncengine.AwaitingFolderMessage, q.Name) + return e.offerGame(gameID, peerID, q) } rules := make([]delta.TranslationRule, len(settings.PathTranslations)) @@ -579,6 +589,25 @@ func (e *Engine) ensureManifestGame(gameID string, q manifestGameQuery, peerID s return store.Game{}, fmt.Errorf("cannot auto-track %q at %s: %s — set the game's save path on this device manually", q.Name, logging.Quote(localPath), reason) } + // The other device names the folder; only this one decides what may be + // tracked. A save path in a manifest request is the peer's say, and a + // paired device — or anyone posing as one — could name any folder this + // user can read: ~/.ssh, a browser profile, another app's settings. Once + // tracked, its files were served to the peer and the peer's written into + // it (CVE-2026-103398). The old guard above refuses only whole profiles, + // drives and system folders. + // + // So a game arriving from a peer is tracked by itself only where this + // device's own scanner recognises a save folder: one it found and noted, + // or one inside an emulator's own save folder here. Anywhere else, it is + // offered, and the user picks the folder on this device. + if e.KnownSaveLocation == nil || !e.KnownSaveLocation(localPath) { + e.Log("warn", fmt.Sprintf( + "a paired device asked to sync %q at %s, which this device does not know as a save folder — "+ + "it is offered on Home instead, for you to place", q.Name, logging.Quote(localPath))) + return e.offerGame(gameID, peerID, q) + } + game := store.Game{ ID: gameID, Name: q.Name, SavePath: localPath, ActiveBranch: "main", AutoSync: true, MaxSnapshots: 5, @@ -614,6 +643,22 @@ func (e *Engine) ensureManifestGame(gameID string, q manifestGameQuery, peerID s return game, nil } +// offerGame records a game a peer syncs as offered, for the user to place on +// this device, and tells the peer this device is waiting rather than that the +// game does not exist: the same answer, which the peer cannot see past, is +// given for a game deliberately untracked. +func (e *Engine) offerGame(gameID, peerID string, q manifestGameQuery) (store.Game, error) { + if err := e.Store.RecordOfferedGame(store.OfferedGame{ + GameID: gameID, PeerID: peerID, Name: q.Name, + AppID: q.AppID, CoverURL: q.CoverURL, PeerPath: q.SavePath, + }); err != nil { + e.Log("warn", fmt.Sprintf("could not record %q as an offered game: %v", q.Name, err)) + } else { + e.notifyGamesUpdate() + } + return store.Game{}, fmt.Errorf("%s: %q", syncengine.AwaitingFolderMessage, q.Name) +} + // handleManifest serves a game's manifest + branch + latest-snapshot info. // If the game isn't tracked here yet, it is auto-tracked using the // requester's supplied name/savePath (translated to local conventions). diff --git a/internal/p2p/wanclient_handlers.go b/internal/p2p/wanclient_handlers.go index 3fdf021e..95203dd3 100644 --- a/internal/p2p/wanclient_handlers.go +++ b/internal/p2p/wanclient_handlers.go @@ -391,12 +391,19 @@ func (w *WanClient) routeRequest(ctx context.Context, msg RelayMessage) (int, an w.engine.Log("warn", fmt.Sprintf("blocked %s from %s: %v", route, from, authErr)) return 401, map[string]string{"error": "Unauthorized: Request failed authentication."} case authNotPossible: - // Paired before end-to-end encryption existed, or the other side - // has not upgraded yet. Allowed, as it was before, and said out - // loud once it matters rather than failing silently. - w.engine.Log("warn", fmt.Sprintf( - "%s from %q is not authenticated — re-pair the two devices to protect it", - route, peer.Name)) + // Unproven: a pairing that holds no key, or one whose device has + // not signed yet. These used to be let through, which is all an + // attacker in the room needed: "from" is whatever the sender + // writes, and the room tells everyone which IDs are paired + // (CVE-2026-103398). Only unpairing still is — it gives nothing + // away, and it is how such a pair is cleared up. + if route != "/unpair" { + w.engine.Log("warn", fmt.Sprintf( + "refused %s from %q: it was not signed with the key from pairing. "+ + "Pairings made over the internet before 2.4.0 never kept one: unpair and pair the two devices again", + route, peer.Name)) + return 401, map[string]string{"error": unsignedRefusal} + } } } diff --git a/internal/presets/knownroots.go b/internal/presets/knownroots.go new file mode 100644 index 00000000..cd4226a7 --- /dev/null +++ b/internal/presets/knownroots.go @@ -0,0 +1,45 @@ +package presets + +import ( + "path/filepath" + "runtime" + "strings" +) + +// InsideEmulatorSaveRoot reports whether path is, or is inside, the save +// folder of an emulator on this device: a Switch title's folder within an +// emulator's NAND, a memory card folder, RetroArch's saves. Only folders that +// exist count, so it names what is here, not what could be. +// +// One of the two things a game arriving from a peer may be tracked at by +// itself (with the folders a scan noted): see p2p ensureManifestGame. +func (sc *Scanner) InsideEmulatorSaveRoot(path string) bool { + if strings.TrimSpace(path) == "" { + return false + } + for _, p := range presetDefs { + if p.Type != "emulator" { + continue + } + for _, root := range p.resolvedPaths(sc) { + if dirExists(root) && within(root, path) { + return true + } + } + } + return false +} + +// within reports whether p is root or inside it, by its cleaned path, without +// letting "..", or a sibling sharing root's name as a prefix, count. +func within(root, p string) bool { + root, p = filepath.Clean(root), filepath.Clean(p) + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + root, p = strings.ToLower(root), strings.ToLower(p) + } + rel, err := filepath.Rel(root, p) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} From 4ba6b8b196511cc849cd71ccfd3eb59e45f36680 Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Thu, 1 Oct 2026 19:46:04 +0530 Subject: [PATCH 10/75] 2.4.1: changelog and version --- CHANGELOG.md | 20 ++++++++++++++++++++ cmd/opensave-app/wails.json | 2 +- internal/version/version.go | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f79e10e..f007ebf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to OpenSave are documented here. This project adheres to ## [Unreleased] +### Security + +- **A paired device can no longer reach folders that aren't game saves + (CVE-2026-103398).** When another device synced a game this one didn't + have yet, OpenSave tracked it at whatever folder that device named, and + then served that folder's files to it and accepted files into it. Only + whole drives, profiles and system folders were refused, so a malicious or + impersonated device could read or overwrite other files you can access. + Now a game arriving from another device is tracked by itself only at a + folder this device's own scan recognises as a save folder, or inside an + emulator's save folder here. Anything else appears on Home as offered, + for you to place in a folder you choose. Reported by mansurmavlankulov. +- **Requests for save data must now be signed with the key from pairing.** + A device that had never signed a request, and pairings made over the + internet before 2.4.0 (which never kept a key), were let through on their + word or their network address. Both are now refused. **Update every + device to 2.4.1**, and if two devices were paired over the internet before + 2.4.0, unpair them and pair them again. Unpairing still works without a + signature, so an old pairing can always be cleared. + ### Fixed - **Restoring onto a new PC no longer holds a game back for nothing.** After diff --git a/cmd/opensave-app/wails.json b/cmd/opensave-app/wails.json index 3ff7660a..a7ef215f 100644 --- a/cmd/opensave-app/wails.json +++ b/cmd/opensave-app/wails.json @@ -12,7 +12,7 @@ "info": { "companyName": "OpenSave", "productName": "OpenSave", - "productVersion": "2.4.0", + "productVersion": "2.4.1", "copyright": "Copyright © 2026 Siva Prakash and OpenSave contributors", "comments": "Peer-to-peer game save sync" } diff --git a/internal/version/version.go b/internal/version/version.go index caaea209..68e2768f 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -11,7 +11,7 @@ import ( // Version is the app's semantic version. Overridable via // -ldflags "-X github.com/opensave/opensave/internal/version.Version=…". -var Version = "2.4.0" +var Version = "2.4.1" // BuildTime is the unix-seconds build timestamp, injected via // -ldflags "-X github.com/opensave/opensave/internal/version.BuildTime=…". From 35f5391cc6d2f1db734246678df9c43154e530ed Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Thu, 1 Oct 2026 19:52:47 +0530 Subject: [PATCH 11/75] E2E: peers' games auto-track only at known save folders --- e2e/autotrack_first_snapshot_test.go | 3 +++ e2e/lan_sync_test.go | 1 + e2e/matching_backup_test.go | 1 + e2e/offered_games_test.go | 27 +++++++++++++++++++++++++++ e2e/wan_sync_test.go | 1 + testutil/harness.go | 21 +++++++++++++++++++++ 6 files changed, 54 insertions(+) diff --git a/e2e/autotrack_first_snapshot_test.go b/e2e/autotrack_first_snapshot_test.go index b67e3e35..30b0bf48 100644 --- a/e2e/autotrack_first_snapshot_test.go +++ b/e2e/autotrack_first_snapshot_test.go @@ -27,6 +27,9 @@ func TestAutoTrack_TakesAFirstSnapshotAndWatches(t *testing.T) { saveA, saveB := filepath.Join(rootA, "Detroit"), filepath.Join(rootB, "Detroit") writeAt(t, filepath.Join(saveA, "slot1.sav"), "chapter 12") writeAt(t, filepath.Join(saveB, "slot1.sav"), "chapter 12") + // B's own scan has seen the save there: what lets B track a game a peer + // syncs without asking (CVE-2026-103398). + b.KnowSaveFolder(saveB) b.API(http.MethodPost, "/api/settings", map[string]any{ "pathTranslations": []map[string]string{{"fromPattern": rootA, "toPattern": rootB}}, }, nil) diff --git a/e2e/lan_sync_test.go b/e2e/lan_sync_test.go index 4adac045..f2e11203 100644 --- a/e2e/lan_sync_test.go +++ b/e2e/lan_sync_test.go @@ -275,6 +275,7 @@ func TestCoverArtPropagatesOnSync(t *testing.T) { a.PairWith(b) gameID := a.TrackGame("Cover Game") + b.KnowPeersSaveFolder(a.SaveDir) // so B tracks it by itself // Give A's game a cover; B does not track this game at all. a.API(http.MethodPatch, "/api/games/"+gameID, map[string]string{ "appId": "264710", "coverUrl": "https://cdn.example/steam/264710/cover.jpg", diff --git a/e2e/matching_backup_test.go b/e2e/matching_backup_test.go index 0ba95a57..000c4ed2 100644 --- a/e2e/matching_backup_test.go +++ b/e2e/matching_backup_test.go @@ -81,6 +81,7 @@ func TestMatching_AppIDIgnoredWhenDisabled(t *testing.T) { a.WriteSave("slot1.sav", "A progress") gameA := a.TrackGame("Some Game") + b.KnowPeersSaveFolder(a.SaveDir) // so B tracks it by itself var gameB struct { ID string `json:"id"` } diff --git a/e2e/offered_games_test.go b/e2e/offered_games_test.go index 1f549726..64c936ed 100644 --- a/e2e/offered_games_test.go +++ b/e2e/offered_games_test.go @@ -49,6 +49,9 @@ func TestOfferedGames_DefaultStillAutoTracks(t *testing.T) { a.WriteSave("slot1.sav", "from A") gameID := a.TrackGame("AutoTracked Game") + // A folder B's own scan knows: tracked by itself. One it does not is + // offered instead (next test). + b.KnowPeersSaveFolder(a.SaveDir) syncTo(a, gameID, b.NodeID()) var games []store.Game @@ -76,6 +79,30 @@ func TestOfferedGames_DefaultStillAutoTracks(t *testing.T) { } } +// Even by default, a game at a folder this device does not know as a save is +// offered, not tracked: the peer names the folder, and that is not enough to +// make this device read and write it (CVE-2026-103398). +func TestOfferedGames_DefaultOffersAFolderThisDeviceDoesNotKnow(t *testing.T) { + a := testutil.NewTestDaemon(t, "Offer-Unknown-A") + b := testutil.NewTestDaemon(t, "Offer-Unknown-B") + a.PairWith(b) + + a.WriteSave("slot1.sav", "from A") + gameID := a.TrackGame("Unknown Folder Game") + syncTo(a, gameID, b.NodeID()) + + if !testutil.WaitFor(30*time.Second, func() bool { + offers, _ := b.Daemon.Store.ListOfferedGames() + return len(offers) == 1 && offers[0].GameID == gameID + }) { + offers, _ := b.Daemon.Store.ListOfferedGames() + t.Fatalf("the game was not offered on B; offers %+v", offers) + } + if g, err := b.Daemon.Store.GetGame(gameID); err == nil { + t.Errorf("B tracked the game at %s, a folder it does not know as a save", g.SavePath) + } +} + // With asking turned on, an unknown game becomes an offer and nothing is // created or synced until a person chooses a folder. func TestOfferedGames_AskRecordsAnOfferAndTracksNothing(t *testing.T) { diff --git a/e2e/wan_sync_test.go b/e2e/wan_sync_test.go index 364df60f..46b8edbf 100644 --- a/e2e/wan_sync_test.go +++ b/e2e/wan_sync_test.go @@ -219,6 +219,7 @@ func TestWanAutoTrackFromPeer(t *testing.T) { // A tracks a game B has NEVER tracked. Only A adds it. a.WriteSave("save.dat", "content from A") gameID := a.TrackGame("AutoTrack Game") + b.KnowPeersSaveFolder(a.SaveDir) // so B tracks it by itself if _, err := b.Daemon.Store.GetGame(gameID); err == nil { t.Fatal("precondition: B should not have the game yet") } diff --git a/testutil/harness.go b/testutil/harness.go index ed8407c2..9323e7b4 100644 --- a/testutil/harness.go +++ b/testutil/harness.go @@ -20,6 +20,8 @@ import ( "github.com/opensave/opensave/internal/api" "github.com/opensave/opensave/internal/daemon" + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/store" ) // TestDaemon is one running daemon + API server. @@ -426,3 +428,22 @@ func SettleSync(t *testing.T, gameID string, devices ...*TestDaemon) { } } } + +// KnowSaveFolder marks path as a folder this device's own scan found. A game +// arriving from a peer is tracked by itself only at such a folder (or inside +// an emulator's save folder here) since CVE-2026-103398; anywhere else it is +// offered for the user to place. In use, the background scan notes a game's +// folder once the game has saved on this device. +func (d *TestDaemon) KnowSaveFolder(path string) { + d.T.Helper() + if err := d.Daemon.Store.RememberSaves([]store.KnownSave{{Path: path, Name: filepath.Base(path)}}); err != nil { + d.T.Fatalf("remember %s as a known save: %v", path, err) + } +} + +// KnowPeersSaveFolder is KnowSaveFolder for where a peer's save at peerPath +// lands on this device, with no translation rules of its own. +func (d *TestDaemon) KnowPeersSaveFolder(peerPath string) { + d.T.Helper() + d.KnowSaveFolder(delta.TranslatePathToLocal(peerPath, nil)) +} From c053e8f8df1304366ecfe52142a5f8870b9ef4f8 Mon Sep 17 00:00:00 2001 From: sivadaboi Date: Thu, 1 Oct 2026 22:53:56 +0530 Subject: [PATCH 12/75] A Switch save slot in a NAND here is a known save folder --- e2e/watch_sync_gauntlet_test.go | 1 + internal/daemon/daemon.go | 7 ++-- internal/presets/knownroots.go | 38 ++++++++++++++++++++ internal/presets/knownroots_test.go | 56 +++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 internal/presets/knownroots_test.go diff --git a/e2e/watch_sync_gauntlet_test.go b/e2e/watch_sync_gauntlet_test.go index 6708a892..7c38385d 100644 --- a/e2e/watch_sync_gauntlet_test.go +++ b/e2e/watch_sync_gauntlet_test.go @@ -57,6 +57,7 @@ func TestWatchAutoSyncChain(t *testing.T) { a := testutil.NewTestDaemon(t, "Chain-A") b := testutil.NewTestDaemon(t, "Chain-B") a.PairWith(b) + logOnFailure(t, a, b) a.WriteSave("seed.sav", "seed") restoreSyncOnTrack := suppressSyncOnTrack(a, b) // see sync_on_track_test.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index d8779b0a..306ce6ff 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -171,13 +171,14 @@ func New(opts Options) (*Daemon, error) { // A Switch save a peer syncs goes into this device's own emulator profile. d.P2P.SwitchSaveFolder = d.Scanner.SwitchSaveFolder // What a game arriving from a peer may be tracked at without asking: - // a folder this device's own scanner noted as a save, or one inside an - // emulator's save folder here. Anything else is offered to the user. + // a folder this device's own scanner noted as a save, one inside an + // emulator's save folder here, or a Switch title's slot in a NAND here. + // Anything else is offered to the user. d.P2P.KnownSaveLocation = func(path string) bool { if known, err := d.Store.IsKnownSave(path); err == nil && known { return true } - return d.Scanner.InsideEmulatorSaveRoot(path) + return d.Scanner.InsideEmulatorSaveRoot(path) || presets.IsSwitchSaveSlot(path) } // Every new snapshot mirrors to the configured cloud provider in the diff --git a/internal/presets/knownroots.go b/internal/presets/knownroots.go index cd4226a7..5f8a2aaf 100644 --- a/internal/presets/knownroots.go +++ b/internal/presets/knownroots.go @@ -4,6 +4,8 @@ import ( "path/filepath" "runtime" "strings" + + "github.com/opensave/opensave/internal/switchtitle" ) // InsideEmulatorSaveRoot reports whether path is, or is inside, the save @@ -43,3 +45,39 @@ func within(root, p string) bool { } return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) } + +// IsSwitchSaveSlot reports whether path is one Switch title's save folder in +// a yuzu-lineage NAND here: /user/save///, +// under a profile that already exists. Such a folder is a save by its shape, +// wherever the emulator keeps its NAND — a portable install, an EmuDeck +// folder — which a preset's standard paths would not know. +// +// The other thing a game arriving from a peer may be tracked at by itself: +// SwitchSaveFolder puts a Switch save there. See p2p ensureManifestGame. +func IsSwitchSaveSlot(path string) bool { + title := filepath.Clean(path) + profile := filepath.Dir(title) + account := filepath.Dir(profile) + save := filepath.Dir(account) + user := filepath.Dir(save) + nand := filepath.Dir(user) + return switchtitle.Valid(filepath.Base(title)) && + hexOfLen(filepath.Base(profile), 32) && + hexOfLen(filepath.Base(account), 16) && + strings.EqualFold(filepath.Base(save), "save") && + strings.EqualFold(filepath.Base(user), "user") && + strings.EqualFold(filepath.Base(nand), "nand") && + dirExists(profile) +} + +func hexOfLen(s string, n int) bool { + if len(s) != n { + return false + } + for _, r := range s { + if !strings.ContainsRune("0123456789abcdefABCDEF", r) { + return false + } + } + return true +} diff --git a/internal/presets/knownroots_test.go b/internal/presets/knownroots_test.go new file mode 100644 index 00000000..2a0313df --- /dev/null +++ b/internal/presets/knownroots_test.go @@ -0,0 +1,56 @@ +package presets + +import ( + "os" + "path/filepath" + "testing" +) + +// A Switch title's save slot is a save folder by its shape, wherever the NAND +// is; nothing else that merely ends in a title id is. +func TestIsSwitchSaveSlot(t *testing.T) { + root := t.TempDir() + profile := filepath.Join(root, "Eden Portable", "user", "nand", "user", "save", "0000000000000000", "0123456789ABCDEF0123456789ABCDEF") + if err := os.MkdirAll(profile, 0o777); err != nil { + t.Fatal(err) + } + slot := filepath.Join(profile, "0100ABCDEF012000") + if !IsSwitchSaveSlot(slot) { + t.Errorf("a title's slot under an existing profile was not recognised: %s", slot) + } + + notSlots := map[string]string{ + "no such profile here": filepath.Join(root, "nand", "user", "save", "0000000000000000", "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", "0100ABCDEF012000"), + "not a title id": filepath.Join(profile, "id_ed25519"), + "a folder named save": filepath.Join(root, "save", "x", "y", "0100ABCDEF012000"), + "a home folder": filepath.Join(root, ".ssh"), + } + for why, p := range notSlots { + if IsSwitchSaveSlot(p) { + t.Errorf("%s: %s was taken for a Switch save slot", why, p) + } + } +} + +func TestInsideEmulatorSaveRootNeedsAnEmulatorHere(t *testing.T) { + phone := t.TempDir() + sc := &Scanner{GOOS: "linux", HomeDir: phone} + if err := os.MkdirAll(filepath.Join(phone, ".config", "retroarch", "saves"), 0o777); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("XDG_DATA_HOME", "") + if !sc.InsideEmulatorSaveRoot(filepath.Join(phone, ".config", "retroarch", "saves", "Pokemon.srm")) { + t.Error("a file in RetroArch's saves folder was not inside an emulator's save root") + } + for _, p := range []string{ + filepath.Join(phone, ".config", "retroarch"), // the emulator's own settings, above its saves + filepath.Join(phone, ".config", "retroarch-evil"), // a sibling sharing the name + filepath.Join(phone, ".config", "retroarch", "..", "gh"), // climbing out + filepath.Join(phone, ".ssh"), + } { + if sc.InsideEmulatorSaveRoot(p) { + t.Errorf("%s was taken for an emulator save folder", p) + } + } +} From 346d9bda0749fb57b48fd266f13e535af80f0285 Mon Sep 17 00:00:00 2001 From: sivadaboi <syncsavedev@gmail.com> Date: Fri, 2 Oct 2026 08:02:34 +0530 Subject: [PATCH 13/75] Changelog for 2.4.1 --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f007ebf3..1d5300ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,14 @@ All notable changes to OpenSave are documented here. This project adheres to [Semantic Versioning](https://semver.org/). -## [Unreleased] +## [2.4.1] — 2026-10-02 + +A security update: please install it on every device. A paired device could +make the one it synced with track any folder and share it; now a game from +another device is only tracked by itself at a folder this one already knows +as a save folder, and requests for save data must be signed. Devices still on +2.3 need updating to keep syncing, and devices paired over the internet before +2.4.0 need to be paired again. ### Security From afd5b7b2955b910683d220da98c9df186c2e852d Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:00:22 +0200 Subject: [PATCH 14/75] delta: hash cache and manifest walk that scale to saves of many files On a save of ~240k small files (Project Zomboid map chunks) a repeat manifest build took 13-19s in 2.4.0; now ~1.3s, with an identical hash. - Walk with filepath.WalkDir instead of filepath.Walk. Walk's extra Lstat per entry opens every file on Windows and dominated a warm build. - Reuse block read buffers (sync.Pool) instead of allocating 64KB+ per file: a cold build allocated ~15GB of garbage, now ~0.6GB. - Sort with sort.Slice when evicting from the hash cache. The insertion sort was quadratic in the entry count (map order is random). - Raise the cache budget floor to 256MB and scale it at 1MB per game, ceiling 512MB. 64MB was filled by one large save on its own, so the cache evicted and re-read that save on every pass. A budget, not an allocation: small libraries never come near it. - Share the whole-file hash string with the block hash for single-block files (most save files). - FileEntryForInfo: a cache lookup with the FileInfo a walk already has. --- internal/delta/hashcache.go | 35 +++++++++++++++++++++++++---------- internal/delta/manifest.go | 28 +++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/internal/delta/hashcache.go b/internal/delta/hashcache.go index 5ee3b611..61adcda8 100644 --- a/internal/delta/hashcache.go +++ b/internal/delta/hashcache.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "sort" "strings" "sync" "time" @@ -64,11 +65,19 @@ type cacheEntry struct { // the old behaviour. const ( // cacheMinBytes is the floor, and the value a small library gets. - cacheMinBytes = 64 << 20 + // + // A budget, not an allocation: the cache only ever holds entries for + // files that exist, so a small library never comes near it. It has to + // cover the rare library with one enormous save, though — a game that + // writes every map chunk as its own file (Project Zomboid: ~240k files, + // ~53MB by entryCost) filled a 64MB budget on its own, and the cache then + // evicted and re-read that save on every pass, which is the churn it + // exists to prevent. + cacheMinBytes = 256 << 20 // cacheMaxCeiling is the most this will ever hold, however many games are // tracked. A cache is meant to replace repeated reading, not to become // the memory problem it was added to fix. - cacheMaxCeiling = 384 << 20 + cacheMaxCeiling = 512 << 20 // cachePerGameBytes is how much the budget grows per tracked game. // // From measurement rather than taste: a game's manifest costs roughly @@ -77,7 +86,10 @@ const ( // library and is not enough for someone tracking 350 games — the cache // sits at its cap and evicts entries it is about to want again, which // quietly reinstates the repeated reading it exists to remove. - cachePerGameBytes = 512 << 10 + // + // Doubled alongside the floor so a large library still gets more than a + // small one (350 games -> 350MB). + cachePerGameBytes = 1 << 20 ) // cacheMaxBytes is the current budget. Not a constant: it scales with how many @@ -193,6 +205,12 @@ func FileEntryFor(path string) (FileEntry, error) { return cachedHashFile(path, info) } +// FileEntryForInfo is FileEntryFor with the FileInfo a directory walk already +// has, so a cache hit costs no syscall at all. +func FileEntryForInfo(path string, info os.FileInfo) (FileEntry, error) { + return cachedHashFile(path, info) +} + func cachedHashFile(path string, info os.FileInfo) (FileEntry, error) { key := cacheKeyFor(path) stamp := cacheStamp{size: info.Size(), mtimeNs: info.ModTime().UnixNano()} @@ -302,13 +320,10 @@ func evictLocked() { for k, v := range hashCache.entries { all = append(all, aged{k, v.usedAt}) } - // Partial selection would be faster, but this runs only on overflow and - // clarity is worth more here than the microseconds. - for i := 1; i < len(all); i++ { - for j := i; j > 0 && all[j].at.Before(all[j-1].at); j-- { - all[j], all[j-1] = all[j-1], all[j] - } - } + // sort.Slice, not an insertion sort: map iteration order is random, so + // insertion sort was quadratic in the entry count — ~10^10 comparisons for + // a quarter-million-file save, on every overflow. + sort.Slice(all, func(i, j int) bool { return all[i].at.Before(all[j].at) }) target := cacheMaxBytes * 3 / 4 for _, a := range all { if hashCache.bytes <= target { diff --git a/internal/delta/manifest.go b/internal/delta/manifest.go index f70016e5..d008ad76 100644 --- a/internal/delta/manifest.go +++ b/internal/delta/manifest.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "os" "path/filepath" "sort" @@ -217,9 +218,17 @@ func HashFile(path string) (FileEntry, error) { } } + wholeHash := hex.EncodeToString(whole.Sum(nil)) + // A single-block file's block hash is the whole-file hash; share the one + // string. Most save files are single-block, and on a save of a quarter + // million of them the duplicate hex strings cost ~20MB per manifest. + if len(blocks) == 1 { + blocks[0].Hash = wholeHash + } + return FileEntry{ Size: info.Size(), - Hash: hex.EncodeToString(whole.Sum(nil)), + Hash: wholeHash, Blocks: blocks, BlockSize: blockSize, MtimeMs: Milli(info.ModTime().UnixMilli()), @@ -263,7 +272,20 @@ func buildManifest(root string) (Manifest, error) { } var dirs []string - err = filepath.Walk(root, func(path string, walkInfo os.FileInfo, walkErr error) error { + // WalkDir, not Walk: Walk issues an extra Lstat per entry, which on + // Windows opens every file and dominated the cost of a warm build of a + // large save (13-19s vs ~1.2s for 240k files). WalkDir takes size and + // mtime from the directory listing itself. + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + var walkInfo os.FileInfo + if walkErr == nil && path != root { + // Reparse points are rejected below from the listing's type bits, + // before Info is asked for anything. + if d.Type()&(os.ModeSymlink|os.ModeIrregular) != 0 { + return nil + } + walkInfo, walkErr = d.Info() + } if walkErr != nil { // The save folder itself could not be read — its listing refused, // or the folder gone since it was looked at. That is not an empty @@ -282,7 +304,7 @@ func buildManifest(root string) (Manifest, error) { // skipped instead of failing the whole manifest — one // unreadable directory must not abort every sync of the game. if os.IsPermission(walkErr) { - if walkInfo != nil && walkInfo.IsDir() { + if d != nil && d.IsDir() { return filepath.SkipDir } return nil From ae3c2b096336c0b34612255c13e7c8e6010d9d64 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:00:22 +0200 Subject: [PATCH 15/75] snapshot: copy unchanged files from the previous snapshot instead of compressing them again Every snapshot stays a complete zip that restores on its own. What changes is how it is written: a file whose content (SHA-256, as already recorded per snapshot and held by the hash cache) matches the previous snapshot of the same branch is copied in as its already-compressed entry (zip.Writer.Copy). Only new and changed files are compressed. On a 204k-file save: a full snapshot 80.5s, a follow-up 8.9s, same size. The archive walk also moves to filepath.WalkDir for the same reason as the manifest walk. --- internal/snapshot/reuse.go | 124 ++++++++++++++++++++++++++ internal/snapshot/reuse_bench_test.go | 61 +++++++++++++ internal/snapshot/reuse_test.go | 69 ++++++++++++++ internal/snapshot/snapshot.go | 9 +- internal/snapshot/zip.go | 21 ++++- internal/snapshot/zip_roots.go | 28 ++++-- 6 files changed, 299 insertions(+), 13 deletions(-) create mode 100644 internal/snapshot/reuse.go create mode 100644 internal/snapshot/reuse_bench_test.go create mode 100644 internal/snapshot/reuse_test.go diff --git a/internal/snapshot/reuse.go b/internal/snapshot/reuse.go new file mode 100644 index 00000000..90da079e --- /dev/null +++ b/internal/snapshot/reuse.go @@ -0,0 +1,124 @@ +package snapshot + +import ( + "archive/zip" + "os" + "sync/atomic" + + "github.com/opensave/opensave/internal/delta" +) + +// Snapshots that re-pack only what changed. +// +// Every snapshot is a complete zip, restorable on its own — that stays. What +// changes is how it is written: a file whose content is exactly what the +// previous snapshot of the same branch holds is copied into the new archive +// as its already-compressed entry (zip.Writer.Copy), not read and deflated +// again. Only new and changed files are compressed. +// +// For a save of a quarter-million small files (Project Zomboid map chunks) +// where a play session touches a few hundred, that turns a snapshot from a +// minute of reading and deflating 0.76GB into writing mostly copied bytes. +// +// Content is identified by SHA-256 — the hash each snapshot already records +// per file (store.SnapshotFiles) and the one the manifest's hash cache holds, +// so an unchanged file is recognised without being read. Size is checked as +// well, cheaply, before the hash is asked for. +type reuseSource struct { + zr *zip.ReadCloser + files map[string]*zip.File // entry name -> previous entry + hashes map[string]string // entry name -> sha256 recorded for it + reused int // entries copied, for tests +} + +// lastReused is how many entries the most recent snapshot copied from its +// predecessor. Tests read it; nothing else does. +var lastReused atomic.Int64 + +// openReuseSource prepares the newest snapshot of a branch for reuse. Any +// problem — no snapshot, no recorded file list (an older snapshot, or a +// mirror), a zip that does not open — means no reuse, never a failed snapshot. +func (m *Manager) openReuseSource(gameID, branch string) *reuseSource { + prev, err := m.LatestSnapshot(gameID, branch) + if err != nil || prev.ZipPath == "" { + return nil + } + recorded, err := m.Store.SnapshotFiles(prev.ID) + if err != nil || len(recorded) == 0 { + return nil + } + zr, err := zip.OpenReader(prev.ZipPath) + if err != nil { + return nil + } + r := &reuseSource{ + zr: zr, + files: make(map[string]*zip.File, len(zr.File)), + hashes: make(map[string]string, len(recorded)), + } + for _, f := range zr.File { + r.files[f.Name] = f + } + for _, c := range recorded { + name := c.Path + if c.Root != "" { + name = RootPrefix + c.Root + "/" + c.Path + } + r.hashes[name] = c.Hash + } + return r +} + +func (r *reuseSource) Close() { + if r == nil { + lastReused.Store(0) + return + } + lastReused.Store(int64(r.reused)) + if r.zr != nil { + r.zr.Close() + } +} + +// addFileEntry adds filePath under entryName, copying the previous +// snapshot's entry when the content is the same, and archiving it afresh +// otherwise. Returns the file's sha256 either way. A nil receiver archives +// afresh, as every snapshot did before. +func (r *reuseSource) addFileEntry(w *zip.Writer, filePath, entryName string, info os.FileInfo) (string, error) { + if r != nil { + if hash, ok := r.reusable(filePath, entryName, info); ok { + if err := w.Copy(r.files[entryName]); err != nil { + return "", err + } + r.reused++ + return hash, nil + } + } + return addFileEntry(w, filePath, entryName) +} + +// reusable reports whether the previous snapshot holds exactly this file's +// current content under the same name. +func (r *reuseSource) reusable(filePath, entryName string, info os.FileInfo) (string, bool) { + prevHash, ok := r.hashes[entryName] + prev := r.files[entryName] + if !ok || prev == nil || prev.Mode().IsDir() { + return "", false + } + if info == nil { + var err error + if info, err = os.Stat(filePath); err != nil { + return "", false + } + } + if uint64(info.Size()) != prev.UncompressedSize64 { + return "", false + } + // From the hash cache when the file is unchanged since it was last + // hashed, which after the watcher's manifest build it nearly always is. + entry, err := delta.FileEntryForInfo(filePath, info) + if err != nil || entry.Hash != prevHash { + return "", false + } + return prevHash, true +} diff --git a/internal/snapshot/reuse_bench_test.go b/internal/snapshot/reuse_bench_test.go new file mode 100644 index 00000000..641a0335 --- /dev/null +++ b/internal/snapshot/reuse_bench_test.go @@ -0,0 +1,61 @@ +package snapshot + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + "time" + + "github.com/opensave/opensave/internal/delta" +) + +// TestBench_SnapshotReuse times a full snapshot of a real save folder and a +// second one that reuses it. Opt-in: OPENSAVE_BENCH_SAVE=<folder>. +// Reads the folder only; archives go to a temp dir. +func TestBench_SnapshotReuse(t *testing.T) { + src := os.Getenv("OPENSAVE_BENCH_SAVE") + if src == "" { + t.Skip("set OPENSAVE_BENCH_SAVE to run") + } + dir := t.TempDir() + + first := filepath.Join(dir, "first.zip") + start := time.Now() + skipped, captured, err := zipRootsCapturing(src, nil, first, nil) + if err != nil { + t.Fatal(err) + } + full := time.Since(start) + t.Logf("full snapshot skipped %d unreadable files", len(skipped)) + + // As in the app: the watcher's manifest build has hashed the save just + // before a snapshot is taken, so the cache is warm. + if _, err := delta.BuildManifest(src); err != nil { + t.Fatal(err) + } + + zr, err := zip.OpenReader(first) + if err != nil { + t.Fatal(err) + } + r := &reuseSource{zr: zr, files: map[string]*zip.File{}, hashes: map[string]string{}} + for _, f := range zr.File { + r.files[f.Name] = f + } + for _, c := range captured { + r.hashes[c.Path] = c.Hash + } + second := filepath.Join(dir, "second.zip") + start = time.Now() + if _, _, err := zipRootsCapturing(src, nil, second, r); err != nil { + t.Fatal(err) + } + incr := time.Since(start) + r.Close() + + fi1, _ := os.Stat(first) + fi2, _ := os.Stat(second) + t.Logf("%d files: full snapshot %s (%d MB), reusing snapshot %s (%d MB), %d entries copied", + len(captured), full.Round(time.Millisecond), fi1.Size()>>20, incr.Round(time.Millisecond), fi2.Size()>>20, lastReused.Load()) +} diff --git a/internal/snapshot/reuse_test.go b/internal/snapshot/reuse_test.go new file mode 100644 index 00000000..104a8efd --- /dev/null +++ b/internal/snapshot/reuse_test.go @@ -0,0 +1,69 @@ +package snapshot + +import ( + "fmt" + "testing" +) + +// A snapshot copies the files that did not change from the previous one, and +// still restores to exactly the save it was taken of. +func TestSnapshotReusesUnchangedFiles(t *testing.T) { + env := setup(t) + for i := 0; i < 50; i++ { + write(t, env.saveDir, fmt.Sprintf("map/chunk_%02d.bin", i), fmt.Sprintf("chunk %d %s", i, "................................................")) + } + first, err := env.mgr.Create("game1", "first", false) + if err != nil { + t.Fatal(err) + } + if got := lastReused.Load(); got != 0 { + t.Errorf("first snapshot reused %d entries from nothing", got) + } + + // A session changes two chunks and adds one. + write(t, env.saveDir, "map/chunk_03.bin", "changed three") + write(t, env.saveDir, "map/chunk_40.bin", "changed forty") + write(t, env.saveDir, "map/chunk_99.bin", "new") + second, err := env.mgr.Create("game1", "second", false) + if err != nil { + t.Fatal(err) + } + if got := lastReused.Load(); got != 48 { + t.Errorf("second snapshot copied %d unchanged entries, want 48", got) + } + + // Each snapshot restores its own state, independently of the other. + write(t, env.saveDir, "map/chunk_03.bin", "ruined") + if _, err := env.mgr.Restore("game1", second.ID); err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{ + "map/chunk_03.bin": "changed three", + "map/chunk_40.bin": "changed forty", + "map/chunk_99.bin": "new", + "map/chunk_07.bin": "chunk 7 ................................................", + } { + if got := read(env.saveDir, name); got != want { + t.Errorf("after restoring the second snapshot, %s = %q, want %q", name, got, want) + } + } + if _, err := env.mgr.Restore("game1", first.ID); err != nil { + t.Fatal(err) + } + if got := read(env.saveDir, "map/chunk_03.bin"); got != "chunk 3 ................................................" { + t.Errorf("after restoring the first snapshot, chunk_03 = %q", got) + } + if got := read(env.saveDir, "map/chunk_99.bin"); got != "" { + t.Errorf("restoring the first snapshot left the later file: %q", got) + } + + // And the recorded contents of the second snapshot are right for the + // copied entries too. + files, err := env.store.SnapshotFiles(second.ID) + if err != nil { + t.Fatal(err) + } + if len(files) != 51 { + t.Errorf("second snapshot records %d files, want 51", len(files)) + } +} diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 77d0cf6f..f483bde4 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -236,7 +236,14 @@ func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSys } else { src = from } - skipped, captured, err := ZipRootsCapturing(src, extraRoots, stagingPath) + // The game's own save, onto its branch: unchanged files are copied from + // the branch's previous snapshot rather than compressed again (reuse.go). + var reuse *reuseSource + if from == "" { + reuse = m.openReuseSource(gameID, branch) + } + skipped, captured, err := zipRootsCapturing(src, extraRoots, stagingPath, reuse) + reuse.Close() if err != nil { os.Remove(stagingPath) return store.Snapshot{}, fmt.Errorf("zip save data: %w", err) diff --git a/internal/snapshot/zip.go b/internal/snapshot/zip.go index 1ea2920c..803c60a8 100644 --- a/internal/snapshot/zip.go +++ b/internal/snapshot/zip.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "fmt" "io" + "io/fs" "os" "path/filepath" "strings" @@ -38,6 +39,10 @@ func ZipPath(sourcePath, outPath string) (skipped []string, err error) { // archived so the caller can record what the snapshot holds. The hashes come // free: the bytes pass through a hasher on their way into the archive. func ZipPathCapturing(sourcePath, outPath string) (skipped []string, captured []store.CapturedFile, err error) { + return zipPathCapturing(sourcePath, outPath, nil) +} + +func zipPathCapturing(sourcePath, outPath string, reuse *reuseSource) (skipped []string, captured []store.CapturedFile, err error) { info, err := os.Stat(sourcePath) if err != nil { return nil, nil, fmt.Errorf("source path does not exist: %w", err) @@ -54,7 +59,7 @@ func ZipPathCapturing(sourcePath, outPath string) (skipped []string, captured [] if !info.IsDir() { name := filepath.Base(sourcePath) - hash, addErr := addFileEntry(w, sourcePath, name) + hash, addErr := reuse.addFileEntry(w, sourcePath, name, info) if addErr != nil { return nil, nil, addErr } @@ -62,7 +67,10 @@ func ZipPathCapturing(sourcePath, outPath string) (skipped []string, captured [] } archived := 0 - walkErr := filepath.Walk(sourcePath, func(path string, walkInfo os.FileInfo, walkErr error) error { + // WalkDir, not Walk: Walk's extra Lstat per entry opens every file on + // Windows, which on a save of a quarter-million files cost more than the + // archiving itself once unchanged files are copied (reuse.go). + walkErr := filepath.WalkDir(sourcePath, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { // Unreadable subtree — record it and move on. if path != sourcePath { @@ -80,14 +88,19 @@ func ZipPathCapturing(sourcePath, outPath string) (skipped []string, captured [] } rel = filepath.ToSlash(rel) - if walkInfo.IsDir() { + if d.IsDir() { // Explicit directory entries keep empty dirs restorable. if _, err := w.CreateHeader(&zip.FileHeader{Name: rel + "/", Method: zip.Store}); err != nil { return err } return nil } - hash, addErr := addFileEntry(w, path, rel) + info, infoErr := d.Info() + if infoErr != nil { + skipped = append(skipped, path) + return nil + } + hash, addErr := reuse.addFileEntry(w, path, rel, info) if addErr != nil { skipped = append(skipped, path) return nil diff --git a/internal/snapshot/zip_roots.go b/internal/snapshot/zip_roots.go index 8f70ff2f..c3618b6c 100644 --- a/internal/snapshot/zip_roots.go +++ b/internal/snapshot/zip_roots.go @@ -3,6 +3,7 @@ package snapshot import ( "archive/zip" "fmt" + "io/fs" "os" "path/filepath" "sort" @@ -44,8 +45,14 @@ func ZipRoots(primary string, extra map[string]string, outPath string) (skipped // instead of inferred from a single whole-save hash that nothing reliably // kept current. func ZipRootsCapturing(primary string, extra map[string]string, outPath string) (skipped []string, captured []store.CapturedFile, err error) { + return zipRootsCapturing(primary, extra, outPath, nil) +} + +// zipRootsCapturing is ZipRootsCapturing, copying unchanged files straight +// out of reuse (the previous snapshot) when it is given. See reuse.go. +func zipRootsCapturing(primary string, extra map[string]string, outPath string, reuse *reuseSource) (skipped []string, captured []store.CapturedFile, err error) { if len(extra) == 0 { - return ZipPathCapturing(primary, outPath) + return zipPathCapturing(primary, outPath, reuse) } // One archive, written in a single pass. @@ -69,7 +76,7 @@ func ZipRootsCapturing(primary string, extra map[string]string, outPath string) defer f.Close() w := newSnapshotWriter(f) - primarySkipped, primaryFiles, err := archiveInto(w, primary, "", "") + primarySkipped, primaryFiles, err := archiveInto(w, primary, "", "", reuse) skipped = append(skipped, primarySkipped...) captured = append(captured, primaryFiles...) if err != nil { @@ -82,7 +89,7 @@ func ZipRootsCapturing(primary string, extra map[string]string, outPath string) if strings.TrimSpace(path) == "" { continue } - sub, subFiles, subErr := archiveInto(w, path, RootPrefix+name+"/", name) + sub, subFiles, subErr := archiveInto(w, path, RootPrefix+name+"/", name, reuse) skipped = append(skipped, sub...) captured = append(captured, subFiles...) if subErr != nil { @@ -94,14 +101,14 @@ func ZipRootsCapturing(primary string, extra map[string]string, outPath string) } // archiveInto writes one directory tree into an open zip under prefix. -func archiveInto(w *zip.Writer, sourcePath, prefix, root string) (skipped []string, captured []store.CapturedFile, err error) { +func archiveInto(w *zip.Writer, sourcePath, prefix, root string, reuse *reuseSource) (skipped []string, captured []store.CapturedFile, err error) { info, err := os.Stat(sourcePath) if err != nil { return nil, nil, err } if !info.IsDir() { name := filepath.Base(sourcePath) - hash, addErr := addFileEntry(w, sourcePath, prefix+name) + hash, addErr := reuse.addFileEntry(w, sourcePath, prefix+name, info) if addErr != nil { return nil, nil, addErr } @@ -117,7 +124,7 @@ func archiveInto(w *zip.Writer, sourcePath, prefix, root string) (skipped []stri } } - walkErr := filepath.Walk(sourcePath, func(path string, walkInfo os.FileInfo, walkErr error) error { + walkErr := filepath.WalkDir(sourcePath, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { if path != sourcePath { skipped = append(skipped, path) @@ -133,11 +140,16 @@ func archiveInto(w *zip.Writer, sourcePath, prefix, root string) (skipped []stri return err } rel = filepath.ToSlash(rel) - if walkInfo.IsDir() { + if d.IsDir() { _, err := w.CreateHeader(&zip.FileHeader{Name: prefix + rel + "/", Method: zip.Store}) return err } - hash, addErr := addFileEntry(w, path, prefix+rel) + info, infoErr := d.Info() + if infoErr != nil { + skipped = append(skipped, path) + return nil + } + hash, addErr := reuse.addFileEntry(w, path, prefix+rel, info) if addErr != nil { skipped = append(skipped, path) return nil From fafb6342ba0196494492092f433f17bb8f2eb926 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:00:24 +0200 Subject: [PATCH 16/75] sync: stop "Keep mine" deleting files, hold mass deletions, batch small files, skip unchanged manifests Data safety - "Keep mine" (and "Keep both", which ends the same way) recorded every local file as shared before the peer had pulled any of it. When that pull did not finish, the next sync read the peer's missing files as its deletions and deleted them on the device whose version had just been kept - tens of thousands of files on a large save, and the folders differing again re-raised the conflict. Now only what both sides verifiably hold is recorded as shared; if the peer cannot be asked, nothing is. Same fix for extra save locations. TestKeepLocal_PeerThatNeverPulled_DeletesNothingHere fails on 2.4.0. - A sync that would delete more than 100 files and more than 10% of a game, on either side, is held as a conflict instead of applied. A deletion confirmed on purpose (emptied-folder flow) is not held. - Tests that an interrupted or partial transfer never becomes deletions, per file and batched. - The conflict carries uncapped counts of what differs, so the dialog can say what each answer deletes. Performance - Batched pulls (protocol revision 2): small files are fetched up to 256 per request (4MB), 4 requests in flight, over a new /api/p2p/files route. Each file is still written through the PatchWriter (verified, fsynced, renamed). One request per file cost ~200ms on a real LAN; on loopback 5000 files went from 42s to 12s. Peers below revision 2 and relay connections keep the per-file path. - Unchanged games: when this device still holds the agreed base, the manifest request carries ifHash and a peer that still holds it too answers "unchanged" instead of sending the manifest. Older peers ignore the parameter; their full answer is used, not fetched twice. - Manifests are gzipped for clients that accept it (every Go client does, so older versions read them too): ~5.6x smaller with real hashes. - Manifests and batches get a 10-minute client timeout instead of 30s; a large manifest over a VPN never arrived inside 30s, so those games never synced. - An in-sync pass also mirrors the peer's latest snapshot, which only a pull did before; games identical everywhere from the start showed history on one device only. Robustness - Presence: paired LAN peers are probed every 10s on their own ticker. Probes used to ride along with the minute-long reconcile, which waits for a whole sync-all pass; peers reached over a VPN (no UDP broadcast) showed online/offline minutes late. Probe rounds are serialized. - A peer coming back online triggers one sync-all, not one per concurrent request (re-read under a lock). - Games whose save folder is not on this device are skipped by sync-all and dropped from the retry queue instead of failing every 20s. - "syncing X with Y" is logged only when there is something to do; most passes produced only "already in sync" lines, which rotated everything else out of the log within hours. --- e2e/batch_bench_test.go | 59 ++++ internal/p2p/engine.go | 81 ++++++ internal/p2p/manifest_gzip_test.go | 63 +++++ internal/p2p/missing_skip_test.go | 28 ++ internal/p2p/presence_test.go | 82 ++++++ internal/p2p/routes.go | 124 +++++++- internal/p2p/syncengine/batch.go | 264 ++++++++++++++++++ internal/p2p/syncengine/batch_test.go | 185 ++++++++++++ internal/p2p/syncengine/conflict.go | 33 ++- internal/p2p/syncengine/engine.go | 99 ++++++- internal/p2p/syncengine/engine_test.go | 53 ++++ .../p2p/syncengine/keeplocal_safety_test.go | 34 +++ internal/p2p/syncengine/massdelete.go | 28 ++ internal/p2p/syncengine/massdelete_test.go | 69 +++++ internal/p2p/syncengine/multiroot_conflict.go | 12 +- .../p2p/syncengine/partial_safety_test.go | 113 ++++++++ internal/p2p/syncengine/quick.go | 73 +++++ internal/p2p/syncengine/quick_test.go | 117 ++++++++ internal/p2p/syncengine/transport.go | 43 +++ internal/p2p/transport_lan.go | 65 ++++- internal/p2p/transport_wan.go | 14 + 21 files changed, 1619 insertions(+), 20 deletions(-) create mode 100644 e2e/batch_bench_test.go create mode 100644 internal/p2p/manifest_gzip_test.go create mode 100644 internal/p2p/missing_skip_test.go create mode 100644 internal/p2p/presence_test.go create mode 100644 internal/p2p/syncengine/batch.go create mode 100644 internal/p2p/syncengine/batch_test.go create mode 100644 internal/p2p/syncengine/keeplocal_safety_test.go create mode 100644 internal/p2p/syncengine/massdelete.go create mode 100644 internal/p2p/syncengine/massdelete_test.go create mode 100644 internal/p2p/syncengine/partial_safety_test.go create mode 100644 internal/p2p/syncengine/quick.go create mode 100644 internal/p2p/syncengine/quick_test.go diff --git a/e2e/batch_bench_test.go b/e2e/batch_bench_test.go new file mode 100644 index 00000000..0299c4e8 --- /dev/null +++ b/e2e/batch_bench_test.go @@ -0,0 +1,59 @@ +package e2e + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/opensave/opensave/internal/p2p" + "github.com/opensave/opensave/internal/p2p/syncengine" + "github.com/opensave/opensave/testutil" +) + +// TestBench_ManySmallFiles times pulling a save of many small files between +// two real daemons, batched and file by file. Opt-in, because it is a +// measurement rather than a check: OPENSAVE_BENCH_FILES=5000 go test ./e2e -run TestBench -v +func TestBench_ManySmallFiles(t *testing.T) { + n, _ := strconv.Atoi(os.Getenv("OPENSAVE_BENCH_FILES")) + if n <= 0 { + t.Skip("set OPENSAVE_BENCH_FILES to run") + } + for _, mode := range []struct { + name string + proto int + }{{"batched", syncengine.ProtoBatchFiles}, {"file-by-file", syncengine.ProtoMultiRoot}} { + t.Run(mode.name, func(t *testing.T) { + restore := p2p.SetServedProto(mode.proto) + defer p2p.SetServedProto(restore) + + a := testutil.NewTestDaemon(t, "Bench-A") + b := testutil.NewTestDaemon(t, "Bench-B") + a.PairWith(b) + chunk := strings.Repeat("x", 3500) // a Project Zomboid map chunk is ~3.5KB + for i := 0; i < n; i++ { + a.WriteSave(fmt.Sprintf("map/map_%d_%d.bin", i/100, i%100), chunk+strconv.Itoa(i)) + } + restoreSyncOnTrack := suppressSyncOnTrack(a, b) + gameID := a.TrackGame("Bench") + b.API(http.MethodPost, "/api/games", map[string]string{"name": "Bench", "savePath": b.SaveDir}, nil) + restoreSyncOnTrack() + + start := time.Now() + b.API(http.MethodPost, "/api/games/"+gameID+"/sync", nil, nil) + count := func() int { + m, _ := filepath.Glob(filepath.Join(b.SaveDir, "map", "*.bin")) + return len(m) + } + if !testutil.WaitFor(60*time.Minute, func() bool { return count() >= n }) { + t.Fatalf("only %d of %d files arrived", count(), n) + } + el := time.Since(start) + t.Logf("%s: %d files in %s = %.2f ms/file", mode.name, n, el.Round(time.Millisecond), float64(el.Microseconds())/1000/float64(n)) + }) + } +} diff --git a/internal/p2p/engine.go b/internal/p2p/engine.go index b1777e2e..8f92209d 100644 --- a/internal/p2p/engine.go +++ b/internal/p2p/engine.go @@ -9,11 +9,15 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "net/http" + "os" + "path/filepath" "strings" "sync" "time" + "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/e2ee" "github.com/opensave/opensave/internal/p2p/discovery" "github.com/opensave/opensave/internal/p2p/pairing" @@ -119,6 +123,13 @@ type Engine struct { // that a device has gone away — see offlineStrikes. pingMu sync.Mutex pingMisses map[string]int + // probeMu serializes PingPairedPeers rounds. + probeMu sync.Mutex + + // Serializes the offline->online transition in requirePairedPeer, so a + // burst of concurrent requests from a returning peer triggers one full + // auto-sync instead of one per request. + peerOnlineMu sync.Mutex // Lineage refreshes already running after a peer-applied deletion, keyed // by game+peer. Deletions arrive one file at a time, and each one leaves @@ -382,6 +393,11 @@ func (e *Engine) clearPingMisses(peerID string) { // PingPairedPeers probes every paired LAN peer and updates their // online/offline status. func (e *Engine) PingPairedPeers(ctx context.Context) { + // One probe round at a time: the presence loop and a sync starting can + // both ask, and two overlapping rounds would count one outage as two + // misses toward offlineStrikes. + e.probeMu.Lock() + defer e.probeMu.Unlock() peers, err := e.Store.ListPeers() if err != nil { return @@ -542,6 +558,8 @@ func (e *Engine) StartResyncLoop() { stop := e.stopRetry e.pendingMu.Unlock() + e.startPresenceLoop(stop) + // Tracked on the engine's lifecycle so a shutdown lands between ticks // rather than half-way through a transfer. e.GoSync(func(ctx context.Context) { @@ -571,6 +589,40 @@ func (e *Engine) StartResyncLoop() { }) } +// presenceInterval is how often paired LAN peers are probed just to know +// whether they are there. +// +// Probing used to happen only as a side effect: before the 60-second +// reconcile, before a retry, before a manual sync. The reconcile runs on the +// same goroutine as the sync-everything pass it starts, so while a big +// library synced the next probe waited minutes, and with offlineStrikes +// that was minutes more before a device that had gone showed as gone. Peers +// seen by UDP broadcast hid this; one reached over a VPN such as Tailscale, +// which does not carry broadcasts, depends on probes alone. +// +// 10s makes "back" show within ~10s and "gone" within ~30s. A probe is one +// small GET per peer. A var only so a test can shorten it. +var presenceInterval = 10 * time.Second + +// startPresenceLoop probes paired peers on its own ticker, independent of +// whatever sync work is running. It stops with the resync loop. +func (e *Engine) startPresenceLoop(stop chan struct{}) { + e.GoSync(func(ctx context.Context) { + ticker := time.NewTicker(presenceInterval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ctx.Done(): + return + case <-ticker.C: + e.PingPairedPeers(ctx) + } + } + }) +} + // reconcileAllGames refreshes peer status and re-syncs every game — the // periodic backstop against missed sync triggers. func (e *Engine) reconcileAllGames(ctx context.Context) { @@ -602,6 +654,13 @@ func (e *Engine) retryPendingResyncs(ctx context.Context) { if ctx.Err() != nil { return // shutting down; the failsafe picks these up next start } + // A save folder that is not here cannot be synced, and retrying it + // every tick only logs the same failure. Dropped from the queue; the + // periodic reconcile picks it up once the folder is back. + if g, err := e.Store.GetGame(id); err == nil && saveFolderAbsent(g.SavePath) { + e.ClearPendingResync(id) + continue + } e.Log("info", fmt.Sprintf("retrying interrupted sync for %s", id)) results, err := e.Sync.SyncGame(ctx, id, online) if err == nil { @@ -616,6 +675,22 @@ func (e *Engine) retryPendingResyncs(ctx context.Context) { } } +// saveFolderAbsent reports whether a game's save folder is not on this device +// — the same test as daemon.SaveFolderMissing, which this package cannot +// import. For a single-file save it is the folder the file goes in that +// counts: the file itself may not have been written yet. +func saveFolderAbsent(savePath string) bool { + if savePath == "" { + return false + } + folder := savePath + if isFile, err := delta.ResolveLocalSaveFilePath(savePath); err == nil && isFile { + folder = filepath.Dir(savePath) + } + _, err := os.Stat(folder) + return errors.Is(err, fs.ErrNotExist) +} + // SyncAllGames syncs every tracked game (used when a peer comes online). func (e *Engine) SyncAllGames(ctx context.Context) { if e.Pause.Paused() { @@ -633,6 +708,12 @@ func (e *Engine) SyncAllGames(ctx context.Context) { if !g.AutoSync { continue } + // Not on this device (a drive it doesn't have, a folder that is + // gone): nothing to sync, and it is shown as such on screen. Trying + // anyway logged an error for it against every peer, every pass. + if saveFolderAbsent(g.SavePath) { + continue + } results, err := e.Sync.SyncGame(ctx, g.ID, online) if errors.Is(err, syncengine.ErrHeld) { continue // said once, when it was held; asked about on screen diff --git a/internal/p2p/manifest_gzip_test.go b/internal/p2p/manifest_gzip_test.go new file mode 100644 index 00000000..cca409cf --- /dev/null +++ b/internal/p2p/manifest_gzip_test.go @@ -0,0 +1,63 @@ +package p2p + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/p2p/syncengine" +) + +// A large manifest goes over the wire gzipped, and an ordinary Go client — +// which is what every OpenSave version uses — reads it without knowing. +func TestManifestIsCompressedAndReadable(t *testing.T) { + m := delta.Manifest{Files: map[string]delta.FileEntry{}} + for i := 0; i < 5000; i++ { + sum := sha256.Sum256([]byte(fmt.Sprint(i))) // random-looking, like real hashes + h := hex.EncodeToString(sum[:]) + m.Files[fmt.Sprintf("Saves/world/map_%d_%d.bin", i/100, i%100)] = delta.FileEntry{ + Size: 3500, Hash: h, BlockSize: 65536, Blocks: []delta.Block{{Index: 0, Hash: h, Length: 3500}}, + } + } + resp := syncengine.ManifestResponse{Manifest: m, ActiveBranch: "main"} + + var wire atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cw := &countingResponse{ResponseWriter: w} + jsonOKCompressed(cw, r, resp) + wire.Store(cw.n) + })) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + var got syncengine.ManifestResponse + if err := doJSONWith(lanBulkClient, req, &got); err != nil { + t.Fatal(err) + } + if len(got.Manifest.Files) != len(m.Files) || got.Manifest.ManifestHash() != m.ManifestHash() { + t.Fatalf("manifest came back with %d files and a different hash", len(got.Manifest.Files)) + } + + plain := httptest.NewRecorder() + jsonOK(plain, resp) + if wire.Load()*2 > int64(plain.Body.Len()) { + t.Errorf("compressed %d bytes vs %d plain — expected at least 2x smaller", wire.Load(), plain.Body.Len()) + } + t.Logf("manifest of %d files: %d bytes plain, %d on the wire", len(m.Files), plain.Body.Len(), wire.Load()) +} + +type countingResponse struct { + http.ResponseWriter + n int64 +} + +func (c *countingResponse) Write(b []byte) (int, error) { + n, err := c.ResponseWriter.Write(b) + c.n += int64(n) + return n, err +} diff --git a/internal/p2p/missing_skip_test.go b/internal/p2p/missing_skip_test.go new file mode 100644 index 00000000..b5de9828 --- /dev/null +++ b/internal/p2p/missing_skip_test.go @@ -0,0 +1,28 @@ +package p2p + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveFolderAbsent(t *testing.T) { + dir := t.TempDir() + if saveFolderAbsent(dir) { + t.Error("an existing folder was reported absent") + } + if !saveFolderAbsent(filepath.Join(dir, "gone")) { + t.Error("a missing folder was not reported absent") + } + // A single-file save not written yet: its folder is what counts. + file := filepath.Join(dir, "save.sav") + if err := os.WriteFile(file, []byte("x"), 0o666); err != nil { + t.Fatal(err) + } + if saveFolderAbsent(file) { + t.Error("an existing single-file save was reported absent") + } + if saveFolderAbsent("") { + t.Error("an empty path was reported absent") + } +} diff --git a/internal/p2p/presence_test.go b/internal/p2p/presence_test.go new file mode 100644 index 00000000..de0397de --- /dev/null +++ b/internal/p2p/presence_test.go @@ -0,0 +1,82 @@ +package p2p + +import ( + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/opensave/opensave/internal/snapshot" + "github.com/opensave/opensave/internal/store" +) + +func waitUntil(d time.Duration, cond func() bool) bool { + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(20 * time.Millisecond) + } + return cond() +} + +// A peer's online state follows it on its own ticker, whatever else the +// engine is doing — not only when a sync or the minute-long reconcile happens +// to probe. A peer reached over a VPN gets no UDP broadcasts, so without this +// its indicator lagged by minutes. +func TestPresenceLoop_FollowsPeer(t *testing.T) { + old := presenceInterval + presenceInterval = 50 * time.Millisecond + t.Cleanup(func() { presenceInterval = old }) + + var up atomic.Bool + up.Store(true) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !up.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok","appVersion":"2.4.0"}`)) + })) + t.Cleanup(srv.Close) + host, portStr, _ := net.SplitHostPort(srv.Listener.Addr().String()) + port, _ := strconv.Atoi(portStr) + + s, err := store.Open(filepath.Join(t.TempDir(), "opensave.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + if err := s.EnsureDefaultSettings(t.TempDir(), t.TempDir()); err != nil { + t.Fatal(err) + } + if err := s.UpsertPeer(store.Peer{ID: "node_vpn", Name: "Over VPN", Address: host, Port: port, Status: "offline"}); err != nil { + t.Fatal(err) + } + + e := New(s, snapshot.New(s), func(string, string) {}) + stop := make(chan struct{}) + e.startPresenceLoop(stop) + t.Cleanup(func() { close(stop); e.cancel(); e.bgSyncWG.Wait() }) + + status := func() string { + p, err := s.GetPeer("node_vpn") + if err != nil { + return "" + } + return p.Status + } + if !waitUntil(3*time.Second, func() bool { return status() == "online" }) { + t.Fatalf("peer answering pings still %q", status()) + } + up.Store(false) + if !waitUntil(3*time.Second, func() bool { return status() == "offline" }) { + t.Fatalf("peer no longer answering still %q", status()) + } +} diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index 67d11a5c..89cfd6bd 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -1,6 +1,7 @@ package p2p import ( + "compress/gzip" "context" "encoding/json" "errors" @@ -11,6 +12,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "sync/atomic" "time" @@ -46,6 +48,7 @@ func (e *Engine) RegisterRoutes(r chi.Router) { r.Use(e.refuseWhilePaused) r.Get("/api/p2p/manifest/{gameId}", e.handleManifest) r.Post("/api/p2p/blocks/{gameId}", e.handleBlocks) + r.Post("/api/p2p/files/{gameId}", e.handleFileBatch) r.Post("/api/p2p/delete-file/{gameId}", e.handleDeleteFile) r.Get("/api/sync/trigger/{gameId}", e.handleSyncTrigger) }) @@ -724,7 +727,19 @@ func (e *Engine) handleManifest(w http.ResponseWriter, r *http.Request) { if latest, err := e.Snapshots.LatestSnapshot(gameID, ""); err == nil { resp.LatestSnapshot = &syncengine.SnapshotInfo{ID: latest.ID, Timestamp: latest.Timestamp, Comment: latest.Comment} } - jsonOK(w, resp) + // The asker already holds the state both devices last agreed on and says + // so; if this device still holds it too, "unchanged" is the whole answer. + // For a save of a quarter-million files the full manifest is tens of MB + // of JSON, and the minute-long reconcile used to fetch it from every peer + // every minute to learn that nothing had changed. Single-location games + // only: extra locations carry their own bases (see RootHash). + if ifHash := r.URL.Query().Get("ifHash"); ifHash != "" && len(manifest.Extra) == 0 && + manifest.ManifestHash() == ifHash { + resp.Manifest = delta.Manifest{} + resp.Unchanged = true + resp.ManifestHash = ifHash + } + jsonOKCompressed(w, r, resp) } // holdForServing holds the game's save still for a manifest to be served from @@ -787,6 +802,85 @@ func (e *Engine) handleBlocks(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]any{"blocks": encodeBlocks(blocks, wantsGzip(body.Encodings))}) } +// Bounds on one batch request, so a peer cannot make this device read and +// hold an unbounded amount in a single response. The requester stays well +// under them (syncengine.batchMaxFiles / batchMaxBytes). +const ( + fileBatchMaxFiles = 1024 + fileBatchMaxBytes = 16 << 20 +) + +// handleFileBatch serves the blocks of many files of one save location in a +// single response: handleBlocks for a list. One round trip per file was what +// made a save of many small files take hours; see syncengine.ProtoBatchFiles. +// +// A file that cannot be read is reported in its own entry rather than failing +// the batch, so the requester can say which one it was. +func (e *Engine) handleFileBatch(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "gameId") + var body struct { + Root string `json:"root"` + Files []syncengine.FileBlocksRequest `json:"files"` + Encodings []string `json:"encodings"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Files) == 0 { + jsonError(w, http.StatusBadRequest, "files are required") + return + } + if len(body.Files) > fileBatchMaxFiles { + jsonError(w, http.StatusBadRequest, fmt.Sprintf("at most %d files per batch", fileBatchMaxFiles)) + return + } + var requested int64 + for _, f := range body.Files { + bs := f.BlockSize + if bs <= 0 { + bs = 64 * 1024 + } + requested += int64(len(f.BlockIndices)) * int64(bs) + } + if requested > fileBatchMaxBytes { + jsonError(w, http.StatusBadRequest, "batch too large") + return + } + + game, err := e.trackedGameForPeer(gameID) + if err != nil { + jsonError(w, http.StatusNotFound, "Game not found.") + return + } + base, ok := e.resolveServeRoot(gameID, game, body.Root) + if !ok { + jsonError(w, http.StatusNotFound, "This device has no save location named "+strconv.Quote(body.Root)+" for that game.") + return + } + singleFile, _ := delta.ResolveLocalSaveFilePath(base) + gz := wantsGzip(body.Encodings) + + out := make([]syncengine.FileBlocks, 0, len(body.Files)) + for _, f := range body.Files { + entry := syncengine.FileBlocks{RelPath: f.RelPath} + if f.RelPath == "" || !delta.IsSafePath(base, f.RelPath) { + entry.Error = "invalid path" + out = append(out, entry) + continue + } + // Resolved, not joined — see handleBlocks. + fullPath := delta.LocalNameFor(base, f.RelPath) + if singleFile { + fullPath = base + } + blocks, err := delta.ReadBlocks(fullPath, f.BlockIndices, f.BlockSize) + if err != nil { + entry.Error = "read blocks failed: " + err.Error() + } else { + entry.Blocks = encodeBlocks(blocks, gz) + } + out = append(out, entry) + } + jsonOK(w, map[string]any{"files": out}) +} + func (e *Engine) handleDeleteFile(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "gameId") var body struct { @@ -979,6 +1073,32 @@ func progressEventFromMap(data map[string]any) syncengine.ProgressEvent { return ev } +// jsonOKCompressed is jsonOK, gzipped when the asker accepts it. +// +// For manifests. A save of a few hundred thousand files makes a manifest of +// tens of MB of JSON, which over a VPN did not arrive inside a peer's request +// timeout — so the peer never learned what this device holds, every sync of +// that game failed, and the two devices never converged. Manifest JSON (paths +// and hex hashes) compresses several times over. Go's HTTP client asks for +// gzip and unpacks it on its own unless told not to, so every version of +// OpenSave reads these, including ones that predate this. +func jsonOKCompressed(w http.ResponseWriter, r *http.Request, v any) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + jsonOK(w, v) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "gzip") + w.Header().Add("Vary", "Accept-Encoding") + w.WriteHeader(http.StatusOK) + zw, err := gzip.NewWriterLevel(w, gzip.BestSpeed) + if err != nil { + return + } + _ = json.NewEncoder(zw).Encode(v) + _ = zw.Close() +} + func jsonOK(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -1019,7 +1139,7 @@ func (e *Engine) resolveServeRoot(gameID string, game store.Game, root string) ( // argument — the read still happens concurrently. var servedProto atomic.Int64 -func init() { servedProto.Store(syncengine.ProtoMultiRoot) } +func init() { servedProto.Store(syncengine.ProtoBatchFiles) } // ServedProto reports the protocol revision advertised to peers. func ServedProto() int { return int(servedProto.Load()) } diff --git a/internal/p2p/syncengine/batch.go b/internal/p2p/syncengine/batch.go new file mode 100644 index 00000000..ae688f40 --- /dev/null +++ b/internal/p2p/syncengine/batch.go @@ -0,0 +1,264 @@ +package syncengine + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + "time" + + "github.com/opensave/opensave/internal/delta" +) + +// Pulling many small files, many to a request. +// +// A pull used to cost one HTTP round trip per file, one after the other. +// Measured on a Windows LAN, that came to ~200ms a file, of which writing the +// file (temp, fsync, rename) was ~2ms — so a save made of a quarter-million +// small files took half a day to move 50MB. Batching removes the round trips; +// the writing is the same as before, file by file, and just as safe: each +// file still goes through a PatchWriter (temp file, content verified against +// the manifest's hash, fsync, rename) and gets the peer's mtime. + +const ( + // batchFileMaxBytes is the most a file may need fetched to go into a + // batch. Anything bigger keeps the per-file path, which already fetches + // its blocks in parallel and has nothing to gain here. + batchFileMaxBytes = 256 << 10 + // batchMaxFiles and batchMaxBytes bound one request, well inside what the + // responder accepts (p2p.fileBatchMaxFiles / fileBatchMaxBytes). + batchMaxFiles = 256 + batchMaxBytes = 4 << 20 +) + +// batchWorkers is how many batch requests are in flight at once: enough to +// keep the link and the responder's disk busy while this side writes. A var +// only so a test can make the order deterministic. +var batchWorkers = 4 + +type batchJob struct { + relPath string + localPath string + remote delta.FileEntry + indices []int +} + +// changedBytes is how much of a file a pull has to fetch. +func changedBytes(f delta.FileEntry, indices []int) int64 { + var n int64 + for _, idx := range indices { + if idx >= 0 && idx < len(f.Blocks) { + n += int64(f.Blocks[idx].Length) + } + } + return n +} + +// groupBatches splits jobs into requests of at most batchMaxFiles files and +// batchMaxBytes of blocks. +func groupBatches(jobs []batchJob) [][]batchJob { + var out [][]batchJob + var cur []batchJob + var curBytes int64 + for _, j := range jobs { + b := changedBytes(j.remote, j.indices) + if len(cur) > 0 && (len(cur) >= batchMaxFiles || curBytes+b > batchMaxBytes) { + out = append(out, cur) + cur, curBytes = nil, 0 + } + cur = append(cur, j) + curBytes += b + } + if len(cur) > 0 { + out = append(out, cur) + } + return out +} + +// pullBatched fetches and writes jobs, many files per request. It returns the +// paths it wrote, including on error, since those files are on disk. +func (e *Engine) pullBatched(ctx context.Context, batcher BatchFetcher, peer Peer, gameID, root string, + jobs []batchJob, throttle *throttler, tracker *progressTracker, onProgress func(force bool)) ([]string, error) { + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var ( + mu sync.Mutex + written []string + firstErr error + ) + fail := func(err error) { + mu.Lock() + if firstErr == nil { + firstErr = err + cancel() + } + mu.Unlock() + } + + batches := groupBatches(jobs) + workers := batchWorkers + if workers > len(batches) { + workers = len(batches) + } + work := make(chan []batchJob) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for batch := range work { + done, err := e.pullOneBatch(ctx, batcher, peer, gameID, root, batch, throttle, tracker, onProgress) + mu.Lock() + written = append(written, done...) + mu.Unlock() + if err != nil { + fail(err) + return + } + // One line per batch instead of one per file: a quarter-million + // "file updated" lines rotated every other message out of the log. + if len(done) > 0 { + e.Log("info", fmt.Sprintf("%d files updated (%s … %s)", len(done), done[0], done[len(done)-1])) + } + onProgress(true) + } + }() + } + for _, b := range batches { + select { + case work <- b: + case <-ctx.Done(): + } + } + close(work) + wg.Wait() + + mu.Lock() + defer mu.Unlock() + if firstErr != nil { + return written, firstErr + } + return written, ctx.Err() +} + +// pullOneBatch fetches one batch and writes its files. +func (e *Engine) pullOneBatch(ctx context.Context, batcher BatchFetcher, peer Peer, gameID, root string, + batch []batchJob, throttle *throttler, tracker *progressTracker, onProgress func(force bool)) ([]string, error) { + + req := make([]FileBlocksRequest, len(batch)) + for i, j := range batch { + req[i] = FileBlocksRequest{RelPath: j.relPath, BlockIndices: j.indices, BlockSize: j.remote.BlockSize} + } + + var resp []FileBlocks + var err error + const maxAttempts = 3 + for attempt := 1; attempt <= maxAttempts; attempt++ { + resp, err = batcher.FetchFileBatch(ctx, peer, gameID, root, req) + if err == nil || errors.Is(err, ErrBatchUnsupported) { + break + } + e.Log("warn", fmt.Sprintf("batch fetch attempt %d/%d of %d files failed: %v", attempt, maxAttempts, len(batch), err)) + if attempt < maxAttempts { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + } + if errors.Is(err, ErrBatchUnsupported) { + return e.pullBatchOneByOne(ctx, peer, gameID, root, batch, throttle, tracker, onProgress) + } + if err != nil { + return nil, fmt.Errorf("fetch batch of %d files: %w", len(batch), err) + } + + byPath := make(map[string]FileBlocks, len(resp)) + for _, f := range resp { + byPath[f.RelPath] = f + } + + var written []string + for _, j := range batch { + if ctx.Err() != nil { + return written, ctx.Err() + } + f, ok := byPath[j.relPath] + if !ok { + return written, fmt.Errorf("fetch blocks for %s: %s did not send it", j.relPath, peer.Name) + } + if f.Error != "" { + return written, fmt.Errorf("fetch blocks for %s: %s", j.relPath, f.Error) + } + if err := writeBatchedFile(j, f.Blocks); err != nil { + return written, err + } + written = append(written, j.relPath) + n := changedBytes(j.remote, j.indices) + tracker.add(n) + onProgress(false) + throttle.wait(ctx, n) + } + return written, nil +} + +// writeBatchedFile writes one file from its fetched blocks, exactly as +// pullFile does: unchanged blocks from the copy on disk, the rest from the +// peer, verified and renamed into place by the PatchWriter. +func writeBatchedFile(j batchJob, blocks []BlockData) error { + writer, err := delta.NewPatchWriter(j.localPath, j.remote) + if err != nil { + return fmt.Errorf("patch %s: %w", j.relPath, err) + } + committed := false + defer func() { + if !committed { + writer.Abort() + } + }() + incoming := make(map[int]bool, len(j.indices)) + for _, idx := range j.indices { + incoming[idx] = true + } + if err := writer.SeedUnchanged(j.localPath, incoming); err != nil { + return fmt.Errorf("patch %s: %w", j.relPath, err) + } + for _, b := range blocks { + if err := writer.WriteBlock(b.Index, b.Data); err != nil { + return fmt.Errorf("patch %s: %w", j.relPath, err) + } + } + if err := writer.Commit(); err != nil { + return fmt.Errorf("patch %s: %w", j.relPath, err) + } + committed = true + if j.remote.MtimeMs > 0 { + mtime := time.UnixMilli(int64(j.remote.MtimeMs)) + _ = os.Chtimes(j.localPath, mtime, mtime) + } + return nil +} + +// pullBatchOneByOne is the fallback when the transport cannot batch for this +// peer after all: the same files, through the per-file path. +func (e *Engine) pullBatchOneByOne(ctx context.Context, peer Peer, gameID, root string, + batch []batchJob, throttle *throttler, tracker *progressTracker, onProgress func(force bool)) ([]string, error) { + + var written []string + for _, j := range batch { + if err := e.pullFile(ctx, peer, FileRef{GameID: gameID, Root: root, RelPath: j.relPath}, j.localPath, + j.remote, j.indices, throttle, tracker, onProgress); err != nil { + return written, err + } + if j.remote.MtimeMs > 0 { + mtime := time.UnixMilli(int64(j.remote.MtimeMs)) + _ = os.Chtimes(j.localPath, mtime, mtime) + } + written = append(written, j.relPath) + } + return written, nil +} diff --git a/internal/p2p/syncengine/batch_test.go b/internal/p2p/syncengine/batch_test.go new file mode 100644 index 00000000..7550df1a --- /dev/null +++ b/internal/p2p/syncengine/batch_test.go @@ -0,0 +1,185 @@ +package syncengine + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/snapshot" +) + +// batchTransport is fakeTransport plus batched fetches, answering manifests +// with a chosen protocol revision. +type batchTransport struct { + *fakeTransport + proto int + + mu sync.Mutex + batchCalls int + blockCalls int + failPath string // answered with a per-file error +} + +func (b *batchTransport) FetchManifest(ctx context.Context, peer Peer, gameID string, q ManifestQuery) (ManifestResponse, error) { + resp, err := b.fakeTransport.FetchManifest(ctx, peer, gameID, q) + resp.Proto = b.proto + return resp, err +} + +func (b *batchTransport) FetchBlocks(ctx context.Context, peer Peer, ref FileRef, idx []int, bs int) ([]BlockData, error) { + b.mu.Lock() + b.blockCalls++ + b.mu.Unlock() + return b.fakeTransport.FetchBlocks(ctx, peer, ref, idx, bs) +} + +func (b *batchTransport) FetchFileBatch(ctx context.Context, peer Peer, gameID, root string, files []FileBlocksRequest) ([]FileBlocks, error) { + b.mu.Lock() + b.batchCalls++ + b.mu.Unlock() + if len(files) > batchMaxFiles { + return nil, fmt.Errorf("batch of %d files is over the limit", len(files)) + } + out := make([]FileBlocks, 0, len(files)) + for _, f := range files { + if f.RelPath == b.failPath { + out = append(out, FileBlocks{RelPath: f.RelPath, Error: "read blocks failed: locked"}) + continue + } + blocks, err := b.fakeTransport.FetchBlocks(ctx, peer, FileRef{GameID: gameID, Root: root, RelPath: f.RelPath}, f.BlockIndices, f.BlockSize) + if err != nil { + out = append(out, FileBlocks{RelPath: f.RelPath, Error: err.Error()}) + continue + } + out = append(out, FileBlocks{RelPath: f.RelPath, Blocks: blocks}) + } + return out, nil +} + +func setupBatchEngine(t *testing.T, proto int) (*engineEnv, *batchTransport) { + t.Helper() + env := setupEngine(t) + bt := &batchTransport{fakeTransport: env.transport, proto: proto} + env.engine = New(env.store, snapshot.New(env.store), bt) + return env, bt +} + +// A save of many small files, plus one big one, in two folders. +func writeManyFiles(t *testing.T, dir string, n int) { + t.Helper() + for i := 0; i < n; i++ { + write(t, dir, fmt.Sprintf("map/chunk_%04d.bin", i), strings.Repeat(fmt.Sprintf("chunk %d;", i), 20)) + } + big := bytes.Repeat([]byte("large save data "), (512<<10)/16) // 512KB: per-file path + if err := os.WriteFile(filepath.Join(dir, "big.sav"), big, 0o666); err != nil { + t.Fatal(err) + } +} + +func assertSameTree(t *testing.T, want, got string) { + t.Helper() + wm, err := delta.BuildManifest(want) + if err != nil { + t.Fatal(err) + } + gm, err := delta.BuildManifest(got) + if err != nil { + t.Fatal(err) + } + if wm.ManifestHash() != gm.ManifestHash() { + t.Fatalf("trees differ: %d files remote, %d local", len(wm.Files), len(gm.Files)) + } + for p, f := range wm.Files { + if gm.Files[p].MtimeMs != f.MtimeMs { + t.Errorf("%s: mtime %d, want the peer's %d", p, gm.Files[p].MtimeMs, f.MtimeMs) + break + } + } +} + +func TestPull_BatchesSmallFiles(t *testing.T) { + env, bt := setupBatchEngine(t, ProtoBatchFiles) + writeManyFiles(t, env.remoteDir, 600) + + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil { + t.Fatalf("SyncWithPeer: %v", err) + } + if res.Status != "updated" || res.Direction != "pull" { + t.Fatalf("result = %+v, want updated/pull", res) + } + assertSameTree(t, env.remoteDir, env.localDir) + + // 600 small files at 256 per request -> 3 requests; the big file alone + // goes block by block. + if bt.batchCalls != 3 { + t.Errorf("batch requests = %d, want 3", bt.batchCalls) + } + if bt.blockCalls != 1 { + t.Errorf("per-file block requests = %d, want 1 (the big file)", bt.blockCalls) + } + if len(env.transport.syncEvents) == 0 || env.transport.syncEvents[len(env.transport.syncEvents)-1] != "sync-complete" { + t.Errorf("sync did not complete: %v", env.transport.syncEvents) + } +} + +// A peer that predates batching is asked file by file, as before. +func TestPull_OldPeerIsNotBatched(t *testing.T) { + env, bt := setupBatchEngine(t, ProtoMultiRoot) + writeManyFiles(t, env.remoteDir, 50) + + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatalf("SyncWithPeer: %v", err) + } + assertSameTree(t, env.remoteDir, env.localDir) + if bt.batchCalls != 0 { + t.Errorf("batch requests to an old peer = %d, want 0", bt.batchCalls) + } + if bt.blockCalls != 51 { + t.Errorf("per-file requests = %d, want 51", bt.blockCalls) + } +} + +// One file the peer cannot read fails the pull and names the file, like the +// per-file path does; nothing half-written is left in its place. +func TestPull_BatchFileErrorNamesTheFile(t *testing.T) { + env, bt := setupBatchEngine(t, ProtoBatchFiles) + writeManyFiles(t, env.remoteDir, 10) + bt.failPath = "map/chunk_0003.bin" + + _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err == nil || !strings.Contains(err.Error(), "map/chunk_0003.bin") { + t.Fatalf("err = %v, want one naming map/chunk_0003.bin", err) + } + if _, statErr := os.Stat(filepath.Join(env.localDir, "map", "chunk_0003.bin")); statErr == nil { + t.Error("the file the peer could not serve was written anyway") + } + leftovers, _ := filepath.Glob(filepath.Join(env.localDir, "map", "*"+delta.TmpSuffix)) + if len(leftovers) > 0 { + t.Errorf("temp files left behind: %v", leftovers) + } +} + +func TestGroupBatches_RespectsLimits(t *testing.T) { + var jobs []batchJob + for i := 0; i < 600; i++ { + jobs = append(jobs, batchJob{relPath: fmt.Sprint(i), + remote: delta.FileEntry{Blocks: []delta.Block{{Index: 0, Length: 32 << 10}}}, + indices: []int{0}}) + } + for _, b := range groupBatches(jobs) { + var n int64 + for _, j := range b { + n += changedBytes(j.remote, j.indices) + } + if len(b) > batchMaxFiles || n > batchMaxBytes { + t.Errorf("batch of %d files / %d bytes is over the limit", len(b), n) + } + } +} diff --git a/internal/p2p/syncengine/conflict.go b/internal/p2p/syncengine/conflict.go index 66293b5a..1cef4b5b 100644 --- a/internal/p2p/syncengine/conflict.go +++ b/internal/p2p/syncengine/conflict.go @@ -41,7 +41,7 @@ func (e *Engine) ResolveConflict(ctx context.Context, gameID, peerID, resolution // see both sides still differing from the stale base and re-conflict // immediately — the "keep looping" bug. e.Log("info", fmt.Sprintf("conflict on %q resolved: keep LOCAL — our version becomes the shared state", gameID)) - if err := e.markResolvedLocal(gameID, peer); err != nil { + if err := e.markResolvedLocal(ctx, gameID, peer); err != nil { return "", err } e.clearConflict(gameID) @@ -103,7 +103,7 @@ func (e *Engine) ResolveConflict(ctx context.Context, gameID, peerID, resolution // From here it is "keep mine": this device's version becomes the // shared one, and the peer is asked to take it — which, if it has // changes of its own, asks the person there in turn. - if err := e.markResolvedLocal(gameID, peer); err != nil { + if err := e.markResolvedLocal(ctx, gameID, peer); err != nil { return "", err } e.clearConflict(gameID) @@ -160,7 +160,20 @@ func (e *Engine) markResolvedConverged(gameID string, peer Peer) { // unambiguously the newest, so the sync direction is a push. The peer // then receives our version (its own engine snapshots first, or re-raises // its own conflict if it has newer unsynced work of its own). -func (e *Engine) markResolvedLocal(gameID string, peer Peer) error { +// +// The lineage, though, must say only what both sides verifiably hold. It used +// to record every local file as shared (persistLineage(local, local)) before +// the peer had pulled any of it. When that pull did not finish — a large save, +// a request that timed out, a device that went away — the next sync found +// those files "shared before and missing on the peer now", read that as the +// peer having deleted them, and deleted them HERE: on the device whose version +// the person had just chosen to keep. A save of a quarter-million files lost +// tens of thousands that way, and the folders differing again re-raised the +// conflict, inviting the same answer. Now the record is the intersection of +// the two saves as they stand; a file only this device has is new to the +// peer, not deleted by it, and goes over on the next sync. If the peer cannot +// be asked, nothing is recorded as shared, so nothing can be read as deleted. +func (e *Engine) markResolvedLocal(ctx context.Context, gameID string, peer Peer) error { game, err := e.Store.GetGame(gameID) if err != nil { return err @@ -170,7 +183,19 @@ func (e *Engine) markResolvedLocal(gameID string, peer Peer) error { if err != nil { return err } - e.persistLineage(gameID, peer.ID, local, local) + var files, dirs []string + if remote, err := e.peerStateForResolution(ctx, gameID, peer); err == nil { + files, dirs = IntersectLineage(local, remote.Manifest) + } else { + e.Log("info", fmt.Sprintf("could not read %s's save while resolving %q (%v) — nothing is recorded as shared, so nothing it lacks is taken as deleted", peer.Name, gameID, err)) + } + if rules := e.rulesFor(gameID); !rules.Empty() { + files = filterPathList(files, rules) + dirs = filterPathList(dirs, rules) + } + if err := e.Store.SetSyncState(gameID, peer.ID, files, dirs); err != nil { + e.Log("warn", fmt.Sprintf("persist sync lineage failed: %v", err)) + } _ = e.Store.SetAgreedHash(gameID, peer.ID, local.ManifestHash()) _ = e.Store.SetLastManifestHash(gameID, e.contentHashOf(gameID, local, game.SavePath)) e.recordSynced(gameID, peer.ID) diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index e3b02b00..ac58f604 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -27,6 +27,13 @@ type Conflict struct { RemoteStats SideStats `json:"remoteStats"` DiffFiles []DiffFile `json:"diffFiles"` // capped; DiffTotal is the real count DiffTotal int `json:"diffTotal"` + // The uncapped counts by kind. DiffFiles stops at 100, and counting from + // it told someone choosing "keep theirs" on a save of a quarter-million + // files that it would remove a few dozen, when it removed tens of + // thousands: every OnlyLocal file goes. + OnlyLocalTotal int `json:"onlyLocalTotal"` + OnlyRemoteTotal int `json:"onlyRemoteTotal"` + ChangedTotal int `json:"changedTotal"` } // SideStats summarises one side's save state for the conflict UI. @@ -397,15 +404,34 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re if err != nil { return Result{}, err } - e.Log("info", fmt.Sprintf("syncing %q with %q (%s)", game.Name, peer.Name, - map[bool]string{true: "WAN relay", false: "direct LAN"}[peer.Wan()])) - - // 1. Fetch remote manifest + branch info. isFile, _ := delta.ResolveLocalSaveFilePath(game.SavePath) - remoteData, err := e.Transport.FetchManifest(ctx, peer, gameID, ManifestQuery{ + query := ManifestQuery{ Name: game.Name, SavePath: game.SavePath, IsFile: isFile, AppID: game.AppID, CoverURL: game.CoverURL, - }) + } + + // 0. Nothing changed on either side since they last agreed: say so and + // stop, without moving either manifest. Quietly, too — this is the + // answer for nearly every game on every periodic pass. + res, done, prefetched := e.quickInSync(ctx, gameID, game, peer, query) + if done { + return res, nil + } + + // Said once there is something to do (after the in-sync check below), + // not here: a line per game per peer per periodic pass, most of them + // followed by "already in sync", rotated everything else out of the log + // within hours. + syncingLine := fmt.Sprintf("syncing %q with %q (%s)", game.Name, peer.Name, + map[bool]string{true: "WAN relay", false: "direct LAN"}[peer.Wan()]) + + // 1. Fetch remote manifest + branch info. + var remoteData ManifestResponse + if prefetched != nil { + remoteData = *prefetched + } else { + remoteData, err = e.Transport.FetchManifest(ctx, peer, gameID, query) + } if err != nil { // The peer simply isn't tracking this game (they untracked it, or // never had it). That's a stable state, not a transient network @@ -644,7 +670,6 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re decision := ComputeWithDeletions(localManifest, remoteData.Manifest, lineageFiles, lineageDirs, agreedHash, deleted) if !decision.HasChanges() { - e.Log("success", fmt.Sprintf("%q already in sync with %q", game.Name, peer.Name)) e.persistLineage(gameID, peer.ID, localManifest, remoteData.Manifest) // Both sides verifiably identical: this is a convergence point. _ = e.Store.SetAgreedHash(gameID, peer.ID, localManifest.ManifestHash()) @@ -663,8 +688,23 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // are read through the gate — so the branch hold goes first. releaseAligned() e.syncExtraRoots(ctx, gameID, game, peer, remoteData) + // Share the peer's latest history entry here too. A pull does this; + // a sync that found both sides already identical used to skip it, + // so a game whose saves were identical everywhere from the start + // (Steam Cloud, a copied folder) showed a snapshot on the device + // that tracked it and none on any other. The local files ARE the + // peer's at this point, so the mirror describes the same content. + // Once per peer snapshot: recordMirrorSnapshot skips an id it has. + // + // Last, after the other locations have synced: it zips every location, + // and taking that time before them delayed their sync for nothing. + if remoteData.LatestSnapshot != nil && len(localManifest.Files) > 0 { + e.recordMirrorSnapshot(gameID, game, peer, *remoteData.LatestSnapshot, + fmt.Sprintf("Synced from peer: %s (%s)", peer.Name, remoteData.LatestSnapshot.Comment)) + } return Result{Status: "in_sync", Direction: "none"}, nil } + e.Log("info", syncingLine) if emptiedUnconfirmed(remoteData.Manifest.Files, decision, remoteData.DeletionConfirmed) { e.Log("info", fmt.Sprintf("%q holds none of %q's save files now, and has not confirmed deleting them — keeping this device's copies", @@ -673,6 +713,22 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re } handedOverDeletion := e.handOverEmptying(gameID, peer, localManifest.Files, &decision) + // A sync about to delete a large part of a save — here or on the peer — + // stops and asks instead. emptiedUnconfirmed above covers a save emptied + // completely; this covers one gutted partly, which is what a partial copy + // on one device turns into once it is read as the other's deletions. Two + // waves of that took tens of thousands of files out of a save before + // anyone was asked. A deletion someone confirmed on purpose + // (handedOverDeletion, DeletionConfirmed) is not second-guessed. + if !handedOverDeletion && !remoteData.DeletionConfirmed { + if n, total := massDeletion(localManifest, remoteData.Manifest, decision); n > 0 { + e.Log("warn", fmt.Sprintf("syncing %q with %q would delete %d of %d files — holding it for a decision instead", + game.Name, peer.Name, n, total)) + e.registerConflict(gameID, peer, localManifest, remoteData) + return Result{Status: "conflict", PeerID: peer.ID, PeerName: peer.Name}, nil + } + } + // Nothing below is about files arriving, only about local files leaving. // A pull that brings files this device never held destroys nothing. atRisk := filesAtRisk(localManifest, decision) @@ -1260,6 +1316,10 @@ func (e *Engine) registerConflict(gameID string, peer Peer, localManifest delta. // Capture comparison data while we hold both manifests, so the UI can // show which side is further along and exactly what differs. diffs := diffManifests(localManifest, remoteData.Manifest) + counts := map[string]int{} + for _, d := range diffs { + counts[d.Status]++ + } const maxDiffFiles = 100 total := len(diffs) if len(diffs) > maxDiffFiles { @@ -1269,10 +1329,11 @@ func (e *Engine) registerConflict(gameID string, peer Peer, localManifest delta. e.mu.Lock() e.activeConflicts[gameID] = &Conflict{ Peer: peer, LocalSnap: localSnap, RemoteSnap: remoteSnap, - LocalStats: manifestStats(localManifest), - RemoteStats: manifestStats(remoteData.Manifest), - DiffFiles: diffs, - DiffTotal: total, + LocalStats: manifestStats(localManifest), + RemoteStats: manifestStats(remoteData.Manifest), + DiffFiles: diffs, + DiffTotal: total, + OnlyLocalTotal: counts["only-local"], OnlyRemoteTotal: counts["only-remote"], ChangedTotal: counts["changed"], } e.mu.Unlock() @@ -1561,6 +1622,11 @@ func (e *Engine) pullFiles(ctx context.Context, peer Peer, gameID string, game s // can record the lineage from confirmed fact rather than rediscovering it // with a manifest round trip. See the sync-complete event below. var pulled []string + // Small files are collected here and fetched many to a request once the + // per-file checks below have passed; see pullBatched. + batcher, canBatch := e.Transport.(BatchFetcher) + canBatch = canBatch && !peer.Wan() && remoteData.Proto >= ProtoBatchFiles + var batched []batchJob for _, relPath := range filesToPull { if !delta.IsSafePath(root.Path, relPath) { return fmt.Errorf("path traversal attempt on pulled file %s", relPath) @@ -1608,6 +1674,10 @@ func (e *Engine) pullFiles(ctx context.Context, peer Peer, gameID string, game s if isFile, _ := delta.ResolveLocalSaveFilePath(root.Path); isFile { localFilePath = root.Path // single-file save mode } + if canBatch && len(indices) > 0 && changedBytes(remoteFile, indices) <= batchFileMaxBytes { + batched = append(batched, batchJob{relPath: relPath, localPath: localFilePath, remote: remoteFile, indices: indices}) + continue + } if err := e.pullFile(ctx, peer, FileRef{GameID: gameID, Root: root.Name, RelPath: relPath}, localFilePath, remoteFile, indices, throttle, tracker, reportProgress); err != nil { return err @@ -1622,6 +1692,13 @@ func (e *Engine) pullFiles(ctx context.Context, peer Peer, gameID string, game s // File-boundary progress reporting (always fires). reportProgress(true) } + if len(batched) > 0 { + done, err := e.pullBatched(ctx, batcher, peer, gameID, root.Name, batched, throttle, tracker, reportProgress) + pulled = append(pulled, done...) + if err != nil { + return err + } + } // Said once, plainly, at the end. A sync that quietly leaves files behind // is worse than one that fails: the save looks synced and is not, and the diff --git a/internal/p2p/syncengine/engine_test.go b/internal/p2p/syncengine/engine_test.go index 1ed654ff..22a2be33 100644 --- a/internal/p2p/syncengine/engine_test.go +++ b/internal/p2p/syncengine/engine_test.go @@ -224,6 +224,59 @@ func TestSync_PullNewRemoteFile(t *testing.T) { } } +// Saves identical on both sides from the start (Steam Cloud, a copied folder) +// never pull anything — and used to never share the peer's history entry +// either, so the game showed a snapshot on one device and none on the other. +func TestSync_InSyncMirrorsPeerSnapshot(t *testing.T) { + env := setupEngine(t) + write(t, env.localDir, "save.dat", "same progress") + write(t, env.remoteDir, "save.dat", "same progress") + env.transport.latestSnap = &SnapshotInfo{ID: "snap_888", Timestamp: "2026-06-01T00:00:00.000Z", Comment: "Initial snapshot"} + + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil { + t.Fatalf("SyncWithPeer error = %v", err) + } + if res.Status != "in_sync" { + t.Fatalf("result = %+v, want in_sync", res) + } + snap, err := env.store.GetSnapshot("snap_888") + if err != nil { + t.Fatalf("peer snapshot not mirrored on an in-sync pass: %v", err) + } + if !strings.Contains(snap.Comment, "Synced from peer: Remote Device") { + t.Errorf("mirror comment = %q", snap.Comment) + } + + // A second in-sync pass must not take it again. + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatal(err) + } + game, err := env.store.GetGame("game1") + if err != nil { + t.Fatal(err) + } + snaps, err := env.store.ListSnapshots("game1", game.ActiveBranch) + if err != nil { + t.Fatal(err) + } + if len(snaps) != 1 { + t.Errorf("snapshots after two in-sync passes = %d, want 1", len(snaps)) + } +} + +// An empty save folder in sync with an empty peer has nothing to mirror. +func TestSync_InSyncEmptyFolderTakesNoMirror(t *testing.T) { + env := setupEngine(t) + env.transport.latestSnap = &SnapshotInfo{ID: "snap_999", Timestamp: "2026-06-01T00:00:00.000Z", Comment: "Initial snapshot"} + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatal(err) + } + if _, err := env.store.GetSnapshot("snap_999"); err == nil { + t.Error("an empty folder was mirrored") + } +} + func TestSync_InsufficientDiskSpace(t *testing.T) { env := setupEngine(t) write(t, env.remoteDir, "save.dat", "remote progress that needs space") diff --git a/internal/p2p/syncengine/keeplocal_safety_test.go b/internal/p2p/syncengine/keeplocal_safety_test.go new file mode 100644 index 00000000..064298f5 --- /dev/null +++ b/internal/p2p/syncengine/keeplocal_safety_test.go @@ -0,0 +1,34 @@ +package syncengine + +import ( + "context" + "fmt" + "testing" +) + +// "Keep mine" against a peer holding part of the save, where the peer never +// gets to pull (a pull that times out, a device that goes away). The next +// sync must not read the peer's missing files as deletions and remove them +// from the device whose version the person chose to keep. +func TestKeepLocal_PeerThatNeverPulled_DeletesNothingHere(t *testing.T) { + env := setupEngine(t) + for i := 0; i < 300; i++ { + write(t, env.localDir, fmt.Sprintf("world/map_%04d.bin", i), fmt.Sprintf("chunk %d", i)) + } + for i := 0; i < 100; i++ { + write(t, env.remoteDir, fmt.Sprintf("world/map_%04d.bin", i), fmt.Sprintf("chunk %d", i)) + } + + // The person answers the conflict with "Keep mine". + if err := env.engine.markResolvedLocal(context.Background(), "game1", env.peer); err != nil { + t.Fatal(err) + } + // The peer's pull never happens; its folder stays as it was. + + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatalf("SyncWithPeer: %v", err) + } + if n := countFiles(t, env.localDir); n != 300 { + t.Fatalf("after \"keep mine\" this device went from 300 to %d files — the peer's partial copy was read as deletions", n) + } +} diff --git a/internal/p2p/syncengine/massdelete.go b/internal/p2p/syncengine/massdelete.go new file mode 100644 index 00000000..aea0cc26 --- /dev/null +++ b/internal/p2p/syncengine/massdelete.go @@ -0,0 +1,28 @@ +package syncengine + +import "github.com/opensave/opensave/internal/delta" + +// A sync is held for a decision when it would delete more than both of these: +// enough files that it is not a game tidying its slots, and enough of the save +// that it is not one corner of a big one. Ordinary play deletes a handful of +// files; a device holding part of a save being read as the other's deletions +// deletes thousands. +const ( + massDeleteMinFiles = 100 + massDeleteFraction = 0.10 +) + +// massDeletion reports how many files the decision would delete, on either +// side, and how many the save holds, when that crosses the thresholds above; +// zero otherwise. +func massDeletion(local, remote delta.Manifest, d Decision) (deleting, total int) { + n := len(d.FilesToDeleteLocally) + len(d.FilesToDeleteOnPeer) + total = len(local.Files) + if len(remote.Files) > total { + total = len(remote.Files) + } + if n <= massDeleteMinFiles || total == 0 || float64(n) <= massDeleteFraction*float64(total) { + return 0, total + } + return n, total +} diff --git a/internal/p2p/syncengine/massdelete_test.go b/internal/p2p/syncengine/massdelete_test.go new file mode 100644 index 00000000..a18b34c7 --- /dev/null +++ b/internal/p2p/syncengine/massdelete_test.go @@ -0,0 +1,69 @@ +package syncengine + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" +) + +// Both sides converge on n files, then the peer loses some of them. +func convergedThenPeerLoses(t *testing.T, n, lost int) *engineEnv { + t.Helper() + env := setupEngine(t) + writeSmallFiles(t, env.localDir, n) + writeSmallFiles(t, env.remoteDir, n) + if res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil || res.Status != "in_sync" { + t.Fatalf("setup sync = %+v, %v", res, err) + } + for i := 0; i < lost; i++ { + if err := os.Remove(filepath.Join(env.remoteDir, "world", fmt.Sprintf("map_%04d.bin", i))); err != nil { + t.Fatal(err) + } + } + return env +} + +func TestMassDeletion_IsHeldForADecision(t *testing.T) { + env := convergedThenPeerLoses(t, 1000, 500) + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil { + t.Fatal(err) + } + if res.Status != "conflict" { + t.Fatalf("result = %+v, want the sync held as a conflict", res) + } + if n := countFiles(t, env.localDir); n != 1000 { + t.Fatalf("this side has %d files; the 500 deletions were applied instead of held", n) + } + if _, ok := env.engine.ActiveConflicts()["game1"]; !ok { + t.Error("no conflict registered to ask about") + } + + // "Keep mine": the peer's missing files go back to it, nothing is deleted here. + if _, err := env.engine.ResolveConflict(context.Background(), "game1", env.peer.ID, "keep-local"); err != nil { + t.Fatal(err) + } + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatal(err) + } + if n := countFiles(t, env.localDir); n != 1000 { + t.Fatalf("after keep-mine this side has %d files, want 1000", n) + } +} + +// Ordinary play deletes a few files; that still just syncs. +func TestMassDeletion_SmallDeletionsStillSync(t *testing.T) { + env := convergedThenPeerLoses(t, 1000, 20) + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil { + t.Fatal(err) + } + if res.Status == "conflict" { + t.Fatalf("20 of 1000 deletions were held: %+v", res) + } + if n := countFiles(t, env.localDir); n != 980 { + t.Fatalf("this side has %d files, want the peer's 20 deletions applied (980)", n) + } +} diff --git a/internal/p2p/syncengine/multiroot_conflict.go b/internal/p2p/syncengine/multiroot_conflict.go index 1fea1ba6..401d69de 100644 --- a/internal/p2p/syncengine/multiroot_conflict.go +++ b/internal/p2p/syncengine/multiroot_conflict.go @@ -140,7 +140,17 @@ func (e *Engine) ResolveRootConflict(ctx context.Context, gameID, peerID, root, if err := e.Store.SetAgreedHashForRoot(gameID, peerID, root, local.RootHash(delta.PrimaryRoot)); err != nil { return err } - files, dirs := IntersectLineage(local, local) + // Shared only what the peer verifiably holds too — see + // markResolvedLocal for what recording the whole local copy as shared + // did once the peer's pull did not finish. + var files, dirs []string + if remoteData, err := e.Transport.FetchManifest(ctx, peer, gameID, ManifestQuery{ + Name: game.Name, SavePath: game.SavePath, AppID: game.AppID, CoverURL: game.CoverURL, + }); err == nil { + if remoteRoot, has := remoteData.Manifest.Extra[root]; has { + files, dirs = IntersectLineage(local, delta.Manifest{Files: remoteRoot.Files, Dirs: remoteRoot.Dirs}) + } + } _ = e.Store.SetSyncStateForRoot(gameID, peerID, root, files, dirs) e.clearRootConflict(gameID, root) e.Transport.TriggerPeerPull(peer, gameID) diff --git a/internal/p2p/syncengine/partial_safety_test.go b/internal/p2p/syncengine/partial_safety_test.go new file mode 100644 index 00000000..59588c39 --- /dev/null +++ b/internal/p2p/syncengine/partial_safety_test.go @@ -0,0 +1,113 @@ +package syncengine + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync/atomic" + "testing" +) + +// A transfer that stops part-way must never turn into deletions. +// +// Seen in 2.3.1: a device that had received part of a 240k-file save offered +// its partial copy back, the missing files were read as "deleted over there", +// and 23k files were deleted on the device that still had them. 2.4 records +// as shared only what both manifests showed (persistLineage), which should +// rule that out; these hold it there, for the per-file and the batched pull. + +func countFiles(t *testing.T, dir string) int { + t.Helper() + n := 0 + _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + n++ + } + return nil + }) + return n +} + +func writeSmallFiles(t *testing.T, dir string, n int) { + t.Helper() + for i := 0; i < n; i++ { + write(t, dir, fmt.Sprintf("world/map_%04d.bin", i), fmt.Sprintf("chunk %d", i)) + } +} + +func interruptedThenResumed(t *testing.T, env *engineEnv, total, stopAfter int) { + t.Helper() + writeSmallFiles(t, env.remoteDir, total) + + ctx, cancel := context.WithCancel(context.Background()) + var fetched atomic.Int64 + env.transport.onFetchBlocks = func() { + if fetched.Add(1) == int64(stopAfter) { + cancel() + } + } + if _, err := env.engine.SyncWithPeer(ctx, "game1", env.peer); err == nil { + t.Fatal("setup: the first sync was meant to be interrupted") + } + env.transport.onFetchBlocks = nil + cancel() + + got := countFiles(t, env.localDir) + if got == 0 || got >= total { + t.Fatalf("setup: after the interruption this side has %d of %d files; want a partial copy", got, total) + } + + // Resume. Nothing may be deleted anywhere, and everything must arrive. + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatalf("resumed sync: %v", err) + } + if len(env.transport.deletedOnPeer) > 0 { + t.Fatalf("the partial copy was propagated as %d deletions on the peer, e.g. %s", + len(env.transport.deletedOnPeer), env.transport.deletedOnPeer[0]) + } + if n := countFiles(t, env.remoteDir); n != total { + t.Fatalf("peer has %d files, want all %d", n, total) + } + if n := countFiles(t, env.localDir); n != total { + t.Fatalf("this side has %d files after resuming, want %d", n, total) + } +} + +func TestPartialPull_InterruptedThenResumed(t *testing.T) { + env := setupEngine(t) + interruptedThenResumed(t, env, 200, 60) +} + +func TestPartialPull_InterruptedThenResumed_Batched(t *testing.T) { + // One batch in flight, so the first batch of 256 is written before the + // interruption lands in the second: a deterministic partial copy. + old := batchWorkers + batchWorkers = 1 + t.Cleanup(func() { batchWorkers = old }) + env, _ := setupBatchEngine(t, ProtoBatchFiles) + // Batches fetch through the same fake reader, one call per file; the + // 300th is inside the second batch. + interruptedThenResumed(t, env, 600, 300) +} + +// The other side of the same situation: this device has everything, the peer +// holds only part of it and never synced. The part it lacks is new to it, not +// deleted by it. +func TestPartialPeer_NeverReadAsDeletions(t *testing.T) { + env := setupEngine(t) + writeSmallFiles(t, env.localDir, 300) + for i := 0; i < 80; i++ { + write(t, env.remoteDir, fmt.Sprintf("world/map_%04d.bin", i), fmt.Sprintf("chunk %d", i)) + } + + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatalf("SyncWithPeer: %v", err) + } + if n := countFiles(t, env.localDir); n != 300 { + t.Fatalf("this side went from 300 to %d files — the peer's partial copy was read as deletions", n) + } + if len(env.transport.deletedOnPeer) > 0 { + t.Fatalf("deleted on peer: %v", env.transport.deletedOnPeer[:1]) + } +} diff --git a/internal/p2p/syncengine/quick.go b/internal/p2p/syncengine/quick.go new file mode 100644 index 00000000..435eba66 --- /dev/null +++ b/internal/p2p/syncengine/quick.go @@ -0,0 +1,73 @@ +package syncengine + +import ( + "context" + "fmt" + + "github.com/opensave/opensave/internal/store" +) + +// quickInSync settles the common case of a sync in one small exchange: this +// device's save still hashes to the base both devices last agreed on, and +// the peer, asked whether it does too (ManifestQuery.IfHash), says +// "unchanged". Then there is nothing to decide, and neither manifest needs +// to cross the wire — for a save of a quarter-million files that is tens of +// MB of JSON per peer, which the minute-long reconcile used to fetch every +// minute to learn that nothing had changed. +// +// It only ever answers "in sync" when both sides provably hold the agreed +// state. In every other case it reports not done and the full sync runs; a +// full manifest that came back anyway (a peer that predates the question) +// is handed over in prefetched so it is not fetched twice. +// +// Deliberately narrow: +// - no exclusion rules: the agreed base is taken over the filtered +// manifest, the peer hashes its unfiltered one; +// - no extra save locations: those carry bases of their own (RootHash); +// - no conflict waiting, and the same branch on both sides. +func (e *Engine) quickInSync(ctx context.Context, gameID string, game store.Game, peer Peer, q ManifestQuery) (res Result, done bool, prefetched *ManifestResponse) { + agreed := e.Store.GetAgreedHash(gameID, peer.ID) + if agreed == "" || !e.rulesFor(gameID).Empty() { + return Result{}, false, nil + } + if roots, err := e.Store.GameRootPaths(gameID); err != nil || len(roots) > 0 { + return Result{}, false, nil + } + e.mu.Lock() + conflicted := e.activeConflicts[gameID] != nil + e.mu.Unlock() + if conflicted { + return Result{}, false, nil + } + local, err := e.ReadManifest(ctx, gameID, game.SavePath) + if err != nil || len(local.Extra) > 0 || local.ManifestHash() != agreed { + return Result{}, false, nil + } + + q.IfHash = agreed + resp, err := e.Transport.FetchManifest(ctx, peer, gameID, q) + if err != nil { + // Let the full path fetch again and classify the error as it always has. + return Result{}, false, nil + } + if !resp.Unchanged { + return Result{}, false, &resp + } + if resp.ManifestHash != agreed || (resp.ActiveBranch != "" && resp.ActiveBranch != game.ActiveBranch) { + // Unchanged, but not in a way this shortcut may act on: the full + // exchange decides. + return Result{}, false, nil + } + + // Same as the in-sync outcome of a full sync, minus what is already + // recorded: the base is unchanged and the lineage with it. + e.Transport.ReportSyncEvent(peer, gameID, "in-sync", map[string]any{ + "peerName": e.deviceName(), + "manifestHash": agreed, + }) + if resp.LatestSnapshot != nil && len(local.Files) > 0 { + e.recordMirrorSnapshot(gameID, game, peer, *resp.LatestSnapshot, + fmt.Sprintf("Synced from peer: %s (%s)", peer.Name, resp.LatestSnapshot.Comment)) + } + return Result{Status: "in_sync", Direction: "none"}, true, nil +} diff --git a/internal/p2p/syncengine/quick_test.go b/internal/p2p/syncengine/quick_test.go new file mode 100644 index 00000000..3cfdf8d8 --- /dev/null +++ b/internal/p2p/syncengine/quick_test.go @@ -0,0 +1,117 @@ +package syncengine + +import ( + "context" + "testing" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/snapshot" +) + +// ifHashTransport answers IfHash the way a current peer does, and counts +// how many full manifests it had to send. +type ifHashTransport struct { + *fakeTransport + full, unchanged int +} + +func (t *ifHashTransport) FetchManifest(ctx context.Context, peer Peer, gameID string, q ManifestQuery) (ManifestResponse, error) { + resp, err := t.fakeTransport.FetchManifest(ctx, peer, gameID, q) + if err != nil { + return resp, err + } + if q.IfHash != "" && resp.Manifest.ManifestHash() == q.IfHash { + t.unchanged++ + return ManifestResponse{ActiveBranch: resp.ActiveBranch, LatestSnapshot: resp.LatestSnapshot, + Unchanged: true, ManifestHash: q.IfHash, Manifest: delta.Manifest{}}, nil + } + t.full++ + return resp, nil +} + +func setupIfHashEngine(t *testing.T) (*engineEnv, *ifHashTransport) { + t.Helper() + env := setupEngine(t) + tr := &ifHashTransport{fakeTransport: env.transport} + env.engine = New(env.store, snapshot.New(env.store), tr) + return env, tr +} + +func TestQuickInSync_SkipsTheManifestWhenNothingChanged(t *testing.T) { + env, tr := setupIfHashEngine(t) + write(t, env.localDir, "save.dat", "same") + write(t, env.remoteDir, "save.dat", "same") + + // First sync converges and records the agreed base. + if res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil || res.Status != "in_sync" { + t.Fatalf("first sync = %+v, %v", res, err) + } + if tr.full != 1 { + t.Fatalf("setup: full manifests = %d, want 1", tr.full) + } + + // Nothing changed anywhere: settled without a full manifest. + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil || res.Status != "in_sync" { + t.Fatalf("second sync = %+v, %v", res, err) + } + if tr.full != 1 || tr.unchanged != 1 { + t.Errorf("second sync fetched %d full manifests and %d unchanged answers; want 0 more full, 1 unchanged", tr.full-1, tr.unchanged) + } +} + +// A change on either side must go the full way — the shortcut may only ever +// say "in sync" when both sides provably hold the agreed state. +func TestQuickInSync_ChangesStillSync(t *testing.T) { + env, tr := setupIfHashEngine(t) + write(t, env.localDir, "save.dat", "same") + write(t, env.remoteDir, "save.dat", "same") + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatal(err) + } + + // The peer changes. + write(t, env.remoteDir, "new.dat", "made on the other device") + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil { + t.Fatal(err) + } + if res.Status != "updated" || res.Direction != "pull" { + t.Fatalf("peer change: result = %+v, want updated/pull", res) + } + + // This side changes. + write(t, env.localDir, "mine.dat", "made here") + before := env.transport.pullTriggers + res, err = env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil { + t.Fatal(err) + } + if res.Status == "in_sync" { + t.Fatalf("local change was reported in sync: %+v", res) + } + if env.transport.pullTriggers == before { + t.Error("the peer was not told to pull this side's change") + } + if tr.unchanged != 0 { + t.Errorf("unchanged answers = %d with a change on one side, want 0", tr.unchanged) + } +} + +// A peer that predates the question sends the full manifest; it is used, +// not fetched a second time. +func TestQuickInSync_OldPeerManifestIsNotFetchedTwice(t *testing.T) { + env := setupEngine(t) // plain fake: ignores IfHash + write(t, env.localDir, "save.dat", "same") + write(t, env.remoteDir, "save.dat", "same") + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatal(err) + } + before := env.transport.manifestCalls + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatal(err) + } + if got := env.transport.manifestCalls - before; got != 1 { + t.Errorf("manifest requests for one sync = %d, want 1", got) + } +} diff --git a/internal/p2p/syncengine/transport.go b/internal/p2p/syncengine/transport.go index c39e315c..1db8c5df 100644 --- a/internal/p2p/syncengine/transport.go +++ b/internal/p2p/syncengine/transport.go @@ -2,6 +2,7 @@ package syncengine import ( "context" + "errors" "github.com/opensave/opensave/internal/delta" ) @@ -31,6 +32,38 @@ type SnapshotInfo struct { // with more than one save location. const ProtoMultiRoot = 1 +// ProtoBatchFiles is the revision at which a peer serves the blocks of many +// files in one request (FetchFileBatch). Before it, every file cost its own +// round trip — ~200ms each in practice, which for a save of a quarter-million +// small files (Project Zomboid map chunks) meant half a day for 50MB. +const ProtoBatchFiles = 2 + +// FileBlocksRequest asks for some blocks of one file, as one entry of a batch. +type FileBlocksRequest struct { + RelPath string `json:"relPath"` + BlockIndices []int `json:"blockIndices"` + BlockSize int `json:"blockSize"` +} + +// FileBlocks is one file's answer inside a batch. Error is set, and Blocks +// empty, when the responder could not read that file. +type FileBlocks struct { + RelPath string `json:"relPath"` + Blocks []BlockData `json:"blocks"` + Error string `json:"error,omitempty"` +} + +// BatchFetcher is implemented by transports that can fetch the blocks of +// many files, all in one save location, in a single request. Optional: the +// engine checks for it, and for a peer that advertised ProtoBatchFiles. +type BatchFetcher interface { + FetchFileBatch(ctx context.Context, peer Peer, gameID, root string, files []FileBlocksRequest) ([]FileBlocks, error) +} + +// ErrBatchUnsupported is what a transport returns when it cannot batch for +// this peer (the relay path); the engine then pulls file by file. +var ErrBatchUnsupported = errors.New("batched file fetch is not supported for this peer") + // ManifestResponse is what a peer returns for a manifest request. type ManifestResponse struct { Manifest delta.Manifest `json:"manifest"` @@ -56,6 +89,13 @@ type ManifestResponse struct { // did (see hold.go). Without it an empty location is not taken as every // file in it deleted — see emptiedUnconfirmed. DeletionConfirmed bool `json:"deletionConfirmed,omitempty"` + + // Unchanged answers a request that carried ManifestQuery.IfHash: the + // responder's save still hashes to exactly that, so Manifest is left + // empty. Only ever set when asked; a peer that predates it ignores the + // question and sends the full manifest, which is handled as before. + Unchanged bool `json:"unchanged,omitempty"` + ManifestHash string `json:"manifestHash,omitempty"` } // FileRef identifies one file inside one of a game's save locations. @@ -81,6 +121,9 @@ type ManifestQuery struct { // cover art, instead of a blank tile. AppID string CoverURL string + // IfHash asks the peer to answer "unchanged" instead of its manifest when + // its save still hashes to this — the base both devices last agreed on. + IfHash string } // BlockData is one fetched block. diff --git a/internal/p2p/transport_lan.go b/internal/p2p/transport_lan.go index 1955b640..4405f141 100644 --- a/internal/p2p/transport_lan.go +++ b/internal/p2p/transport_lan.go @@ -35,6 +35,23 @@ type lanTransport struct { var lanClient = &http.Client{Timeout: 30 * time.Second} +// lanBulkClient is for the two requests whose size grows with the save: a +// manifest, and a batch of files. Thirty seconds was fine for a few hundred +// files and not for a few hundred thousand over a VPN; the manifest then never +// arrived, every sync of that game failed, and the devices never converged. +// The sync's own context still bounds it (perPeerSyncTimeout), and the +// transport still gives up on a peer that does not answer at all. +var lanBulkClient = &http.Client{ + Timeout: 10 * time.Minute, + Transport: func() http.RoundTripper { + t := http.DefaultTransport.(*http.Transport).Clone() + // Headers arrive once the peer has built its manifest, which for a big + // save not hashed recently takes a while — but not this long. + t.ResponseHeaderTimeout = 3 * time.Minute + return t + }(), +} + func peerURL(peer syncengine.Peer, route string) string { return fmt.Sprintf("http://%s:%d/api/p2p%s", peer.Address, peer.Port, route) } @@ -54,9 +71,17 @@ func (t *lanTransport) FetchManifest(ctx context.Context, peer syncengine.Peer, if q.CoverURL != "" { params.Set("coverUrl", q.CoverURL) } + if q.IfHash != "" { + params.Set("ifHash", q.IfHash) + } var resp syncengine.ManifestResponse - err := t.getJSON(ctx, peer, peerURL(peer, "/manifest/"+gameID)+"?"+params.Encode(), &resp) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, peerURL(peer, "/manifest/"+gameID)+"?"+params.Encode(), nil) + if err != nil { + return resp, err + } + t.sign(req, peer, nil) + err = doJSONWith(lanBulkClient, req, &resp) return resp, err } @@ -76,6 +101,38 @@ func (t *lanTransport) FetchBlocks(ctx context.Context, peer syncengine.Peer, re return decodeBlocks(resp.Blocks) } +// FetchFileBatch fetches the blocks of many files in one request. Only asked +// of a peer that advertised syncengine.ProtoBatchFiles. +func (t *lanTransport) FetchFileBatch(ctx context.Context, peer syncengine.Peer, gameID, root string, files []syncengine.FileBlocksRequest) ([]syncengine.FileBlocks, error) { + var resp struct { + Files []syncengine.FileBlocks `json:"files"` + } + raw, err := json.Marshal(map[string]any{"root": root, "files": files}) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, peerURL(peer, "/files/"+gameID), bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + t.sign(req, peer, raw) + if err := doJSONWith(lanBulkClient, req, &resp); err != nil { + return nil, err + } + for i := range resp.Files { + if resp.Files[i].Error != "" { + continue + } + blocks, err := decodeBlocks(resp.Files[i].Blocks) + if err != nil { + return nil, fmt.Errorf("%s: %w", resp.Files[i].RelPath, err) + } + resp.Files[i].Blocks = blocks + } + return resp.Files, nil +} + func (t *lanTransport) DeleteRemote(ctx context.Context, peer syncengine.Peer, ref syncengine.FileRef) error { return t.postJSON(ctx, peer, peerURL(peer, "/delete-file/"+ref.GameID), map[string]any{"relPath": ref.RelPath, "root": ref.Root}, nil) } @@ -224,7 +281,11 @@ func (t *lanTransport) sign(req *http.Request, peer syncengine.Peer, body []byte } func doJSON(req *http.Request, out any) error { - resp, err := lanClient.Do(req) + return doJSONWith(lanClient, req, out) +} + +func doJSONWith(client *http.Client, req *http.Request, out any) error { + resp, err := client.Do(req) if err != nil { return err } diff --git a/internal/p2p/transport_wan.go b/internal/p2p/transport_wan.go index 859e91ef..e16e545e 100644 --- a/internal/p2p/transport_wan.go +++ b/internal/p2p/transport_wan.go @@ -154,6 +154,20 @@ func (t *routingTransport) FetchBlocks(ctx context.Context, peer syncengine.Peer return t.pick(peer).FetchBlocks(ctx, peer, ref, blockIndices, blockSize) } +// FetchFileBatch batches over the LAN only. Relay messages are sealed and +// size-limited per client (see BatchIndices), so the relay keeps pulling file +// by file. +func (t *routingTransport) FetchFileBatch(ctx context.Context, peer syncengine.Peer, gameID, root string, files []syncengine.FileBlocksRequest) ([]syncengine.FileBlocks, error) { + if peer.Wan() { + return nil, syncengine.ErrBatchUnsupported + } + b, ok := t.lan.(syncengine.BatchFetcher) + if !ok { + return nil, syncengine.ErrBatchUnsupported + } + return b.FetchFileBatch(ctx, peer, gameID, root, files) +} + func (t *routingTransport) DeleteRemote(ctx context.Context, peer syncengine.Peer, ref syncengine.FileRef) error { return t.pick(peer).DeleteRemote(ctx, peer, ref) } From 297b4ef9f8011e0c8ee89980554ff4e560cf5329 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:00:25 +0200 Subject: [PATCH 17/75] Saves on another drive; conflict dialog states what each answer deletes - A missing save folder is looked for at the identical path on every other drive letter (a Steam library on D: on one PC and E: on another). Exactly one match is adopted for this device and logged; none or several are logged once. Searched at most once an hour. - A save folder on a drive this device does not have is shown as "Not on this device" (muted) instead of a warning, kept out of the warning headline and the notification bell. A folder gone from a drive that exists is still a warning. - The conflict dialog shows exact counts (not the first 100) and says what each answer does: "Keep mine"/"Keep both" delete nothing; "Keep theirs" deletes N files only this device has, in red when that is many. --- .../src/components/ConflictModal.svelte | 50 ++++++- .../frontend/src/lib/gamestatus.js | 5 +- .../frontend/src/lib/gamestatus.test.js | 12 ++ .../frontend/src/lib/notifications.js | 3 +- .../frontend/src/lib/notifications.test.js | 5 + .../frontend/src/views/game/GameHeader.svelte | 21 ++- internal/api/server.go | 12 +- internal/daemon/daemon.go | 3 + internal/daemon/missing.go | 123 +++++++++++++++++- internal/daemon/missing_test.go | 40 ++++++ internal/daemon/relocate_test.go | 82 ++++++++++++ 11 files changed, 343 insertions(+), 13 deletions(-) create mode 100644 internal/daemon/missing_test.go create mode 100644 internal/daemon/relocate_test.go diff --git a/cmd/opensave-app/frontend/src/components/ConflictModal.svelte b/cmd/opensave-app/frontend/src/components/ConflictModal.svelte index 4a9311ff..b16e4151 100644 --- a/cmd/opensave-app/frontend/src/components/ConflictModal.svelte +++ b/cmd/opensave-app/frontend/src/components/ConflictModal.svelte @@ -103,9 +103,16 @@ $: diffFiles = conflict?.diffFiles ?? []; $: diffTotal = conflict?.diffTotal ?? 0; $: diffCapped = diffTotal > diffFiles.length; - $: changedCount = diffFiles.filter((d) => d.status === 'changed').length; - $: onlyLocalCount = diffFiles.filter((d) => d.status === 'only-local').length; - $: onlyRemoteCount = diffFiles.filter((d) => d.status === 'only-remote').length; + // The uncapped counts when the device sends them; counted from the capped + // list otherwise (an older build), which is exact below the cap. + const countOr = (n, status) => (typeof n === 'number' ? n : diffFiles.filter((d) => d.status === status).length); + $: changedCount = countOr(conflict?.changedTotal, 'changed'); + $: onlyLocalCount = countOr(conflict?.onlyLocalTotal, 'only-local'); + $: onlyRemoteCount = countOr(conflict?.onlyRemoteTotal, 'only-remote'); + // "Keep theirs" deletes every file only this device has. Said before the + // click, in numbers, and loudly once it is a lot. + $: theirsDeletes = onlyLocalCount; + $: theirsDeletesMany = theirsDeletes >= 100; // Bytes that differ, per side: a changed file counts its own size on each // side, a file present on only one side counts only there. @@ -217,12 +224,28 @@ <div class="v-time">last change {fmtMs(remoteMs)}</div> </div> </div> - {#if diffCapped} + {#if diffCapped && typeof conflict.onlyLocalTotal !== 'number'} <p class="diff-capped"> Counts above cover the first {diffFiles.length} of {diffTotal} differing files. </p> {/if} + <ul class="consequences"> + <li> + <strong>Keep mine</strong> / <strong>Keep both</strong>: nothing is deleted. + {#if onlyRemoteCount > 0}The {onlyRemoteCount} file{onlyRemoteCount === 1 ? '' : 's'} only on {peerName} {onlyRemoteCount === 1 ? 'is' : 'are'} brought here;{/if} + {#if changedCount > 0}where both have a file, yours is kept.{/if} + </li> + <li class:danger={theirsDeletesMany}> + <strong>Keep theirs</strong>: + {#if theirsDeletes > 0} + <strong>deletes {theirsDeletes} file{theirsDeletes === 1 ? '' : 's'}</strong> that only this device has (snapshotted first). + {:else} + nothing only this device has is lost. + {/if} + </li> + </ul> + {#if conflict.diffTotal > 0} <button class="diff-toggle" on:click={() => (showDiff = !showDiff)}> <Chevron open={showDiff} /> What's different ({conflict.diffTotal} file{conflict.diffTotal === 1 ? '' : 's'}) @@ -250,7 +273,8 @@ <div class="actions"> <button class="btn ghost later" disabled={busy} on:click={putOff} title="Nothing of this game syncs until you decide. Open the game to decide.">Decide later</button> <button class="btn" disabled={busy} on:click={() => resolve('keep-local')}>Keep mine</button> - <button class="btn" disabled={busy} on:click={() => resolve('keep-remote')}>Keep theirs</button> + <button class="btn" class:danger={theirsDeletesMany} disabled={busy} on:click={() => resolve('keep-remote')} + title={theirsDeletes > 0 ? `Deletes ${theirsDeletes} file(s) only this device has` : ''}>Keep theirs</button> <button class="btn primary" disabled={busy} on:click={() => resolve('merge-branch')}> Keep both (recommended) </button> @@ -362,6 +386,22 @@ color: var(--text-faint); margin: -8px 0 12px; } + .consequences { + margin: 0 0 14px; + padding-left: 18px; + font-size: 0.8rem; + color: var(--text-dim); + } + .consequences li { + margin: 2px 0; + } + .consequences li.danger, + .btn.danger { + color: var(--danger, #e5484d); + } + .btn.danger { + border-color: currentColor; + } .v-time { font-size: 0.74rem; color: var(--text-faint); diff --git a/cmd/opensave-app/frontend/src/lib/gamestatus.js b/cmd/opensave-app/frontend/src/lib/gamestatus.js index 4d920c01..2be2f839 100644 --- a/cmd/opensave-app/frontend/src/lib/gamestatus.js +++ b/cmd/opensave-app/frontend/src/lib/gamestatus.js @@ -21,6 +21,9 @@ import { playLength } from './format.js'; export function gameStatus(game, { peers = {}, activity, conflicted = false, now = Date.now() } = {}) { // Before anything else: with its folder gone nothing else about it can // move — no snapshot, no sync, no decision taken. + // …unless the whole drive is absent here: the game's saves live on another + // device's D:\ or E:\, and there is nothing on this one to worry about. + if (game.savePathMissing && game.saveDriveMissing) return { state: 'elsewhere', label: 'Not on this device', tone: 'muted' }; if (game.savePathMissing) return { state: 'missing', label: 'Save folder missing', tone: 'warn' }; // Every save file deleted here at once, held back from the other devices // until someone says whether that was meant (lib/emptied.js). @@ -177,7 +180,7 @@ const stamp = (s) => (s ? Date.parse(s) : null); const snapshotsOf = (game) => Object.values(game.branches ?? {}).flatMap((b) => b.snapshots ?? []); // What needs looking at, in the order the summary above the library reads it. -const URGENCY = { missing: 0, emptied: 1, conflict: 2, error: 3, syncing: 4, restoring: 4, unsynced: 5, empty: 6, paused: 7 }; +const URGENCY = { missing: 0, emptied: 1, conflict: 2, error: 3, syncing: 4, restoring: 4, unsynced: 5, empty: 6, paused: 7, elsewhere: 10 }; // unlisted states (synced, local…) rank 9 /** * The orders the library can be put in. Each compares two rows, {game, diff --git a/cmd/opensave-app/frontend/src/lib/gamestatus.test.js b/cmd/opensave-app/frontend/src/lib/gamestatus.test.js index d144943c..810b197b 100644 --- a/cmd/opensave-app/frontend/src/lib/gamestatus.test.js +++ b/cmd/opensave-app/frontend/src/lib/gamestatus.test.js @@ -119,6 +119,18 @@ describe('a missing save folder', () => { expect(librarySummary(rows)).toEqual({ tone: 'warn', headline: "Hades's save folder is missing" }); expect(sortRows(rows, 'attention').map((r) => r.game.name)).toEqual(['Hades', 'Celeste']); }); + + it('on a drive this device does not have is not a warning, and stays out of the headline', () => { + const g = game({ id: 'a', name: 'ARK', savePathMissing: true, saveDriveMissing: true }); + const s = gameStatus(g); + expect(s).toMatchObject({ state: 'elsewhere', tone: 'muted', label: 'Not on this device' }); + const rows = [ + { game: g, status: s }, + { game: game({ id: 'c', name: 'Celeste', branches: snaps(minsAgo(5)) }), status: { state: 'synced' } } + ]; + expect(librarySummary(rows).tone).not.toBe('warn'); + expect(sortRows(rows, 'attention').map((r) => r.game.name)).toEqual(['Celeste', 'ARK']); + }); }); describe('SORTS', () => { diff --git a/cmd/opensave-app/frontend/src/lib/notifications.js b/cmd/opensave-app/frontend/src/lib/notifications.js index e8633fd5..ece29614 100644 --- a/cmd/opensave-app/frontend/src/lib/notifications.js +++ b/cmd/opensave-app/frontend/src/lib/notifications.js @@ -46,7 +46,8 @@ export function waitingOnYou({ games = {}, conflicts = {}, locationConflicts = [ out.push({ id: `offer:${o.gameId}:${o.snapshotId}`, tone: 'info', gameId: o.gameId, title: `A newer save for ${o.gameName ?? name(o.gameId)}`, detail: `From ${o.deviceName ?? 'another device'}, through the cloud`, go: { view: 'home' } }); } for (const g of Object.values(games)) { - if (g.savePathMissing) { + // A drive this device doesn't have is not waiting on anyone. + if (g.savePathMissing && !g.saveDriveMissing) { out.push({ id: `missing:${g.id}`, tone: 'warn', gameId: g.id, title: `${g.name}'s save folder is missing`, detail: 'Nothing is synced for it until it is back', go: { view: 'game', params: { gameId: g.id } } }); } } diff --git a/cmd/opensave-app/frontend/src/lib/notifications.test.js b/cmd/opensave-app/frontend/src/lib/notifications.test.js index a5828324..1e32a9f7 100644 --- a/cmd/opensave-app/frontend/src/lib/notifications.test.js +++ b/cmd/opensave-app/frontend/src/lib/notifications.test.js @@ -21,6 +21,11 @@ describe('notifications', () => { expect(w.find((x) => x.id.startsWith('damaged')).title).toBe("1 snapshot of Hades can't be restored"); }); + it('does not ask about a game whose drive this device does not have', () => { + const w = waitingOnYou({ games: { ark: { id: 'ark', name: 'ARK', savePathMissing: true, saveDriveMissing: true, branches: {} } } }); + expect(w).toEqual([]); + }); + it('turns activity into events, unread since the bell was last opened', () => { const now = Date.UTC(2026, 8, 25, 12); const items = [ diff --git a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte index 19750020..8eb05a2e 100644 --- a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte +++ b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte @@ -17,6 +17,7 @@ import Pencil from 'lucide-svelte/icons/pencil'; import Star from 'lucide-svelte/icons/star'; import TriangleAlert from 'lucide-svelte/icons/triangle-alert'; + import Info from 'lucide-svelte/icons/info'; import { collections, isFavourite, toggleFavourite } from '../../lib/collections.js'; export let game; @@ -188,7 +189,18 @@ </div> {/if} -{#if game.savePathMissing && !editPath} +{#if game.savePathMissing && game.saveDriveMissing && !editPath} + <!-- The drive itself isn't on this device: the game lives on another PC's + D:\ or E:\. Nothing was lost here, so this informs rather than warns. --> + <div class="missing elsewhere" role="status"> + <Info size={16} /> + <div> + <strong>This game isn't on this device.</strong> + Its saves are on {game.savePath.slice(0, 2)}, which this PC doesn't have, so it's skipped here and keeps syncing + between your other devices. If it is installed here, point the game at its save folder with Edit. + </div> + </div> +{:else if game.savePathMissing && !editPath} <!-- The folder is not there. It is not created again: an empty folder in its place would read as every file deleted, and syncing that would delete them on your other devices too. --> @@ -288,6 +300,13 @@ .missing strong { color: var(--text); } + .missing.elsewhere { + border-color: var(--border); + background: transparent; + } + .missing.elsewhere :global(svg) { + color: var(--text-faint); + } .emptied-actions { display: flex; flex-wrap: wrap; diff --git a/internal/api/server.go b/internal/api/server.go index 66a32562..c1c2e167 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -567,10 +567,14 @@ func (s *Server) gamePayload(g store.Game) map[string]any { // The save folder is not there — gone, moved, or on a drive not // plugged in. Nothing is watched or synced for it until it is back. "savePathMissing": daemon.SaveFolderMissing(g.SavePath), - "lastPlayedAt": lastPlayed, - "playingSince": playingSince, - "playtimeMs": play.PlaytimeMs, - "playSessions": play.Sessions, + // …and of those, the ones whose whole drive is absent here: the game + // lives on another device's D:\ or E:\, which is not a problem to + // warn about (daemon.SaveDriveMissing). + "saveDriveMissing": daemon.SaveFolderMissing(g.SavePath) && daemon.SaveDriveMissing(g.SavePath), + "lastPlayedAt": lastPlayed, + "playingSince": playingSince, + "playtimeMs": play.PlaytimeMs, + "playSessions": play.Sessions, // Whether the game is installed on this device: "found", "not-found", // or "" when there is no telling (daemon.InstallState). A game that is // not here is one nobody plays here, which is why it shows no play. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 306ce6ff..5683cd88 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -62,6 +62,9 @@ type Daemon struct { // Games whose save folder is not there; see missing.go. missingMu sync.Mutex missing map[string]bool + // relocateChecked is when each missing save folder was last looked for on + // another drive (missing.go). + relocateChecked map[string]time.Time // Play sessions; see sessions.go. sessions sessionState diff --git a/internal/daemon/missing.go b/internal/daemon/missing.go index aa083447..b55c7b1f 100644 --- a/internal/daemon/missing.go +++ b/internal/daemon/missing.go @@ -6,6 +6,8 @@ import ( "io/fs" "os" "path/filepath" + "strings" + "time" "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/logging" @@ -34,10 +36,126 @@ func SaveFolderMissing(savePath string) bool { return errors.Is(err, fs.ErrNotExist) } +// SaveDriveMissing reports whether a save path lives on a drive this device +// does not have at all — a D:\ or E:\ Steam library that exists on another +// PC. That is where a game auto-tracked from a peer lands when the peer's +// library is on a drive letter this machine lacks, and it is not something +// to warn about: nothing was lost here, the game just isn't on this device. +// Only a volume that is plainly absent counts; an unreachable network share +// reports some other error and stays a warning. +func SaveDriveMissing(savePath string) bool { + vol := filepath.VolumeName(savePath) + if vol == "" { + return false + } + _, err := os.Stat(vol + string(filepath.Separator)) + return errors.Is(err, fs.ErrNotExist) +} + +// The same save folder under another drive letter. +// +// A save inside a game's install folder — a Steam library, most often — sits +// on whichever drive that library is on, and that differs between devices: D: +// on one, E: on another. A game tracked on one device and auto-tracked on +// another arrives with the first device's drive letter, and on the second the +// folder is simply "missing" though it is right there on another drive. +// +// So a missing folder is looked for at the identical path on every other drive +// letter. Exactly one match is adopted for this device, and said once. None, or +// more than one (which would be a guess), is said once too, and the search is +// not repeated more often than relocateRecheck: it touches every drive, and the +// minute-long watch reconcile asks about missing folders constantly. +const relocateRecheck = time.Hour + +// relocateCandidates lists the folders at savePath's exact path under the other +// drive letters that exist here. Windows drive letters only. +func relocateCandidates(savePath string) []string { + vol := filepath.VolumeName(savePath) + if len(vol) != 2 || vol[1] != ':' { + return nil + } + rest := savePath[2:] + own := strings.ToUpper(vol[:1]) + var found []string + for c := 'C'; c <= 'Z'; c++ { + letter := string(c) + if letter == own { + continue + } + candidate := letter + ":" + rest + if !SaveFolderMissing(candidate) { + found = append(found, candidate) + } + } + return found +} + +// relocateToOtherDrive switches a game whose save folder is missing to the same +// path on another drive, when there is exactly one. Reports whether it did. +func (d *Daemon) relocateToOtherDrive(gameID, name, savePath string) bool { + d.missingMu.Lock() + last, checked := d.relocateChecked[gameID] + if checked && time.Since(last) < relocateRecheck { + d.missingMu.Unlock() + return false + } + if d.relocateChecked == nil { + d.relocateChecked = map[string]time.Time{} + } + d.relocateChecked[gameID] = time.Now() + d.missingMu.Unlock() + + if name == "" { + name = gameID + } + found := relocateCandidates(savePath) + switch { + case len(found) == 0: + if !checked { + d.Log.Log("info", fmt.Sprintf("the save folder of %q is not on another drive here either (looked for %s on every drive letter)", + name, logging.Quote(savePath[min(2, len(savePath)):]))) + } + return false + case len(found) > 1: + if !checked { + d.Log.Log("warn", fmt.Sprintf("the save folder of %q is missing at %s, and the same path exists on %d other drives (%s) — not guessing which; set it with Edit", + name, logging.Quote(savePath), len(found), strings.Join(found, ", "))) + } + return false + } + + newPath, err := d.ValidateSavePath(found[0]) + if err != nil { + d.Log.Log("warn", fmt.Sprintf("the save folder of %q exists at %s, but it cannot be used: %v", name, logging.Quote(found[0]), err)) + return false + } + game, err := d.Store.GetGame(gameID) + if err != nil || game.SavePath != savePath { + return false // changed meanwhile; the next reconcile sees the new path + } + game.SavePath = newPath + if err := d.Store.UpdateGame(game); err != nil { + d.Log.Log("warn", fmt.Sprintf("could not switch %q to %s: %v", name, logging.Quote(newPath), err)) + return false + } + d.Log.Log("success", fmt.Sprintf("the save folder of %q was not at %s but is at %s on this device — using that", + name, logging.Quote(savePath), logging.Quote(newPath))) + d.missingMu.Lock() + delete(d.missing, gameID) + d.missingMu.Unlock() + if d.OnGameChanged != nil { + d.OnGameChanged(gameID) + } + return true +} + // noteMissing records whether a game's save folder is missing, and says so // once when that changes — not on every minute's check — so the log and the // screen follow the folder going and coming back. func (d *Daemon) noteMissing(gameID, name, savePath string, missing bool) { + if missing && d.relocateToOtherDrive(gameID, name, savePath) { + return // found under another drive letter and switched to it + } d.missingMu.Lock() was := d.missing[gameID] if missing { @@ -55,7 +173,10 @@ func (d *Daemon) noteMissing(gameID, name, savePath string, missing bool) { if name == "" { name = gameID } - if missing { + if missing && SaveDriveMissing(savePath) { + d.Log.Log("info", fmt.Sprintf("the save folder of %q is on %s, which this device doesn't have (%s) — the game is skipped here "+ + "and keeps syncing between the devices that have it", name, filepath.VolumeName(savePath), logging.Quote(savePath))) + } else if missing { d.Log.Log("warn", fmt.Sprintf("the save folder of %q is not there (%s) — it is not watched or synced until it is back; "+ "OpenSave will not create it, since an empty folder in its place would read as every file deleted", name, logging.Quote(savePath))) } else { diff --git a/internal/daemon/missing_test.go b/internal/daemon/missing_test.go new file mode 100644 index 00000000..0b5fdf6c --- /dev/null +++ b/internal/daemon/missing_test.go @@ -0,0 +1,40 @@ +package daemon + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// A save path on a drive letter this machine does not have is "not on this +// device", not "folder gone": the UI informs rather than warns about it. +func TestSaveDriveMissing(t *testing.T) { + existing := t.TempDir() + if SaveDriveMissing(filepath.Join(existing, "saves")) { + t.Error("a folder on an existing drive was reported as on a missing drive") + } + if SaveDriveMissing("relative/path") { + t.Error("a path without a volume was reported as on a missing drive") + } + + if runtime.GOOS != "windows" { + return + } + // Find a drive letter that is not mounted. + for c := 'Z'; c >= 'D'; c-- { + root := string(c) + `:\` + if _, err := os.Stat(root); err == nil { + continue + } + p := string(c) + `:\SteamLibrary\steamapps\common\Game\save` + if !SaveDriveMissing(p) { + t.Errorf("%s: drive %c: is absent but was not reported missing", p, c) + } + if !SaveFolderMissing(p) { + t.Errorf("%s: SaveFolderMissing should still report the folder missing", p) + } + return + } + t.Skip("every drive letter D-Z is mounted") +} diff --git a/internal/daemon/relocate_test.go b/internal/daemon/relocate_test.go new file mode 100644 index 00000000..152755fc --- /dev/null +++ b/internal/daemon/relocate_test.go @@ -0,0 +1,82 @@ +package daemon + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/opensave/opensave/internal/store" +) + +// freeDriveLetter returns a drive letter not mounted here, or "". +func freeDriveLetter() string { + for c := 'Z'; c >= 'D'; c-- { + if _, err := os.Stat(string(c) + `:\`); err != nil { + return string(c) + } + } + return "" +} + +// A game tracked elsewhere with its save on a drive this device lacks, while +// the identical path exists on another drive here, is switched to it — once. +func TestRelocateToOtherDrive(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("drive letters are a Windows thing") + } + free := freeDriveLetter() + if free == "" { + t.Skip("no free drive letter") + } + d := newTestDaemon(t) + real := filepath.Join(t.TempDir(), "SteamLibrary", "steamapps", "common", "Game", "save") + if err := os.MkdirAll(real, 0o777); err != nil { + t.Fatal(err) + } + elsewhere := free + ":" + real[2:] // same path, absent drive + if err := d.Store.CreateGame(store.Game{ID: "g", Name: "Game", SavePath: elsewhere, ActiveBranch: "main", AutoSync: true}); err != nil { + t.Fatal(err) + } + + d.noteMissing("g", "Game", elsewhere, true) + + g, err := d.Store.GetGame("g") + if err != nil { + t.Fatal(err) + } + if !strings.EqualFold(g.SavePath, real) { + t.Fatalf("save path = %s, want it switched to %s", g.SavePath, real) + } + d.missingMu.Lock() + stillMissing := d.missing["g"] + d.missingMu.Unlock() + if stillMissing { + t.Error("the game is still marked missing after being found") + } +} + +// Not found: said once, and not searched again on every reconcile. +func TestRelocateNotFound_IsNotRepeated(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("drive letters are a Windows thing") + } + free := freeDriveLetter() + if free == "" { + t.Skip("no free drive letter") + } + d := newTestDaemon(t) + path := free + `:\NoSuchLibrary\steamapps\common\Nothing\save` + if err := d.Store.CreateGame(store.Game{ID: "n", Name: "Nothing", SavePath: path, ActiveBranch: "main", AutoSync: true}); err != nil { + t.Fatal(err) + } + if d.relocateToOtherDrive("n", "Nothing", path) { + t.Fatal("relocated to a folder that does not exist") + } + first := d.relocateChecked["n"] + d.relocateToOtherDrive("n", "Nothing", path) + if !d.relocateChecked["n"].Equal(first) { + t.Error("searched again straight away; it should wait relocateRecheck") + } +} From a91527e80aee0b2659c461a29be1432eb77f2daa Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:00:25 +0200 Subject: [PATCH 18/75] app: wait for the daemon before the window asks for its address; smaller memory footprint - DaemonAddr waits for startup to finish. The window could ask while the daemon was still starting (8-18s with many watched folders), got an empty address and showed "can't reach ... at http://" until Retry. - GOGC 50 and a 1GiB soft memory limit, unless GOGC/GOMEMLIMIT are set. --- cmd/opensave-app/main.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cmd/opensave-app/main.go b/cmd/opensave-app/main.go index 09e46f07..4f8ca678 100644 --- a/cmd/opensave-app/main.go +++ b/cmd/opensave-app/main.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "runtime" + "runtime/debug" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" @@ -45,6 +46,8 @@ func main() { // removes its leftover binary) before claiming the single-instance lock. cleanupReplacedBinary() + tuneGC() + app := NewApp() err := wails.Run(&options.App{ @@ -97,3 +100,17 @@ func main() { os.Exit(1) } } + +// tuneGC trades a little CPU for a smaller footprint. OpenSave is a +// background app, and Go's default (let the heap double before collecting) +// meant a large save's manifests showed up as twice their size in Task +// Manager. The soft limit makes the runtime collect and hand memory back to +// the OS harder as it approaches 1GiB. Explicit GOGC / GOMEMLIMIT win. +func tuneGC() { + if os.Getenv("GOGC") == "" { + debug.SetGCPercent(50) + } + if os.Getenv("GOMEMLIMIT") == "" { + debug.SetMemoryLimit(1 << 30) + } +} From 10a88e7f9e91614e26e83df573c6192ef7e8973b Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:53:42 +0200 Subject: [PATCH 19/75] presence: do not trust "online" left over from the previous run Every peer started each run with whatever status the database held, and a peer counts as offline only after three failed probes. For a PC switched off overnight that meant being shown online after every start until three probes - the first ten seconds after the background loops started - had failed. - The presence loop probes at once on start, then every 10s. - The three-miss tolerance is for devices heard from during this run (answered a probe, or seen by discovery, a request or the relay since start). A peer with no sign of life since start goes offline on its first unanswered probe. Adds an opt-in e2e measurement (OPENSAVE_PRESENCE_TIMING) of how long a stopped device is shown online: 30s, by design, for one seen this run. --- e2e/presence_timing_test.go | 53 +++++++++++++++++++++++++++++++++++ internal/p2p/engine.go | 37 +++++++++++++++++++++++- internal/p2p/presence_test.go | 43 ++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 e2e/presence_timing_test.go diff --git a/e2e/presence_timing_test.go b/e2e/presence_timing_test.go new file mode 100644 index 00000000..4d9402d6 --- /dev/null +++ b/e2e/presence_timing_test.go @@ -0,0 +1,53 @@ +package e2e + +import ( + "os" + "testing" + "time" + + "github.com/opensave/opensave/testutil" +) + +// TestPresence_GoneDeviceShowsOffline measures how long a device that went +// away keeps being shown online. Opt-in measurement: +// OPENSAVE_PRESENCE_TIMING=1 go test ./e2e -run TestPresence_ -v +func TestPresence_GoneDeviceShowsOffline(t *testing.T) { + if os.Getenv("OPENSAVE_PRESENCE_TIMING") == "" { + t.Skip("set OPENSAVE_PRESENCE_TIMING to run") + } + a := testutil.NewTestDaemon(t, "Presence-A") + b := testutil.NewTestDaemon(t, "Presence-B") + a.PairWith(b) + bID := b.NodeID() + + status := func() string { + p, err := a.Daemon.Store.GetPeer(bID) + if err != nil { + return "?" + } + return p.Status + } + if !testutil.WaitFor(60*time.Second, func() bool { return status() == "online" }) { + t.Fatalf("B never showed online on A: %q", status()) + } + + gone := time.Now() + b.Server.Stop() + b.Daemon.Stop() + + last := status() + for time.Since(gone) < 10*time.Minute { + if s := status(); s != last { + t.Logf("%6.1fs after B stopped: %s -> %s", time.Since(gone).Seconds(), last, s) + last = s + } + if last == "offline" { + break + } + time.Sleep(500 * time.Millisecond) + } + t.Logf("B shown online for %.1fs after it stopped", time.Since(gone).Seconds()) + if last != "offline" { + t.Fatalf("B still %q 10 minutes after it stopped", last) + } +} diff --git a/internal/p2p/engine.go b/internal/p2p/engine.go index 8f92209d..020d24a2 100644 --- a/internal/p2p/engine.go +++ b/internal/p2p/engine.go @@ -125,6 +125,10 @@ type Engine struct { pingMisses map[string]int // probeMu serializes PingPairedPeers rounds. probeMu sync.Mutex + // answered holds the peers that answered a probe during this run, and + // startedMs is when the run began (heardThisRun). + answered map[string]bool + startedMs int64 // Serializes the offline->online transition in requirePairedPeer, so a // burst of concurrent requests from a returning peer triggers one full @@ -294,6 +298,7 @@ func New(s *store.Store, snaps *snapshot.Manager, logf func(level, msg string)) Log: logf, ctx: ctx, cancel: cancel, + startedMs: time.Now().UnixMilli(), } e.Wan = newWanClient(e) e.RelayHost = NewRelayHost(logf) @@ -390,6 +395,26 @@ func (e *Engine) clearPingMisses(peerID string) { delete(e.pingMisses, peerID) } +// noteAnswered records that a peer answered a probe during this run. +func (e *Engine) noteAnswered(peerID string) { + e.pingMu.Lock() + defer e.pingMu.Unlock() + if e.answered == nil { + e.answered = map[string]bool{} + } + e.answered[peerID] = true +} + +// heardThisRun reports whether there is any sign of a peer since this engine +// started: a probe it answered, or a sighting (discovery, a request from it, +// the relay) recent enough to postdate the start. +func (e *Engine) heardThisRun(p store.Peer) bool { + e.pingMu.Lock() + answered := e.answered[p.ID] + e.pingMu.Unlock() + return answered || p.LastSeenMs >= e.startedMs +} + // PingPairedPeers probes every paired LAN peer and updates their // online/offline status. func (e *Engine) PingPairedPeers(ctx context.Context) { @@ -430,11 +455,18 @@ func (e *Engine) PingPairedPeers(ctx context.Context) { // failed cannot lose anything: a device that really has gone is // declared offline a few probes later, and the only cost is a sync // attempt that fails the way it would have anyway. + // + // That patience is for a device seen during this run. A status + // carried over from the previous run is not evidence of anything: a + // PC switched off overnight came back "online" from the database on + // every start, and stayed so until three probes had failed. A device + // not heard from since this start goes offline on its first miss. newStatus := p.Status if ok { e.clearPingMisses(p.ID) + e.noteAnswered(p.ID) newStatus = "online" - } else if e.notePingMiss(p.ID) >= offlineStrikes { + } else if misses := e.notePingMiss(p.ID); misses >= offlineStrikes || !e.heardThisRun(p) { newStatus = "offline" } @@ -608,6 +640,9 @@ var presenceInterval = 10 * time.Second // whatever sync work is running. It stops with the resync loop. func (e *Engine) startPresenceLoop(stop chan struct{}) { e.GoSync(func(ctx context.Context) { + // At once, then on the ticker: until the first round, every peer + // shows whatever the previous run left in the database. + e.PingPairedPeers(ctx) ticker := time.NewTicker(presenceInterval) defer ticker.Stop() for { diff --git a/internal/p2p/presence_test.go b/internal/p2p/presence_test.go index de0397de..aca90a06 100644 --- a/internal/p2p/presence_test.go +++ b/internal/p2p/presence_test.go @@ -25,6 +25,49 @@ func waitUntil(d time.Duration, cond func() bool) bool { return cond() } +// A PC switched off since the last run is in the database as "online". It must +// not stay so for the three probes a device seen this run is allowed: the +// first unanswered probe after start says it is not there. +func TestPresence_StaleOnlineFromLastRunClearsAtOnce(t *testing.T) { + old := presenceInterval + presenceInterval = time.Hour // only the probe at start + t.Cleanup(func() { presenceInterval = old }) + + s, err := store.Open(filepath.Join(t.TempDir(), "opensave.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + if err := s.EnsureDefaultSettings(t.TempDir(), t.TempDir()); err != nil { + t.Fatal(err) + } + // Nothing listens here: the PC is off. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + lastRun := time.Now().Add(-10 * time.Hour).UnixMilli() + if err := s.UpsertPeer(store.Peer{ID: "node_off", Name: "Switched off", Address: "127.0.0.1", Port: port, Status: "online", LastSeenMs: lastRun}); err != nil { + t.Fatal(err) + } + + e := New(s, snapshot.New(s), func(string, string) {}) + stop := make(chan struct{}) + start := time.Now() + e.startPresenceLoop(stop) + t.Cleanup(func() { close(stop); e.cancel(); e.bgSyncWG.Wait() }) + + if !waitUntil(5*time.Second, func() bool { + p, err := s.GetPeer("node_off") + return err == nil && p.Status == "offline" + }) { + t.Fatal("a peer online only in the last run's record was still online after the first probe") + } + t.Logf("shown offline %.1fs after start", time.Since(start).Seconds()) +} + // A peer's online state follows it on its own ticker, whatever else the // engine is doing — not only when a sync or the minute-long reconcile happens // to probe. A peer reached over a VPN gets no UDP broadcasts, so without this From 6eb92be2e6484890d6490926845104ffb89b0c52 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:47:34 +0200 Subject: [PATCH 20/75] watcher: changes OpenSave makes itself are not new saves New package internal/owntouch records the paths OpenSave itself writes, removes or re-dates (pulled files, a peer's deletions, the mtimes "keep mine" touches). The watcher classifies every event by it: a burst made only of OpenSave's own changes moves the recorded hash and nothing else - no auto-snapshot, and no OnChanged sending the sync straight back out. Before, every change a sync applied looked exactly like the game saving. A peer deleting files one request at a time produced an auto-snapshot every couple of seconds, each of a save that was neither the old one nor the new one; they filled the retention budget and pushed out the snapshots that mattered. - a file counts as OpenSave's only if nothing wrote it after the mark (its mtime is not later), so a game saving straight after a sync is still the game's change. - the rescan after new folders come under watch is OpenSave's own when it created those folders (a pull of a save with subfolders), and someone else's otherwise. --- internal/delta/patch.go | 4 + internal/owntouch/owntouch.go | 89 ++++++++++++++++++++ internal/owntouch/owntouch_test.go | 51 ++++++++++++ internal/watcher/owntouch_test.go | 127 +++++++++++++++++++++++++++++ internal/watcher/watcher.go | 62 +++++++++++++- 5 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 internal/owntouch/owntouch.go create mode 100644 internal/owntouch/owntouch_test.go create mode 100644 internal/watcher/owntouch_test.go diff --git a/internal/delta/patch.go b/internal/delta/patch.go index 7976fc45..18ef589f 100644 --- a/internal/delta/patch.go +++ b/internal/delta/patch.go @@ -9,6 +9,7 @@ import ( "time" "github.com/opensave/opensave/internal/fsx" + "github.com/opensave/opensave/internal/owntouch" ) // BlockSource supplies the bytes for one block index, either from the @@ -77,6 +78,9 @@ func replaceWithRetry(tmpPath, filePath string) error { delay *= 2 } clearReadOnlyIfSet(filePath) + // Marked before the rename, so the watcher's event finds it already + // known as a change OpenSave made (owntouch). + owntouch.Mark(filePath) if err = renameFile(tmpPath, filePath); err == nil { return nil } diff --git a/internal/owntouch/owntouch.go b/internal/owntouch/owntouch.go new file mode 100644 index 00000000..7675623c --- /dev/null +++ b/internal/owntouch/owntouch.go @@ -0,0 +1,89 @@ +// Package owntouch remembers which paths in save folders OpenSave itself has +// just written or removed, so the watcher can tell those changes apart from +// ones a game or a person made. +// +// Without it every change OpenSave applied — a file pulled from a peer, a +// deletion a peer asked for, the mtimes "keep mine" touches — looked to the +// watcher exactly like the game saving. A peer deleting files one request at +// a time over several minutes produced a full auto-snapshot every couple of +// seconds, each of a save that was neither the old one nor the new one, and +// those filled the retention budget and pushed out the snapshots that mattered. +package owntouch + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" +) + +// window is how long a mark counts. Long enough to cover the watcher's +// debounce and a slow burst of events behind it; short enough that the same +// path changed again by a game a little later is not mistaken for ours. +const window = 2 * time.Minute + +var ( + mu sync.Mutex + marked = map[string]time.Time{} +) + +func key(path string) string { + p := filepath.Clean(path) + if runtime.GOOS == "windows" { + p = strings.ToLower(p) + } + return p +} + +// Mark records that OpenSave itself just created, wrote, removed or re-dated +// path. Its parent folder is marked too: creating or removing an entry is an +// event on the folder as well. +func Mark(path string) { + if path == "" { + return + } + now := time.Now() + mu.Lock() + defer mu.Unlock() + marked[key(path)] = now + marked[key(filepath.Dir(path))] = now + if len(marked) > 50000 { + for k, at := range marked { + if now.Sub(at) > window { + delete(marked, k) + } + } + } +} + +// slack is how much later than its mark a file OpenSave wrote may be dated: +// re-dating to now happens just after marking, and some filesystems keep +// times to two seconds. +const slack = 2 * time.Second + +// Recent reports whether the change just seen at path is one OpenSave made: +// the path was marked within the window, and nothing has written it since. +// +// The second half matters as much as the first. A game often saves straight +// after a sync — the same file OpenSave has just put there — and a mark alone +// would take that save for the sync's for two minutes: no snapshot of it, and +// no new version to hand to anyone. Whatever OpenSave leaves behind is dated +// no later than its mark (a pulled file keeps the time it was written to its +// temporary name, or is given the peer's; re-dating sets now), so a file +// dated later was written by someone else. A path that is gone was removed by +// OpenSave; a folder carries no save of its own, so its mark is enough. +func Recent(path string) bool { + mu.Lock() + at, ok := marked[key(path)] + mu.Unlock() + if !ok || time.Since(at) > window { + return false + } + fi, err := os.Lstat(path) + if err != nil || fi.IsDir() { + return true + } + return !fi.ModTime().After(at.Add(slack)) +} diff --git a/internal/owntouch/owntouch_test.go b/internal/owntouch/owntouch_test.go new file mode 100644 index 00000000..94ad5edf --- /dev/null +++ b/internal/owntouch/owntouch_test.go @@ -0,0 +1,51 @@ +package owntouch + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestMarkAndRecent(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "sub", "slot1.sav") + if Recent(file) { + t.Fatal("unmarked path reported as ours") + } + Mark(file) + if !Recent(file) { + t.Error("marked path not reported as ours") + } + if !Recent(filepath.Dir(file)) { + t.Error("the parent folder of a marked path not reported as ours") + } + if Recent(filepath.Join(dir, "sub", "slot2.sav")) { + t.Error("a sibling of a marked path reported as ours") + } +} + +// A game saving over a file OpenSave has just written is the game's change, +// however soon after. +func TestRecent_AWriteAfterTheMarkIsNotOurs(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "slot1.sav") + Mark(file) + if err := os.WriteFile(file, []byte("pulled"), 0o666); err != nil { + t.Fatal(err) + } + if !Recent(file) { + t.Fatal("the file OpenSave wrote is not reported as ours") + } + // The game saves a little later. + later := time.Now().Add(5 * time.Second) + if err := os.WriteFile(file, []byte("played"), 0o666); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(file, later, later); err != nil { + t.Fatal(err) + } + if Recent(file) { + t.Error("a save written after OpenSave's was taken for OpenSave's") + } +} diff --git a/internal/watcher/owntouch_test.go b/internal/watcher/owntouch_test.go new file mode 100644 index 00000000..6a1e8134 --- /dev/null +++ b/internal/watcher/owntouch_test.go @@ -0,0 +1,127 @@ +package watcher + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/opensave/opensave/internal/owntouch" +) + +// Files OpenSave writes or deletes itself — a pull, a peer's deletions — are +// not a new save. Only the recorded hash moves; no auto-snapshot, and no +// OnChanged echoing the sync back out. +func TestOwnChanges_AreNotSnapshotted(t *testing.T) { + saveDir := t.TempDir() + col := newCollector() + eng := New(col.callbacks()) + defer eng.Stop() + if err := eng.Watch("game1", saveDir); err != nil { + t.Fatal(err) + } + + // A peer's deletions arriving one at a time, as they do, plus pulled files. + for i := 0; i < 5; i++ { + p := filepath.Join(saveDir, fmt.Sprintf("map_%d.bin", i)) + owntouch.Mark(p) + if err := os.WriteFile(p, []byte(fmt.Sprint(i)), 0o666); err != nil { + t.Fatal(err) + } + time.Sleep(300 * time.Millisecond) + } + // Past the debounce, with room to spare. + if !waitFor(t, 8*time.Second, func() bool { + col.mu.Lock() + defer col.mu.Unlock() + return col.manifestHashes["game1"] != "" + }) { + t.Fatal("the recorded hash was not moved to the new content") + } + time.Sleep(3 * time.Second) + if n := col.snapshotCount(); n != 0 { + t.Errorf("%d auto-snapshot(s) for changes OpenSave made itself, want 0", n) + } + if n := col.changedCount(); n != 0 { + t.Errorf("OnChanged fired %d time(s) for changes OpenSave made itself", n) + } + + // The game then saves: that is snapshotted, as always. + if err := os.WriteFile(filepath.Join(saveDir, "slot1.sav"), []byte("played"), 0o666); err != nil { + t.Fatal(err) + } + if !waitFor(t, 10*time.Second, func() bool { return col.snapshotCount() >= 1 }) { + t.Fatal("a change the game made was not snapshotted") + } +} + +// A pull that creates folders and writes into them before the watcher has put +// them under watch is still the pull: the rescan that follows registering +// them is not someone else's change. +func TestOwnNewFolders_AreNotSnapshotted(t *testing.T) { + saveDir := t.TempDir() + col := newCollector() + eng := New(col.callbacks()) + defer eng.Stop() + if err := eng.Watch("game1", saveDir); err != nil { + t.Fatal(err) + } + for i := 0; i < 20; i++ { + dir := filepath.Join(saveDir, fmt.Sprintf("slot%02d", i)) + owntouch.Mark(dir) + if err := os.MkdirAll(dir, 0o777); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "data.sav") + owntouch.Mark(p) + if err := os.WriteFile(p, []byte(fmt.Sprint(i)), 0o666); err != nil { + t.Fatal(err) + } + } + if !waitFor(t, 10*time.Second, func() bool { + col.mu.Lock() + defer col.mu.Unlock() + return col.manifestHashes["game1"] != "" + }) { + t.Fatal("the recorded hash was not moved to the new content") + } + time.Sleep(4 * time.Second) + if n := col.snapshotCount(); n != 0 { + t.Errorf("%d auto-snapshot(s) for folders and files a pull created", n) + } + + // A folder the game creates is a change, as before. + if err := os.MkdirAll(filepath.Join(saveDir, "newslot"), 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(saveDir, "newslot", "data.sav"), []byte("played"), 0o666); err != nil { + t.Fatal(err) + } + if !waitFor(t, 10*time.Second, func() bool { return col.snapshotCount() >= 1 }) { + t.Fatal("a folder the game created was not snapshotted") + } +} + +// A burst that mixes OpenSave's own changes with one from the game is a real +// change and is snapshotted. +func TestMixedChanges_AreSnapshotted(t *testing.T) { + saveDir := t.TempDir() + col := newCollector() + eng := New(col.callbacks()) + defer eng.Stop() + if err := eng.Watch("game1", saveDir); err != nil { + t.Fatal(err) + } + own := filepath.Join(saveDir, "pulled.bin") + owntouch.Mark(own) + if err := os.WriteFile(own, []byte("from a peer"), 0o666); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(saveDir, "slot1.sav"), []byte("played"), 0o666); err != nil { + t.Fatal(err) + } + if !waitFor(t, 10*time.Second, func() bool { return col.snapshotCount() >= 1 }) { + t.Fatal("a burst including the game's own change was not snapshotted") + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 612ebfb6..3c9b7ee9 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -32,6 +32,7 @@ import ( "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/ignore" "github.com/opensave/opensave/internal/logging" + "github.com/opensave/opensave/internal/owntouch" ) const ( @@ -188,6 +189,17 @@ type gameWatch struct { // registerFolders when an Add fails. rewatch atomic.Bool + // What the burst since the last snapshot check was made of: changes + // OpenSave applied itself (owntouch), anything else, or both. Read and + // written only by the event loop. + ownEvents, otherEvents bool + // foreignFolders: a folder queued for watching since the last rescan was + // not one OpenSave created, or the watch started on a busy folder. The + // rescan that follows registration counts as someone else's change only + // then — a pull creates folders and writes into them before they are + // watched, and that is still the pull. Event loop only. + foreignFolders bool + // folders carries directories to put under watch to registerFolders. See // there for why the event loop does not register them itself. folders chan string @@ -450,6 +462,11 @@ func (e *Engine) WatchWithLocations(gameID, savePath string, extra map[string]st } if busy { gw.rewatch.Store(true) + // What the folder holds is nobody's change OpenSave knows of: this + // rescan must not record it as its own, or a change made while the + // app was closed would become the baseline before the catch-up + // below could see it. Set before the event loop starts. + gw.foreignFolders = true gw.rescan <- struct{}{} // buffered and empty: cannot block } e.games[gameID] = gw @@ -676,10 +693,19 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { if !gw.eventRelevant(event) { continue } + own := owntouch.Recent(event.Name) + if own { + gw.ownEvents = true + } else { + gw.otherEvents = true + } // New subdirectory in directory mode: extend the watch — on // registerFolders, never here. See registerFolders. if !gw.isFile && event.Has(fsnotify.Create) { if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + if !own { + gw.foreignFolders = true + } gw.queueFolder(event.Name) } } @@ -689,7 +715,14 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { // Folders just came under watch, or the watch started on a folder // that was busy. Anything written into a folder before its watch // existed raised no event of its own, so read the tree again once - // things are quiet. + // things are quiet. Whose change that is follows from whose + // folders they were: what OpenSave created, it also filled. + if gw.foreignFolders { + gw.otherEvents = true + } else { + gw.ownEvents = true + } + gw.foreignFolders = false resetDebounce() case err, ok := <-gw.fsw.Errors: @@ -718,6 +751,7 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { // directory and allocates no second buffer for one it already // has. gw.rewatch.Store(true) + gw.otherEvents = true // the missed events could be anyone's } else { e.log("warn", fmt.Sprintf("watching %q: %v", gw.gameID, err)) continue @@ -758,7 +792,9 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { for _, path := range gw.extra { delta.InvalidateRoot(path) } - e.handleChange(ctx, gw) + ownOnly := gw.ownEvents && !gw.otherEvents + gw.ownEvents, gw.otherEvents = false, false + e.handleChangeFrom(ctx, gw, ownOnly) } } } @@ -788,6 +824,22 @@ func (gw *gameWatch) eventRelevant(event fsnotify.Event) bool { // (gameplay guard), skip if content is unchanged since the last // auto-snapshot, then snapshot with retries and notify. func (e *Engine) handleChange(ctx context.Context, gw *gameWatch) { + e.handleChangeFrom(ctx, gw, false) +} + +// handleChangeFrom is handleChange told whether every change in the burst was +// one OpenSave applied itself — a pull, a deletion a peer asked for, mtimes a +// conflict resolution touched (owntouch). +// +// Those are not a new save. They are a sync, which keeps its own history +// (the mirror snapshot of a pull, the safety snapshot before files are +// replaced). Snapshotting them as well took a full auto-snapshot every couple +// of seconds while a peer deleted files one request at a time, each of a save +// half-way between two states, until they had filled the retention budget and +// pushed out the snapshots worth keeping. So the burst only moves the recorded +// hash, which keeps the next real change measured against what is actually on +// disk; anything not known to be ours is snapshotted as before. +func (e *Engine) handleChangeFrom(ctx context.Context, gw *gameWatch, ownOnly bool) { // Gameplay guard: the game may still be mid-write. for anyFileLocked(gw.savePath) { e.log("info", fmt.Sprintf("save files for %q are in use; waiting (gameplay guard)", gw.gameID)) @@ -821,6 +873,12 @@ func (e *Engine) handleChange(ctx context.Context, gw *gameWatch) { e.log("info", fmt.Sprintf("no content change for %q; skipping auto-snapshot", gw.gameID)) return } + if ownOnly { + if err := e.cb.SetLastManifestHash(gw.gameID, currentHash); err != nil { + e.log("warn", fmt.Sprintf("failed to record manifest hash for %q: %v", gw.gameID, err)) + } + return + } for attempt := 1; attempt <= snapshotMaxRetries; attempt++ { err = e.cb.CreateSnapshot(gw.gameID) From d135694cc0e726eb2b51a96bb3d7af4bc1e88097 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:47:41 +0200 Subject: [PATCH 21/75] sync: decide by save versions, not by guessing from files Every device now records, per game, which version of the save it holds as a version vector: one counter per device, moved only when the save really changes there (never by a sync; see internal/owntouch). The version travels in the manifest response, and two devices compare versions before anything else: - one older than the other: that device is behind. It takes the newer save whole - every file and every deletion - and only from a device holding it completely; only then does it adopt that version. Until then it is a source for nobody: nothing is taken from it, pushed by it, or deleted on its word. A pull that stops part-way is remembered across restarts, so the half-copy is never mistaken for a change made there. - a device remembers the newest version it has heard of (its target). Two devices that both know a newer one exists do nothing with each other until a device holding it is back. This is what stopped a half-finished copy being passed on, with everything it lacked read as deletions. - an empty folder holds no version, so it fills itself and never raises a conflict. - neither newer: both changed independently. Only this asks. Resolution stays mutual: "keep mine" on one device does not make the other's save simply older - the other device is asked in turn, and the first is not asked again (the answer travels with the version). If both keep their own, the later answer stands. Every sync and every manifest served first checks the save against the hash recorded with its version, so a change the watcher has not reported yet (it waits for the game to finish writing) is a version before anyone compares - otherwise a device with unrecorded work would look merely behind and take the other's save over it. Changes to excluded files are not versions, and neither is writing an exclusion rule: the recorded hash is tagged with the rules it was taken under and simply re-taken when they change. A device fetching back a save it was asked to put back after it was emptied (hold.go) follows the hold's rules. Saves from before versions get one named after their content, so devices that already agreed agree at once; ones that did not are compared as before until they agree. Games with automatic sync off are not watched, so they keep the old file comparison; so do peers that predate this. Also: - the mass-deletion guard is removed. It held syncs that would delete many files for a decision - a stopgap for deletions read from a half-finished copy, which versions now rule out at the source. - the sync engine marks what it writes and removes (owntouch), and a burst of deletions a peer asks for takes one safety snapshot, at most every ten minutes, instead of one auto-snapshot per file. --- internal/cliapp/syncsummary.go | 4 + internal/daemon/daemon.go | 3 + internal/p2p/engine.go | 4 + internal/p2p/routes.go | 40 + internal/p2p/syncengine/conflict.go | 18 + internal/p2p/syncengine/engine.go | 64 +- internal/p2p/syncengine/massdelete.go | 28 - internal/p2p/syncengine/massdelete_test.go | 69 -- internal/p2p/syncengine/quick.go | 10 + internal/p2p/syncengine/transport.go | 4 + internal/p2p/syncengine/version.go | 827 ++++++++++++++++++ internal/p2p/syncengine/version_test.go | 575 ++++++++++++ .../store/migrations/0037_game_versions.sql | 42 + internal/store/versions.go | 71 ++ 14 files changed, 1643 insertions(+), 116 deletions(-) delete mode 100644 internal/p2p/syncengine/massdelete.go delete mode 100644 internal/p2p/syncengine/massdelete_test.go create mode 100644 internal/p2p/syncengine/version.go create mode 100644 internal/p2p/syncengine/version_test.go create mode 100644 internal/store/migrations/0037_game_versions.sql create mode 100644 internal/store/versions.go diff --git a/internal/cliapp/syncsummary.go b/internal/cliapp/syncsummary.go index b5518341..b07eeb07 100644 --- a/internal/cliapp/syncsummary.go +++ b/internal/cliapp/syncsummary.go @@ -101,6 +101,10 @@ func outcomeFromPeers(gameID string, peers map[string]peerSyncResult) syncOutcom waiting = who + " is waiting for a folder to be chosen for it" case "peer_holding": waiting = who + " is holding it back: its save was emptied there" + case "waiting": + if waiting == "" { + waiting = "a newer version is on a device that is not online" + } case "peer_missing": if waiting == "" { waiting = who + " does not track it" diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 5683cd88..4e2ffcf0 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -233,6 +233,9 @@ func New(opts Options) (*Daemon, error) { return err }, OnChanged: func(gameID string) { + // A change made here, by the game or the user: a new version of + // the save, recorded before anyone is offered it. + d.P2P.Sync.NoteLocalChange(gameID) // An emptied save is noticed as it happens, and said on screen, // even with no other device online to hold it back from. _, _ = d.P2P.Sync.CheckHold(gameID, false) diff --git a/internal/p2p/engine.go b/internal/p2p/engine.go index 020d24a2..ca3e8fce 100644 --- a/internal/p2p/engine.go +++ b/internal/p2p/engine.go @@ -125,6 +125,10 @@ type Engine struct { pingMisses map[string]int // probeMu serializes PingPairedPeers rounds. probeMu sync.Mutex + // When each game last got a safety snapshot before a peer's deletions + // (snapshotBeforePeerDeletions). + peerDeleteSnapMu sync.Mutex + peerDeleteSnap map[string]time.Time // answered holds the peers that answered a probe during this run, and // startedMs is when the run began (heardThisRun). answered map[string]bool diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index 89cfd6bd..dccff6ce 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -19,6 +19,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/logging" + "github.com/opensave/opensave/internal/owntouch" "github.com/opensave/opensave/internal/p2p/pairing" "github.com/opensave/opensave/internal/p2p/syncengine" "github.com/opensave/opensave/internal/store" @@ -723,6 +724,7 @@ func (e *Engine) handleManifest(w http.ResponseWriter, r *http.Request) { ActiveBranch: game.ActiveBranch, Proto: ServedProto(), DeletionConfirmed: e.Sync.DeletionConfirmed(game.ID), + Version: e.Sync.LocalVersion(game, manifest), } if latest, err := e.Snapshots.LatestSnapshot(gameID, ""); err == nil { resp.LatestSnapshot = &syncengine.SnapshotInfo{ID: latest.ID, Timestamp: latest.Timestamp, Comment: latest.Comment} @@ -881,6 +883,37 @@ func (e *Engine) handleFileBatch(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]any{"files": out}) } +// peerDeleteSnapshotEvery is how far apart two safety snapshots before a +// peer's deletions may be: one for a burst of deletions, however many files +// it removes one request at a time. +const peerDeleteSnapshotEvery = 10 * time.Minute + +// snapshotBeforePeerDeletions takes a safety snapshot of a game before the +// first of a burst of deletions a peer asks for. +func (e *Engine) snapshotBeforePeerDeletions(gameID string, r *http.Request) { + if e.Snapshots == nil { + return + } + e.peerDeleteSnapMu.Lock() + if e.peerDeleteSnap == nil { + e.peerDeleteSnap = map[string]time.Time{} + } + if last, ok := e.peerDeleteSnap[gameID]; ok && time.Since(last) < peerDeleteSnapshotEvery { + e.peerDeleteSnapMu.Unlock() + return + } + e.peerDeleteSnap[gameID] = time.Now() + e.peerDeleteSnapMu.Unlock() + + asker := "another device" + if peer, ok := e.peerByAddress(clientIP(r)); ok { + asker = peer.Name + } + if _, err := e.Snapshots.CreateBeforeReplacing(gameID, fmt.Sprintf("Before %s deleted files here", asker)); err != nil { + e.Log("warn", fmt.Sprintf("safety snapshot before %s's deletions failed: %v", asker, err)) + } +} + func (e *Engine) handleDeleteFile(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "gameId") var body struct { @@ -915,6 +948,11 @@ func (e *Engine) handleDeleteFile(w http.ResponseWriter, r *http.Request) { _ = os.Chmod(full, 0o666) deleting := time.Now() if info, statErr := os.Stat(full); statErr == nil { + // One copy of the save before a peer starts deleting from it, not + // one per file: the watcher no longer snapshots changes OpenSave + // applies (owntouch), and it was those, one every few seconds of a + // half-deleted save, that had been the only copy before. + e.snapshotBeforePeerDeletions(game.ID, r) // What is removed is remembered, so a sync of this device's own that // lands before the rest of the batch does not take it for a change // made here (syncengine/peerdeleted.go). @@ -923,6 +961,8 @@ func (e *Engine) handleDeleteFile(w http.ResponseWriter, r *http.Request) { entry, _ = delta.FileEntryFor(full) } // Empty dirs only, for a folder, like rmdirSync. + // A change made at a peer's request, not by the game (owntouch). + owntouch.Mark(full) if os.Remove(full) == nil && (info.IsDir() || entry.Hash != "") { e.Sync.NotePeerDeletion(game.ID, body.Root, body.RelPath, entry, info.IsDir()) } diff --git a/internal/p2p/syncengine/conflict.go b/internal/p2p/syncengine/conflict.go index 1cef4b5b..dfd81581 100644 --- a/internal/p2p/syncengine/conflict.go +++ b/internal/p2p/syncengine/conflict.go @@ -8,6 +8,7 @@ import ( "time" "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/owntouch" "github.com/opensave/opensave/internal/snapshot" ) @@ -44,6 +45,9 @@ func (e *Engine) ResolveConflict(ctx context.Context, gameID, peerID, resolution if err := e.markResolvedLocal(ctx, gameID, peer); err != nil { return "", err } + // This device's version becomes newer than both, so the peer — and any + // device holding either side's — takes it whole (version.go). + e.versionAfterResolution(gameID, conflict.remoteVersion, true) e.clearConflict(gameID) e.Transport.TriggerPeerPull(peer, gameID) return "", nil @@ -63,8 +67,18 @@ func (e *Engine) ResolveConflict(ctx context.Context, gameID, peerID, resolution e.Log("warn", fmt.Sprintf("safety snapshot before keep-remote failed: %v", err)) } if err := e.overwriteLocalWithRemote(ctx, gameID, peer, remoteData, "Resolved conflict: Overwrite with remote"); err != nil { + // This device's own version is given up either way; what is still + // missing comes from the peer like any outdated device's would. + e.discardLocalVersion(gameID, remoteData.Version) return "", err } + if game, err := e.Store.GetGame(gameID); err == nil { + if now, err := delta.BuildManifest(game.SavePath); err == nil && sameFiles(now, remoteData.Manifest) { + e.versionAfterResolution(gameID, remoteData.Version, false) + } else { + e.discardLocalVersion(gameID, remoteData.Version) + } + } e.markResolvedConverged(gameID, peer) e.clearConflict(gameID) return "", nil @@ -106,6 +120,7 @@ func (e *Engine) ResolveConflict(ctx context.Context, gameID, peerID, resolution if err := e.markResolvedLocal(ctx, gameID, peer); err != nil { return "", err } + e.versionAfterResolution(gameID, conflict.remoteVersion, true) e.clearConflict(gameID) e.Transport.TriggerPeerPull(peer, gameID) return created, nil @@ -222,6 +237,7 @@ func (e *Engine) touchSaveMtimes(gameID, root string) { return } if !info.IsDir() { + owntouch.Mark(root) _ = os.Chtimes(root, now, now) return } @@ -229,6 +245,7 @@ func (e *Engine) touchSaveMtimes(gameID, root string) { if walkErr != nil || fi.IsDir() { return nil } + owntouch.Mark(path) _ = os.Chtimes(path, now, now) return nil }) @@ -362,6 +379,7 @@ func (e *Engine) overwriteLocalWithRemote(ctx context.Context, gameID string, pe } full := filepath.Join(game.SavePath, filepath.FromSlash(relPath)) _ = os.Chmod(full, 0o666) + owntouch.Mark(full) _ = os.Remove(full) } diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index ac58f604..e646ead2 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -12,6 +12,7 @@ import ( "time" "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/owntouch" "github.com/opensave/opensave/internal/snapshot" "github.com/opensave/opensave/internal/store" "github.com/opensave/opensave/internal/syncpause" @@ -34,6 +35,10 @@ type Conflict struct { OnlyLocalTotal int `json:"onlyLocalTotal"` OnlyRemoteTotal int `json:"onlyRemoteTotal"` ChangedTotal int `json:"changedTotal"` + + // remoteVersion is the peer's version when the conflict was found, which + // the answer is recorded against (version.go). + remoteVersion *VersionInfo } // SideStats summarises one side's save state for the conflict UI. @@ -143,6 +148,13 @@ type Engine struct { // rootConflicts holds divergences in a game's EXTRA save locations, keyed // by game and location so several can wait on a decision at once. rootConflicts map[string]*RootConflict + + // versionMu serialises reading and writing the save versions + // (version.go); pullingNow is the games this run is pulling a newer + // version of, and loggedOnce the last "waiting" line said per game/peer. + versionMu sync.Mutex + pullingNow map[string]bool + loggedOnce map[string]string } // New creates an Engine. @@ -341,7 +353,7 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer // divergence from the NEXT sync, causing the peer to silently overwrite // its own changes instead of detecting the conflict and asking. switch res.Status { - case "conflict", "error": + case "conflict", "error", versionStatusWaiting: case "peer_missing", "peer_awaiting_folder", "peer_holding": // The two devices talked and finished, which is what the // per-device stamp has always recorded. But nothing of THIS game @@ -534,6 +546,27 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re } e.mu.Unlock() + // 3b. The versions decide, when both devices keep them (version.go): who + // is behind takes the newer save whole, from a device that holds it, and + // nothing is ever taken from — or deleted on the word of — a device that + // is behind. Left to the file comparison below only when the two hold + // the same save, or both saves predate versions. + // + // Not while this device is fetching back a save someone emptied here and + // asked to have put back (hold.go): the emptying was recorded as a version + // of its own, and by versions this device would be the newer one, with + // nothing to fetch. The hold's own rules bring the files back. + fetchingBack := false + if h, ok, err := e.Store.GetDeletionHold(gameID); err == nil && ok && h.State == store.HoldFetching { + fetchingBack = true + } + if remoteData.Version != nil && game.AutoSync && !fetchingBack { + if res, handled, err := e.syncByVersion(ctx, game, peer, localManifest, unfilteredLocal, remoteData); handled { + releaseAligned() + return res, err + } + } + // 4. Conflict detection (lineage + skew-tolerant mtimes). lastSyncMs := e.lastSyncTimeMs(peer.ID) agreedHash := e.Store.GetAgreedHash(gameID, peer.ID) @@ -713,22 +746,6 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re } handedOverDeletion := e.handOverEmptying(gameID, peer, localManifest.Files, &decision) - // A sync about to delete a large part of a save — here or on the peer — - // stops and asks instead. emptiedUnconfirmed above covers a save emptied - // completely; this covers one gutted partly, which is what a partial copy - // on one device turns into once it is read as the other's deletions. Two - // waves of that took tens of thousands of files out of a save before - // anyone was asked. A deletion someone confirmed on purpose - // (handedOverDeletion, DeletionConfirmed) is not second-guessed. - if !handedOverDeletion && !remoteData.DeletionConfirmed { - if n, total := massDeletion(localManifest, remoteData.Manifest, decision); n > 0 { - e.Log("warn", fmt.Sprintf("syncing %q with %q would delete %d of %d files — holding it for a decision instead", - game.Name, peer.Name, n, total)) - e.registerConflict(gameID, peer, localManifest, remoteData) - return Result{Status: "conflict", PeerID: peer.ID, PeerName: peer.Name}, nil - } - } - // Nothing below is about files arriving, only about local files leaving. // A pull that brings files this device never held destroys nothing. atRisk := filesAtRisk(localManifest, decision) @@ -1334,6 +1351,7 @@ func (e *Engine) registerConflict(gameID string, peer Peer, localManifest delta. DiffFiles: diffs, DiffTotal: total, OnlyLocalTotal: counts["only-local"], OnlyRemoteTotal: counts["only-remote"], ChangedTotal: counts["changed"], + remoteVersion: remoteData.Version, } e.mu.Unlock() @@ -1432,6 +1450,7 @@ func (e *Engine) applyLocalDeletions(gameID string, root syncRoot, d Decision) { // spelling this disk actually holds. full := delta.LocalNameFor(root.Path, relPath) _ = os.Chmod(full, 0o666) + owntouch.Mark(full) if err := os.Remove(full); err == nil { e.Log("info", "deleted locally (peer deleted): "+relPath) } @@ -1446,6 +1465,7 @@ func (e *Engine) applyLocalDeletions(gameID string, root syncRoot, d Decision) { } full := delta.LocalNameFor(root.Path, relDir) if info, err := os.Stat(full); err == nil && info.IsDir() { + owntouch.Mark(full) if err := os.Remove(full); err == nil { // only removes empty dirs, matching rmdirSync e.Log("info", "deleted directory locally (peer deleted): "+relDir) } @@ -1488,7 +1508,9 @@ func (e *Engine) createPulledDirsIn(gameID string, root syncRoot, dirsToPull []s if !delta.IsSafePath(root.Path, relDir) { continue } - _ = os.MkdirAll(filepath.Join(root.Path, filepath.FromSlash(relDir)), 0o777) + full := filepath.Join(root.Path, filepath.FromSlash(relDir)) + owntouch.Mark(full) + _ = os.MkdirAll(full, 0o777) } } @@ -1536,7 +1558,11 @@ func (e *Engine) pullFiles(ctx context.Context, peer Peer, gameID string, game s // Make sure every remote directory exists before patching into it. for _, dir := range remoteData.Manifest.Dirs { if delta.IsSafePath(root.Path, dir) { - _ = os.MkdirAll(filepath.Join(root.Path, filepath.FromSlash(dir)), 0o777) + full := filepath.Join(root.Path, filepath.FromSlash(dir)) + if _, err := os.Stat(full); err != nil { + owntouch.Mark(full) + _ = os.MkdirAll(full, 0o777) + } } } diff --git a/internal/p2p/syncengine/massdelete.go b/internal/p2p/syncengine/massdelete.go deleted file mode 100644 index aea0cc26..00000000 --- a/internal/p2p/syncengine/massdelete.go +++ /dev/null @@ -1,28 +0,0 @@ -package syncengine - -import "github.com/opensave/opensave/internal/delta" - -// A sync is held for a decision when it would delete more than both of these: -// enough files that it is not a game tidying its slots, and enough of the save -// that it is not one corner of a big one. Ordinary play deletes a handful of -// files; a device holding part of a save being read as the other's deletions -// deletes thousands. -const ( - massDeleteMinFiles = 100 - massDeleteFraction = 0.10 -) - -// massDeletion reports how many files the decision would delete, on either -// side, and how many the save holds, when that crosses the thresholds above; -// zero otherwise. -func massDeletion(local, remote delta.Manifest, d Decision) (deleting, total int) { - n := len(d.FilesToDeleteLocally) + len(d.FilesToDeleteOnPeer) - total = len(local.Files) - if len(remote.Files) > total { - total = len(remote.Files) - } - if n <= massDeleteMinFiles || total == 0 || float64(n) <= massDeleteFraction*float64(total) { - return 0, total - } - return n, total -} diff --git a/internal/p2p/syncengine/massdelete_test.go b/internal/p2p/syncengine/massdelete_test.go deleted file mode 100644 index a18b34c7..00000000 --- a/internal/p2p/syncengine/massdelete_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package syncengine - -import ( - "context" - "fmt" - "os" - "path/filepath" - "testing" -) - -// Both sides converge on n files, then the peer loses some of them. -func convergedThenPeerLoses(t *testing.T, n, lost int) *engineEnv { - t.Helper() - env := setupEngine(t) - writeSmallFiles(t, env.localDir, n) - writeSmallFiles(t, env.remoteDir, n) - if res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil || res.Status != "in_sync" { - t.Fatalf("setup sync = %+v, %v", res, err) - } - for i := 0; i < lost; i++ { - if err := os.Remove(filepath.Join(env.remoteDir, "world", fmt.Sprintf("map_%04d.bin", i))); err != nil { - t.Fatal(err) - } - } - return env -} - -func TestMassDeletion_IsHeldForADecision(t *testing.T) { - env := convergedThenPeerLoses(t, 1000, 500) - res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) - if err != nil { - t.Fatal(err) - } - if res.Status != "conflict" { - t.Fatalf("result = %+v, want the sync held as a conflict", res) - } - if n := countFiles(t, env.localDir); n != 1000 { - t.Fatalf("this side has %d files; the 500 deletions were applied instead of held", n) - } - if _, ok := env.engine.ActiveConflicts()["game1"]; !ok { - t.Error("no conflict registered to ask about") - } - - // "Keep mine": the peer's missing files go back to it, nothing is deleted here. - if _, err := env.engine.ResolveConflict(context.Background(), "game1", env.peer.ID, "keep-local"); err != nil { - t.Fatal(err) - } - if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { - t.Fatal(err) - } - if n := countFiles(t, env.localDir); n != 1000 { - t.Fatalf("after keep-mine this side has %d files, want 1000", n) - } -} - -// Ordinary play deletes a few files; that still just syncs. -func TestMassDeletion_SmallDeletionsStillSync(t *testing.T) { - env := convergedThenPeerLoses(t, 1000, 20) - res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) - if err != nil { - t.Fatal(err) - } - if res.Status == "conflict" { - t.Fatalf("20 of 1000 deletions were held: %+v", res) - } - if n := countFiles(t, env.localDir); n != 980 { - t.Fatalf("this side has %d files, want the peer's 20 deletions applied (980)", n) - } -} diff --git a/internal/p2p/syncengine/quick.go b/internal/p2p/syncengine/quick.go index 435eba66..74f5b5f2 100644 --- a/internal/p2p/syncengine/quick.go +++ b/internal/p2p/syncengine/quick.go @@ -58,6 +58,16 @@ func (e *Engine) quickInSync(ctx context.Context, gameID string, game store.Game // exchange decides. return Result{}, false, nil } + // The same files; the versions still have to say the same (version.go). + if resp.Version != nil && game.AutoSync { + res, final := e.quickVersionCheck(game, peer, local, *resp.Version) + if !final { + return Result{}, false, nil + } + if res.Status == versionStatusWaiting { + return res, true, nil + } + } // Same as the in-sync outcome of a full sync, minus what is already // recorded: the base is unchanged and the lineage with it. diff --git a/internal/p2p/syncengine/transport.go b/internal/p2p/syncengine/transport.go index 1db8c5df..ed8f8466 100644 --- a/internal/p2p/syncengine/transport.go +++ b/internal/p2p/syncengine/transport.go @@ -96,6 +96,10 @@ type ManifestResponse struct { // question and sends the full manifest, which is handled as before. Unchanged bool `json:"unchanged,omitempty"` ManifestHash string `json:"manifestHash,omitempty"` + + // Version is the responder's version of the save (version.go), absent + // from builds that predate it and for games it does not version. + Version *VersionInfo `json:"version,omitempty"` } // FileRef identifies one file inside one of a game's save locations. diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go new file mode 100644 index 00000000..684f0c09 --- /dev/null +++ b/internal/p2p/syncengine/version.go @@ -0,0 +1,827 @@ +package syncengine + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/ignore" + "github.com/opensave/opensave/internal/store" +) + +// Save versions. +// +// Every device knows which version of a game's save it holds, as a version +// vector: one counter per device that has ever changed the save, moved only +// when the save really changes there (the watcher saw the game or the user +// write it — never a sync). Comparing two vectors answers, exactly, the +// question the file-by-file comparison could only guess at from what each side +// happens to hold: +// +// - one is older than the other: that device is simply behind. It takes the +// newer save as a whole — every file, and every deletion — and only from a +// device that holds it completely. Nothing is ever taken from it, pushed +// by it, or deleted on its say-so, and it is a source for nobody. +// - equal: the same save. +// - neither: both changed it independently. Only then is anyone asked. +// +// A device also remembers the newest version it has heard of (the target) +// until it holds it. That is what keeps two outdated devices from syncing with +// each other: each knows something newer exists, so each waits for a device +// that holds it, instead of reading the other's half-finished copy as the save +// and passing on what it lacks as deletions. +// +// An empty folder holds no version at all, which is older than every version, +// so a new or emptied device fills itself from a complete one and never raises +// a conflict. A save that existed before this was introduced gets a version +// named after its content (genesisKey), so devices that already held the same +// save agree at once, and ones that did not are compared the way they always +// were, until they agree. + +// VersionVector is writer key → counter. +type VersionVector map[string]int64 + +type versionOrder int + +const ( + versionEqual versionOrder = iota + versionOlder + versionNewer + versionConcurrent +) + +// compareVersions says how a relates to b. +func compareVersions(a, b VersionVector) versionOrder { + aBehind, bBehind := false, false + for k, bv := range b { + if a[k] < bv { + aBehind = true + break + } + } + for k, av := range a { + if b[k] < av { + bBehind = true + break + } + } + switch { + case !aBehind && !bBehind: + return versionEqual + case aBehind && !bBehind: + return versionOlder + case !aBehind && bBehind: + return versionNewer + default: + return versionConcurrent + } +} + +// covers reports whether a is b or newer. +func covers(a, b VersionVector) bool { + o := compareVersions(a, b) + return o == versionEqual || o == versionNewer +} + +func mergeVersions(a, b VersionVector) VersionVector { + out := make(VersionVector, len(a)+len(b)) + for k, v := range a { + out[k] = v + } + for k, v := range b { + if v > out[k] { + out[k] = v + } + } + return out +} + +func (v VersionVector) clone() VersionVector { return mergeVersions(v, nil) } + +// genesisOnly reports whether every entry names a save that predates version +// tracking (genesisKey), i.e. nobody has changed it since. +func (v VersionVector) genesisOnly() bool { + if len(v) == 0 { + return false + } + for k := range v { + if !strings.HasPrefix(k, "g:") { + return false + } + } + return true +} + +// String is a short, stable form for the log. +func (v VersionVector) String() string { + if len(v) == 0 { + return "none" + } + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, len(keys)) + for i, k := range keys { + short := k + if len(short) > 8 { + short = short[:8] + } + parts[i] = fmt.Sprintf("%s=%d", short, v[k]) + } + return strings.Join(parts, ",") +} + +// genesisKey names the version of a save that existed before versions did, +// after its content: devices that hold the same files get the same name. +// Directories are left out — they are created by any sync, and are not what +// makes two saves the same. +func genesisKey(m delta.Manifest) string { + return "g:" + filesKey(m, ignore.Rules{})[:16] +} + +// filesKey hashes which files a save holds and what is in them: paths and +// content hashes, nothing else — not folders, not times. Excluded paths are +// left out. +func filesKey(m delta.Manifest, rules ignore.Rules) string { + paths := make([]string, 0, len(m.Files)) + for p := range m.Files { + if rules.Empty() || !rules.Match(p) { + paths = append(paths, p) + } + } + sort.Strings(paths) + h := sha256.New() + for _, p := range paths { + h.Write([]byte(p)) + h.Write([]byte{0}) + h.Write([]byte(m.Files[p].Hash)) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// versionHashOf is what is recorded with a version: the save's files as this +// device syncs them, tagged with the exclusion rules it was taken under. A +// save that no longer hashes to it was changed here since, whether or not the +// watcher said so. +// +// Filtered, because a file nobody syncs — a device's own config — changing +// is not a new version of the save. Tagged, because writing a rule is not one +// either: it changes the filtered hash on every device at once, and each took +// that as a change of its own, so the next sync asked about a divergence +// nobody made. A hash taken under other rules is re-taken, not compared +// (sameSaveAs). +func (e *Engine) versionHashOf(gameID string, primary delta.Manifest) string { + text := "" + if game, err := e.Store.GetGame(gameID); err == nil { + text = game.SyncIgnore + } + tag := sha256.Sum256([]byte(text)) + return hex.EncodeToString(tag[:4]) + ":" + filesKey(primary, ignore.Parse(text)) +} + +// sameSaveAs reports whether a hash recorded with a version still describes +// the save that now hashes to current: the same, or taken under different +// exclusion rules, which says nothing about the save. +func sameSaveAs(recorded, current string) bool { + if recorded == current { + return true + } + r, _, okR := strings.Cut(recorded, ":") + c, _, okC := strings.Cut(current, ":") + return okR && okC && r != c +} + +// VersionInfo is a device's version of one game, as it travels in a manifest +// response. Absent from devices that predate it, which are synced the way they +// always were. +type VersionInfo struct { + Vector VersionVector `json:"vector"` + // Target is the newer version the device knows of and does not hold yet. + Target VersionVector `json:"target,omitempty"` + // HasFiles: the device holds at least one save file for the game. + HasFiles bool `json:"hasFiles"` + // Answered is the version someone on the device kept its own save over + // in a conflict (Unix ms in AnsweredAt). Resolution is mutual: the device + // holding that version is asked in turn, not overwritten. + Answered VersionVector `json:"answered,omitempty"` + AnsweredAt int64 `json:"answeredAt,omitempty"` +} + +// Outdated reports whether the device knows its save is behind another's. +func (v VersionInfo) Outdated() bool { + return len(v.Target) > 0 && compareVersions(v.Vector, v.Target) == versionOlder +} + +// gameVersion is the stored record with its vectors decoded. +type gameVersion struct { + rec store.GameVersion + vec VersionVector + target VersionVector + answered VersionVector +} + +func (e *Engine) loadVersionLocked(gameID string) (gameVersion, error) { + rec, err := e.Store.GetGameVersion(gameID) + if err != nil { + return gameVersion{}, err + } + gv := gameVersion{rec: rec, vec: VersionVector{}} + _ = json.Unmarshal([]byte(rec.Vector), &gv.vec) + if gv.vec == nil { + gv.vec = VersionVector{} + } + if rec.Target != "" { + _ = json.Unmarshal([]byte(rec.Target), &gv.target) + } + if rec.Answered != "" { + _ = json.Unmarshal([]byte(rec.Answered), &gv.answered) + } + return gv, nil +} + +func (e *Engine) saveVersionLocked(gv gameVersion) error { + if gv.rec.GameID == "" { + return nil + } + raw, _ := json.Marshal(gv.vec) + gv.rec.Vector = string(raw) + gv.rec.Target = "" + if len(gv.target) > 0 { + t, _ := json.Marshal(gv.target) + gv.rec.Target = string(t) + } + gv.rec.Answered = "" + if len(gv.answered) > 0 { + a, _ := json.Marshal(gv.answered) + gv.rec.Answered = string(a) + } else { + gv.rec.AnsweredAt = 0 + } + return e.Store.SaveGameVersion(gv.rec) +} + +// ensureGenesisLocked names a save that has files and no version yet. Not +// while the device is taking a newer version: its folder is then a part-way +// copy of someone else's, and naming that would make it a version of its own. +func (gv *gameVersion) ensureGenesis(local delta.Manifest, hash string) bool { + if len(gv.vec) > 0 || len(local.Files) == 0 || len(gv.target) > 0 || gv.rec.Pulling { + return false + } + gv.vec = VersionVector{genesisKey(local): 1} + gv.rec.Hash = hash + return true +} + +// refreshLocked brings this device's version up to date with its save before +// anyone compares it: a save that predates versions is named, and one that no +// longer hashes to what its version recorded was changed here — by the game, +// whether or not the watcher has said so yet (it waits for the game to finish +// writing, and while a game holds its files open that can be a long time). It +// is a version of its own from now. Without this, a device whose change was +// still unrecorded looked merely behind, and took the other's save over it. +// +// Not while a pull towards a newer version is unfinished: the folder is then a +// part-way copy of someone else's save, not a change made here. Reports +// whether the record changed. +func (e *Engine) refreshLocked(gv *gameVersion, game store.Game, local delta.Manifest) bool { + hash := e.versionHashOf(game.ID, local) + if gv.ensureGenesis(local, hash) { + return true + } + if gv.rec.Pulling || len(gv.vec) == 0 || gv.rec.Hash == "" || gv.rec.Hash == hash { + return false + } + if sameSaveAs(gv.rec.Hash, hash) { + // Taken under other exclusion rules, so it cannot say whether the + // save changed: re-taken under the new ones. A change made at the + // same time still becomes a version — the watcher reports it + // (NoteLocalChange). + gv.rec.Hash = hash + return true + } + e.bumpLocked(gv, game.Name, hash) + return true +} + +// learn takes in what a peer knows: a version newer than this device's +// becomes its target, unless it already knows of one newer still. +func (gv *gameVersion) learn(theirs VersionInfo) { + for _, c := range []VersionVector{theirs.Vector, theirs.Target} { + if len(c) == 0 || compareVersions(gv.vec, c) != versionOlder { + continue + } + if len(gv.target) == 0 || compareVersions(gv.target, c) == versionOlder { + gv.target = c.clone() + } + } + gv.dropReachedTarget() +} + +// dropReachedTarget forgets a target this device's version covers — or has +// gone past it sideways, by changing on its own: that is no longer "behind", +// it is a conflict, and the version comparison says so by itself. +func (gv *gameVersion) dropReachedTarget() { + if len(gv.target) > 0 && compareVersions(gv.vec, gv.target) != versionOlder { + gv.target = nil + } +} + +func (gv gameVersion) info(hasFiles bool) VersionInfo { + v := VersionInfo{Vector: gv.vec.clone(), Target: gv.target.clone(), HasFiles: hasFiles} + if len(gv.answered) > 0 { + v.Answered, v.AnsweredAt = gv.answered.clone(), gv.rec.AnsweredAt + } + return v +} + +// adopt makes this device hold theirs: their version, and their answer to a +// conflict if they carry one — the device holding the version they kept +// theirs over is still to be asked, whoever it meets. +func (gv *gameVersion) adopt(vec VersionVector, theirs VersionInfo) { + gv.vec = vec.clone() + gv.answered, gv.rec.AnsweredAt = nil, 0 + if len(theirs.Answered) > 0 { + gv.answered, gv.rec.AnsweredAt = theirs.Answered.clone(), theirs.AnsweredAt + } + gv.dropReachedTarget() +} + +// LocalVersion is this device's version of a game, for a manifest response. +// manifest is the save being served; a save that predates versions is named +// here if it has not been yet. Nil when the game takes no part in versions: +// one with automatic sync turned off is not watched, so nothing would ever +// move its version — the peer then syncs it the way it always has. +func (e *Engine) LocalVersion(game store.Game, manifest delta.Manifest) *VersionInfo { + if !game.AutoSync { + return nil + } + e.versionMu.Lock() + defer e.versionMu.Unlock() + gv, err := e.loadVersionLocked(game.ID) + if err != nil { + return nil + } + if e.refreshLocked(&gv, game, manifest) { + _ = e.saveVersionLocked(gv) + } + v := gv.info(len(manifest.Files) > 0) + return &v +} + +// NoteLocalChange moves this device's version on: the watcher saw the game +// or the user change the save (never a sync — those are told apart by +// internal/owntouch). Called before the change is offered to anyone. +func (e *Engine) NoteLocalChange(gameID string) { + game, err := e.Store.GetGame(gameID) + if err != nil || !game.AutoSync { + return + } + e.versionMu.Lock() + defer e.versionMu.Unlock() + gv, err := e.loadVersionLocked(gameID) + if err != nil || gv.rec.GameID == "" { + return + } + if gv.rec.Pulling { + // Files a pull towards a newer version wrote — this run's own, still + // arriving, or left by a run that stopped part-way (after a restart + // the watcher reports those as a change: it cannot know who wrote + // them). Not a version: the folder is a part-way copy of someone + // else's, and naming it would hand that copy to every other device. + // The pull finishing is what moves the version. Anything written here + // meanwhile is in the snapshot taken before the pull replaces it. + return + } + m, err := delta.BuildManifest(game.SavePath) + if err != nil { + return + } + hash := e.versionHashOf(gameID, m) + if len(gv.vec) == 0 { + // First change ever seen here, on a save that predates versions or a + // new one. Named after its content, so a device holding the same files + // agrees with it rather than conflicting. + if gv.ensureGenesis(m, hash) { + _ = e.saveVersionLocked(gv) + } + return + } + if hash == gv.rec.Hash { + // Already a version: a sync noticed it first (refreshLocked), or the + // change was only to files nobody syncs. + return + } + // The watcher saw the game or the user write the save: a version, even if + // the exclusion rules changed since the last one (writing a rule raises + // no file event, so that alone never arrives here). + e.bumpLocked(&gv, game.Name, hash) +} + +// bumpLocked gives this device a new version of its own, after hash. +func (e *Engine) bumpLocked(gv *gameVersion, name, hash string) { + gv.rec.Counter++ + gv.vec = gv.vec.clone() + gv.vec[gv.rec.Key] = gv.rec.Counter + gv.rec.Hash = hash + gv.dropReachedTarget() + if err := e.saveVersionLocked(*gv); err == nil { + e.Log("info", fmt.Sprintf("%s changed on this device — version %s", name, gv.vec)) + } +} + +// versionAfterResolution records a conflict's answer as a version. +// +// Keeping the peer's save makes this device hold what both held: newer than +// both, so the peer finds nothing to take and devices behind either take it. +// +// Keeping this device's own is a new version of its own — but deliberately +// not one that includes the peer's: that would make the peer simply behind, +// and its save would be replaced without anyone there being asked. Resolution +// is mutual. The answer is recorded instead (Answered), so this device is not +// asked again about the same version, and the peer, still independent of it, +// asks in turn. Whoever answers last decides (syncByVersion). +func (e *Engine) versionAfterResolution(gameID string, theirs *VersionInfo, keptLocal bool) { + if theirs == nil { + return + } + e.versionMu.Lock() + defer e.versionMu.Unlock() + gv, err := e.loadVersionLocked(gameID) + if err != nil || gv.rec.GameID == "" { + return + } + if keptLocal { + gv.rec.Counter++ + gv.vec = gv.vec.clone() + gv.vec[gv.rec.Key] = gv.rec.Counter + gv.answered, gv.rec.AnsweredAt = theirs.Vector.clone(), time.Now().UnixMilli() + } else { + gv.vec = mergeVersions(gv.vec, theirs.Vector) + gv.answered, gv.rec.AnsweredAt = nil, 0 + } + gv.rec.Pulling = false // whatever was being taken, the answer replaces it + gv.rec.Hash = "" + if game, err := e.Store.GetGame(gameID); err == nil { + if m, err := delta.BuildManifest(game.SavePath); err == nil { + gv.rec.Hash = e.versionHashOf(gameID, m) + } + } + gv.dropReachedTarget() + _ = e.saveVersionLocked(gv) +} + +// discardLocalVersion is for a "keep theirs" that did not finish: this +// device's own version is given up, and it takes the peer's like any outdated +// device would — whole, and only from a device that holds it. +func (e *Engine) discardLocalVersion(gameID string, theirs *VersionInfo) { + if theirs == nil { + return + } + e.versionMu.Lock() + defer e.versionMu.Unlock() + gv, err := e.loadVersionLocked(gameID) + if err != nil || gv.rec.GameID == "" { + return + } + gv.vec = VersionVector{} + gv.target = theirs.Vector.clone() + gv.rec.Hash = "" + _ = e.saveVersionLocked(gv) +} + +// versionStatusWaiting: this device or the peer is behind, and the other +// cannot give it the newer version either. Nothing happens; both wait for a +// device that holds it. +const versionStatusWaiting = "waiting" + +// syncByVersion decides a sync from the two devices' versions. handled false +// leaves it to the file comparison: the two hold the same version and the +// same files, or both saves predate versions and have not met since. +// +// local and remote are filtered by the exclusion rules; unfilteredLocal is +// this device's save as it is on disk, which names a version. +func (e *Engine) syncByVersion(ctx context.Context, game store.Game, peer Peer, + local, unfilteredLocal delta.Manifest, remote ManifestResponse) (Result, bool, error) { + + gameID := game.ID + theirs := *remote.Version + + e.versionMu.Lock() + gv, err := e.loadVersionLocked(gameID) + if err != nil || gv.rec.GameID == "" { + e.versionMu.Unlock() + return Result{}, false, nil + } + before := gv.target.clone() + changed := e.refreshLocked(&gv, game, unfilteredLocal) + gv.learn(theirs) + if changed || compareVersions(before, gv.target) != versionEqual || len(before) != len(gv.target) { + _ = e.saveVersionLocked(gv) + } + mine := gv.info(len(local.Files) > 0) + e.versionMu.Unlock() + + waiting := func(why string) (Result, bool, error) { + e.logOnce(gameID+"|"+peer.ID, why) + return Result{Status: versionStatusWaiting, PeerID: peer.ID, PeerName: peer.Name}, true, nil + } + pushTo := func() (Result, bool, error) { + e.forgetLogOnce(gameID + "|" + peer.ID) + e.Log("info", fmt.Sprintf("%q: %s has an older version (%s, this device %s) — asking it to take this one", + game.Name, peer.Name, theirs.Vector, mine.Vector)) + e.Transport.TriggerPeerPull(peer, gameID) + return Result{Status: "triggered_peer_pull", Direction: "push", PeerID: peer.ID, PeerName: peer.Name}, true, nil + } + + switch { + case mine.Outdated(): + if theirs.Outdated() || !covers(theirs.Vector, mine.Target) { + return waiting(fmt.Sprintf("%q: this device is behind (holds %s, newest known %s); %s does not hold that version either — waiting for a device that does", + game.Name, mine.Vector, mine.Target, peer.Name)) + } + return e.pullVersion(ctx, game, peer, local, remote, theirs.Vector) + case theirs.Outdated(): + if covers(mine.Vector, theirs.Target) { + return pushTo() + } + return waiting(fmt.Sprintf("%q: %s is behind and this device does not hold the version it is waiting for either", + game.Name, peer.Name)) + } + + switch compareVersions(mine.Vector, theirs.Vector) { + case versionNewer: + return pushTo() + case versionOlder: + // learn made this device outdated above, so this is only reached if + // the record could not be read back; the peer holds the newer version + // completely, so take it. + return e.pullVersion(ctx, game, peer, local, remote, theirs.Vector) + case versionEqual: + e.forgetLogOnce(gameID + "|" + peer.ID) + if sameFiles(local, remote.Manifest) { + return Result{}, false, nil + } + // One version, different files. Each side checks its own save against + // its version before answering (refreshLocked), so this is a change + // that landed after the peer answered; its sync carries it. + return waiting(fmt.Sprintf("%q: same version as %s but the files differ — waiting for the change to be recorded", + game.Name, peer.Name)) + } + + // Concurrent: both changed independently. + e.forgetLogOnce(gameID + "|" + peer.ID) + if sameFiles(local, remote.Manifest) { + // …into the same save. Both record the union, which each computes the + // same way, and are equal from then on. + e.versionMu.Lock() + if gv, err := e.loadVersionLocked(gameID); err == nil { + gv.vec = mergeVersions(gv.vec, theirs.Vector) + gv.rec.Hash = e.versionHashOf(gameID, unfilteredLocal) + gv.dropReachedTarget() + _ = e.saveVersionLocked(gv) + } + e.versionMu.Unlock() + return Result{}, false, nil + } + if !mine.HasFiles { + // An empty folder is never one side of a conflict: it takes the save. + return e.pullVersion(ctx, game, peer, local, remote, mergeVersions(mine.Vector, theirs.Vector)) + } + if !theirs.HasFiles { + return pushTo() + } + if mine.Vector.genesisOnly() || theirs.Vector.genesisOnly() { + // A save from before versions existed, never compared with this one + // since: the file comparison and the shared history decide, as before. + return Result{}, false, nil + } + + // Someone already answered: here, keeping this device's save over the + // peer's version (or one it has since moved on from), or there, keeping + // theirs over this one's. + iAnswered := len(mine.Answered) > 0 && covers(theirs.Vector, mine.Answered) + theyAnswered := len(theirs.Answered) > 0 && covers(mine.Vector, theirs.Answered) + switch { + case iAnswered && theyAnswered: + // Both were answered, each keeping its own. The later answer stands, + // as the person who gave it saw the other one too; the earlier one's + // save is in the snapshot taken before it is replaced. + // Answers in the same millisecond are settled the same way on both + // devices, or each would keep asking the other to take its own. + if mine.AnsweredAt > theirs.AnsweredAt || + (mine.AnsweredAt == theirs.AnsweredAt && mine.Vector.String() > theirs.Vector.String()) { + return pushTo() + } + e.Log("info", fmt.Sprintf("%q: %s's answer to the conflict came later — taking its version", game.Name, peer.Name)) + return e.pullVersion(ctx, game, peer, local, remote, mergeVersions(mine.Vector, theirs.Vector)) + case iAnswered: + // Answered here; the peer is asked in turn when it syncs. + return pushTo() + } + e.Log("warn", fmt.Sprintf("%q was changed independently on this device (%s) and on %s (%s)", + game.Name, mine.Vector, peer.Name, theirs.Vector)) + e.registerConflict(gameID, peer, local, remote) + return Result{Status: "conflict", PeerID: peer.ID, PeerName: peer.Name}, true, nil +} + +// pullVersion makes this device's save exactly the peer's — every file it +// holds, and none it does not — and only then records the peer's version as +// this device's. Until that, the device stays behind and keeps waiting. +// +// local and remote are filtered by the exclusion rules, so excluded files are +// neither taken nor removed. +func (e *Engine) pullVersion(ctx context.Context, game store.Game, peer Peer, + local delta.Manifest, remote ManifestResponse, adopt VersionVector) (Result, bool, error) { + + gameID := game.ID + if len(remote.Manifest.Files) == 0 && len(local.Files) > 0 && !remote.DeletionConfirmed { + // The newer version is an empty folder nobody has confirmed emptying. + e.Log("info", fmt.Sprintf("%q holds none of %q's save files now, and has not confirmed deleting them — keeping this device's copies", + peer.Name, game.Name)) + return Result{Status: "peer_holding", PeerID: peer.ID, PeerName: peer.Name}, true, nil + } + + var d Decision + for p, lf := range local.Files { + rf, ok := remote.Manifest.Files[p] + switch { + case !ok: + d.FilesToDeleteLocally = append(d.FilesToDeleteLocally, p) + case rf.Hash != lf.Hash: + d.FilesToPull = append(d.FilesToPull, p) + } + } + for p := range remote.Manifest.Files { + if _, ok := local.Files[p]; !ok { + d.FilesToPull = append(d.FilesToPull, p) + } + } + localDirs, remoteDirs := toSet(local.Dirs), toSet(remote.Manifest.Dirs) + for dir := range remoteDirs { + if _, ok := localDirs[dir]; !ok { + d.DirsToPull = append(d.DirsToPull, dir) + } + } + for dir := range localDirs { + if _, ok := remoteDirs[dir]; !ok { + d.DirsToDeleteLocally = append(d.DirsToDeleteLocally, dir) + } + } + sort.Strings(d.FilesToPull) + + e.Log("info", fmt.Sprintf("%q: taking %s's newer version (%s): %d file(s) to fetch, %d to remove", + game.Name, peer.Name, adopt, len(d.FilesToPull), len(d.FilesToDeleteLocally))) + + // Whatever this device holds is kept in a snapshot before any of it is + // replaced or removed. + replacing := len(d.FilesToDeleteLocally) + for _, p := range d.FilesToPull { + if _, ok := local.Files[p]; ok { + replacing++ + } + } + if replacing > 0 { + if _, err := e.Snapshots.CreateBeforeReplacing(gameID, fmt.Sprintf("Before taking %s's newer version", peer.Name)); err != nil { + return Result{}, true, fmt.Errorf("refusing to replace %q's files: they could not be snapshotted first: %w", game.Name, err) + } + } + + e.setPulling(gameID, true) + defer e.setPulling(gameID, false) + + applied := e.Writing(gameID) + started := time.Now() + e.applyLocalDeletions(gameID, primaryRootOf(game), d) + if n := len(d.FilesToDeleteLocally); n > 0 { + e.RecordActivity(store.ActivityEvent{GameID: gameID, Kind: store.ActivityDeleted, Device: peer.Name, Files: n}) + e.noteEmptiedByPeer(gameID, started) + } + e.createPulledDirs(gameID, game, d.DirsToPull) + if len(d.FilesToPull) > 0 { + if err := e.pullFiles(ctx, peer, gameID, game, primaryRootOf(game), local, remote, d.FilesToPull); err != nil { + applied() + return Result{}, true, fmt.Errorf("taking %s's version of %q did not finish (it is resumed on the next sync): %w", peer.Name, game.Name, err) + } + } + applied() + + // Only a save that is now exactly the peer's takes its version. + fresh, err := e.ReadManifest(ctx, gameID, game.SavePath) + if err != nil { + return Result{}, true, err + } + freshHash := e.versionHashOf(gameID, fresh) + if rules := e.rulesFor(gameID); !rules.Empty() { + fresh = filterManifest(fresh, rules) + } + if !sameFiles(fresh, remote.Manifest) { + return Result{}, true, fmt.Errorf("taking %s's version of %q did not finish: the save still differs; it is resumed on the next sync", peer.Name, game.Name) + } + + e.versionMu.Lock() + if gv, err := e.loadVersionLocked(gameID); err == nil { + theirs := VersionInfo{} + if remote.Version != nil { + theirs = *remote.Version + } + gv.adopt(adopt, theirs) + gv.rec.Hash = freshHash + gv.rec.Pulling = false + _ = e.saveVersionLocked(gv) + e.Log("success", fmt.Sprintf("%q now holds version %s from %s", game.Name, gv.vec, peer.Name)) + } + e.versionMu.Unlock() + + // The file comparison's own records, so it agrees if it is ever asked. + e.persistLineage(gameID, peer.ID, fresh, remote.Manifest) + _ = e.Store.SetAgreedHash(gameID, peer.ID, remote.Manifest.ManifestHash()) + + e.syncExtraRoots(ctx, gameID, game, peer, remote) + if !d.HasChanges() { + return Result{Status: "in_sync", Direction: "none", PeerID: peer.ID, PeerName: peer.Name}, true, nil + } + return Result{Status: "updated", Direction: "pull", PeerID: peer.ID, PeerName: peer.Name}, true, nil +} + +// setPulling marks a pull towards a newer version as running, so the files it +// writes are never taken for a change made here. In memory for this run; in +// the record from the start until the pull has completed, which only adopting +// the version clears — a pull that stopped part-way leaves a folder that is +// neither this device's version nor the peer's, and it must not be read as a +// change made here (refreshLocked, NoteLocalChange) while it waits to resume. +func (e *Engine) setPulling(gameID string, on bool) { + e.versionMu.Lock() + defer e.versionMu.Unlock() + if e.pullingNow == nil { + e.pullingNow = map[string]bool{} + } + if !on { + delete(e.pullingNow, gameID) + return + } + e.pullingNow[gameID] = true + if gv, err := e.loadVersionLocked(gameID); err == nil && gv.rec.GameID != "" && !gv.rec.Pulling { + gv.rec.Pulling = true + _ = e.saveVersionLocked(gv) + } +} + +// quickVersionCheck is the version half of quickInSync: both saves are the +// agreed one, so the question is only whether the versions say the same. +// done reports a final answer; otherwise the full sync decides. +func (e *Engine) quickVersionCheck(game store.Game, peer Peer, local delta.Manifest, theirs VersionInfo) (res Result, done bool) { + e.versionMu.Lock() + gv, err := e.loadVersionLocked(game.ID) + if err != nil || gv.rec.GameID == "" { + e.versionMu.Unlock() + return Result{}, false + } + before := gv.target.clone() + changed := e.refreshLocked(&gv, game, local) + gv.learn(theirs) + if changed || compareVersions(before, gv.target) != versionEqual || len(before) != len(gv.target) { + _ = e.saveVersionLocked(gv) + } + mine := gv.info(len(local.Files) > 0) + e.versionMu.Unlock() + + if compareVersions(mine.Vector, theirs.Vector) != versionEqual { + return Result{}, false + } + if mine.Outdated() || theirs.Outdated() { + // The same save, and at least one of the two knows a newer one exists + // that neither holds: nothing to do between these two. + return Result{Status: versionStatusWaiting, PeerID: peer.ID, PeerName: peer.Name}, true + } + return Result{}, true +} + +// logOnce logs a line the first time it is the answer for key, and not again +// while it stays the answer: waiting is re-decided every reconcile. +func (e *Engine) logOnce(key, msg string) { + e.versionMu.Lock() + if e.loggedOnce == nil { + e.loggedOnce = map[string]string{} + } + same := e.loggedOnce[key] == msg + e.loggedOnce[key] = msg + e.versionMu.Unlock() + if !same { + e.Log("info", msg) + } +} + +func (e *Engine) forgetLogOnce(key string) { + e.versionMu.Lock() + delete(e.loggedOnce, key) + e.versionMu.Unlock() +} diff --git a/internal/p2p/syncengine/version_test.go b/internal/p2p/syncengine/version_test.go new file mode 100644 index 00000000..daade3c5 --- /dev/null +++ b/internal/p2p/syncengine/version_test.go @@ -0,0 +1,575 @@ +package syncengine + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "testing" + "time" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/snapshot" + "github.com/opensave/opensave/internal/store" +) + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b VersionVector + want versionOrder + }{ + {VersionVector{}, VersionVector{}, versionEqual}, + {VersionVector{}, VersionVector{"x": 1}, versionOlder}, + {VersionVector{"x": 2}, VersionVector{"x": 1}, versionNewer}, + {VersionVector{"x": 1, "y": 1}, VersionVector{"x": 1, "y": 1}, versionEqual}, + {VersionVector{"x": 2}, VersionVector{"x": 1, "y": 1}, versionConcurrent}, + {VersionVector{"x": 1}, VersionVector{"x": 1, "y": 1}, versionOlder}, + } + for _, c := range cases { + if got := compareVersions(c.a, c.b); got != c.want { + t.Errorf("compare(%v, %v) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +// --- a network of real engines ------------------------------------------- + +// meshNode is one device: its own store, save folder and engine. +type meshNode struct { + name string + dir string + st *store.Store + eng *Engine + // blockBudget, when >= 0, is how many more files this device serves + // before every further block request fails: a pull that stops part-way. + blockBudget int +} + +func (n *meshNode) peer() Peer { + return Peer{ID: "node_" + n.name, Name: n.name, Address: "127.0.0.1", Port: 1} +} + +type mesh struct { + mu sync.Mutex + nodes map[string]*meshNode // by peer id + offline map[string]bool + // deletesSent counts DeleteRemote calls: the version path never makes one. + deletesSent int +} + +type meshTransport struct { + m *mesh + self *meshNode +} + +var errUnreachable = errors.New("peer unreachable") + +func (t *meshTransport) target(peer Peer) (*meshNode, error) { + t.m.mu.Lock() + defer t.m.mu.Unlock() + n := t.m.nodes[peer.ID] + if n == nil || t.m.offline[peer.ID] { + return nil, errUnreachable + } + return n, nil +} + +// FetchManifest answers the way the real route does: the save, and the +// device's version of it. +func (t *meshTransport) FetchManifest(ctx context.Context, peer Peer, gameID string, q ManifestQuery) (ManifestResponse, error) { + n, err := t.target(peer) + if err != nil { + return ManifestResponse{}, err + } + game, err := n.st.GetGame(gameID) + if err != nil { + return ManifestResponse{}, errors.New("Game not found.") + } + m, err := delta.BuildManifest(n.dir) + if err != nil { + return ManifestResponse{}, err + } + return ManifestResponse{ + Manifest: m, + ActiveBranch: game.ActiveBranch, + DeletionConfirmed: n.eng.DeletionConfirmed(gameID), + Version: n.eng.LocalVersion(game, m), + }, nil +} + +func (t *meshTransport) FetchBlocks(ctx context.Context, peer Peer, ref FileRef, blockIndices []int, blockSize int) ([]BlockData, error) { + n, err := t.target(peer) + if err != nil { + return nil, err + } + t.m.mu.Lock() + if n.blockBudget == 0 { + t.m.mu.Unlock() + return nil, errors.New("connection reset") + } + if n.blockBudget > 0 { + n.blockBudget-- + } + t.m.mu.Unlock() + full := filepath.Join(n.dir, filepath.FromSlash(ref.RelPath)) + entry, err := delta.HashFile(full) + if err != nil { + return nil, err + } + raw, err := os.ReadFile(full) + if err != nil { + return nil, err + } + var out []BlockData + for _, idx := range blockIndices { + if idx >= len(entry.Blocks) { + continue + } + start := idx * entry.BlockSize + end := start + entry.Blocks[idx].Length + if end > len(raw) { + end = len(raw) + } + out = append(out, BlockData{Index: idx, Data: raw[start:end], Length: end - start}) + } + return out, nil +} + +func (t *meshTransport) DeleteRemote(ctx context.Context, peer Peer, ref FileRef) error { + n, err := t.target(peer) + if err != nil { + return err + } + t.m.mu.Lock() + t.m.deletesSent++ + t.m.mu.Unlock() + return os.RemoveAll(filepath.Join(n.dir, filepath.FromSlash(ref.RelPath))) +} + +func (t *meshTransport) TriggerPeerPull(peer Peer, gameID string) {} +func (t *meshTransport) ReportSyncEvent(peer Peer, gameID, eventType string, data map[string]any) {} + +func newMesh(t *testing.T, names ...string) (*mesh, map[string]*meshNode) { + t.Helper() + m := &mesh{nodes: map[string]*meshNode{}, offline: map[string]bool{}} + byName := map[string]*meshNode{} + for _, name := range names { + root := t.TempDir() + dir := filepath.Join(root, "saves") + if err := os.MkdirAll(dir, 0o777); err != nil { + t.Fatal(err) + } + st, err := store.Open(filepath.Join(root, "opensave.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.EnsureDefaultSettings(root, filepath.Join(root, "backups")); err != nil { + t.Fatal(err) + } + if err := st.CreateGame(store.Game{ID: "game1", Name: "Game One", SavePath: dir, AutoSync: true, MaxSnapshots: 50}); err != nil { + t.Fatal(err) + } + n := &meshNode{name: name, dir: dir, st: st, blockBudget: -1} + n.eng = New(st, snapshot.New(st), &meshTransport{m: m, self: n}) + m.nodes[n.peer().ID] = n + byName[name] = n + } + for _, a := range byName { + for _, b := range byName { + if a != b { + p := b.peer() + if err := a.st.UpsertPeer(store.Peer{ID: p.ID, Name: p.Name, Address: p.Address, Port: p.Port, Status: "online"}); err != nil { + t.Fatal(err) + } + } + } + } + return m, byName +} + +func (m *mesh) setOffline(n *meshNode, off bool) { + m.mu.Lock() + m.offline[n.peer().ID] = off + m.mu.Unlock() +} + +func (m *mesh) deletes() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.deletesSent +} + +// syncPair runs one sync of a with b, as a's engine would. +func syncPair(t *testing.T, a, b *meshNode) (Result, error) { + t.Helper() + return a.eng.SyncWithPeer(context.Background(), "game1", b.peer()) +} + +// change edits the save the way a game does, then tells the engine — the +// watcher's part. +func (n *meshNode) change(t *testing.T, edit func(dir string)) { + t.Helper() + edit(n.dir) + n.eng.NoteLocalChange("game1") +} + +func (n *meshNode) files(t *testing.T) map[string]string { + t.Helper() + m, err := delta.BuildManifest(n.dir) + if err != nil { + t.Fatal(err) + } + out := map[string]string{} + for p, f := range m.Files { + out[p] = f.Hash + } + return out +} + +func (n *meshNode) version(t *testing.T) VersionInfo { + t.Helper() + game, err := n.st.GetGame("game1") + if err != nil { + t.Fatal(err) + } + m, err := delta.BuildManifest(n.dir) + if err != nil { + t.Fatal(err) + } + return *n.eng.LocalVersion(game, m) +} + +func sameSave(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for p, h := range a { + if b[p] != h { + return false + } + } + return true +} + +func describe(files map[string]string) string { + paths := make([]string, 0, len(files)) + for p := range files { + paths = append(paths, p) + } + sort.Strings(paths) + if len(paths) > 6 { + return fmt.Sprintf("%d files (%v …)", len(paths), paths[:6]) + } + return fmt.Sprintf("%v", paths) +} + +func writeMany(t *testing.T, dir, prefix string, n int) { + t.Helper() + for i := 0; i < n; i++ { + write(t, dir, fmt.Sprintf("%s/chunk_%03d.bin", prefix, i), fmt.Sprintf("%s-%d", prefix, i)) + } +} + +// settleAll syncs every reachable node with every other, a few rounds, the +// way devices that are all online settle. +func settleAll(t *testing.T, nodes ...*meshNode) { + t.Helper() + for round := 0; round < 4; round++ { + for _, a := range nodes { + for _, b := range nodes { + if a != b { + _, _ = syncPair(t, a, b) + } + } + } + } +} + +// --- scenarios --------------------------------------------------------------- + +// The case this exists for. Three devices share a save; the server changes it +// a lot. One laptop starts taking the new version and is cut off part-way; the +// server then goes offline. The two laptops must not sync with each other in +// the meantime — not the half-finished copy, and not its missing files read as +// deletions — and when the server is back, both end up with its save exactly. +func TestVersions_IncompleteDevicesWaitForTheNewestOne(t *testing.T) { + m, n := newMesh(t, "server", "laptop", "desk") + server, laptop, desk := n["server"], n["laptop"], n["desk"] + + writeMany(t, server.dir, "map", 40) + write(t, server.dir, "players.db", "v1") + settleAll(t, server, laptop, desk) + original := server.files(t) + for _, d := range []*meshNode{laptop, desk} { + if !sameSave(d.files(t), original) { + t.Fatalf("%s did not take the initial save: %s", d.name, describe(d.files(t))) + } + } + + // The desk is switched off for a while; the server plays and the laptop + // takes that. The desk is now two versions behind, the laptop one — + // which makes the laptop newer than the desk, the comparison a device + // part-way through a pull must never win. + m.setOffline(desk, true) + server.change(t, func(dir string) { write(t, dir, "players.db", "v1.5") }) + settleAll(t, server, laptop) + m.setOffline(desk, false) + + // The server plays on: new chunks, some removed, one changed. + server.change(t, func(dir string) { + writeMany(t, dir, "map2", 30) + for i := 0; i < 5; i++ { + _ = os.Remove(filepath.Join(dir, "map", fmt.Sprintf("chunk_%03d.bin", i))) + } + write(t, dir, "players.db", "v2") + }) + newest := server.files(t) + + // The laptop starts taking it and is cut off after 10 files. + server.blockBudget = 10 + if _, err := syncPair(t, laptop, server); err == nil { + t.Fatal("a pull cut off part-way reported success") + } + server.blockBudget = -1 + if !laptop.version(t).Outdated() { + t.Fatalf("the laptop does not know it is behind: %+v", laptop.version(t)) + } + m.setOffline(server, true) + + // Server gone. The laptop and desk meet, repeatedly, both ways. + deskBefore := desk.files(t) + laptopBefore := laptop.files(t) + for i := 0; i < 3; i++ { + for _, pair := range [][2]*meshNode{{desk, laptop}, {laptop, desk}} { + res, err := syncPair(t, pair[0], pair[1]) + if err != nil { + t.Fatalf("%s↔%s: %v", pair[0].name, pair[1].name, err) + } + if res.Status == "conflict" { + t.Fatalf("%s↔%s raised a conflict between two copies of the same history", pair[0].name, pair[1].name) + } + } + } + if !sameSave(desk.files(t), deskBefore) { + t.Errorf("the desk changed while the newest device was away: was %s, now %s", + describe(deskBefore), describe(desk.files(t))) + } + if !sameSave(laptop.files(t), laptopBefore) { + t.Errorf("the laptop's partial copy changed while the newest device was away: was %s, now %s", + describe(laptopBefore), describe(laptop.files(t))) + } + if !desk.version(t).Outdated() { + t.Error("the desk never learned from the laptop that a newer version exists") + } + if m.deletes() != 0 { + t.Errorf("%d deletion(s) were sent to another device", m.deletes()) + } + + // The server returns; everyone ends with exactly its save. + m.setOffline(server, false) + settleAll(t, server, laptop, desk) + for _, d := range []*meshNode{server, laptop, desk} { + if !sameSave(d.files(t), newest) { + t.Errorf("%s does not hold the newest save: %s", d.name, describe(d.files(t))) + } + if v := d.version(t); v.Outdated() { + t.Errorf("%s still thinks it is behind: %+v", d.name, v) + } + } + if m.deletes() != 0 { + t.Errorf("%d deletion(s) were sent to another device", m.deletes()) + } +} + +// An empty folder — a new device, or one whose copy never arrived — is never +// one side of a conflict, and never makes the device with the save lose files. +func TestVersions_AnEmptyDeviceTakesTheSaveWithoutAsking(t *testing.T) { + m, n := newMesh(t, "full", "empty") + full, empty := n["full"], n["empty"] + writeMany(t, full.dir, "map", 100) + want := full.files(t) + + // The device with the save syncs first: nothing of its own may go. + res, err := syncPair(t, full, empty) + if err != nil { + t.Fatal(err) + } + if res.Status == "conflict" { + t.Fatal("a save against an empty folder raised a conflict") + } + if !sameSave(full.files(t), want) { + t.Fatalf("the device with the save lost files to an empty one: %s", describe(full.files(t))) + } + if res, err := syncPair(t, empty, full); err != nil || res.Status == "conflict" { + t.Fatalf("the empty device's sync: %+v, %v", res, err) + } + if !sameSave(empty.files(t), want) { + t.Errorf("the empty device did not take the save: %s", describe(empty.files(t))) + } + if m.deletes() != 0 { + t.Errorf("%d deletion(s) were sent", m.deletes()) + } +} + +// A save that existed before versions did, identical on two devices, is one +// version at once: no conflict, nothing moved. +func TestVersions_IdenticalSavesFromBeforeVersionsAgree(t *testing.T) { + _, n := newMesh(t, "a", "b") + a, b := n["a"], n["b"] + writeMany(t, a.dir, "s", 5) + writeMany(t, b.dir, "s", 5) + res, err := syncPair(t, a, b) + if err != nil || res.Status != "in_sync" { + t.Fatalf("identical saves: %+v, %v", res, err) + } + if compareVersions(a.version(t).Vector, b.version(t).Vector) != versionEqual { + t.Errorf("identical saves got different versions: %v vs %v", a.version(t).Vector, b.version(t).Vector) + } +} + +// A deletion made in the game is part of the new version, and reaches the +// other device as that — whole — however many files it is. +func TestVersions_DeletionsTravelWithTheVersion(t *testing.T) { + m, n := newMesh(t, "a", "b") + a, b := n["a"], n["b"] + writeMany(t, a.dir, "map", 20) + settleAll(t, a, b) + a.change(t, func(dir string) { + _ = os.RemoveAll(filepath.Join(dir, "map")) + write(t, dir, "fresh.sav", "new world") + }) + settleAll(t, a, b) + if !sameSave(b.files(t), a.files(t)) { + t.Errorf("b = %s, want %s", describe(b.files(t)), describe(a.files(t))) + } + if m.deletes() != 0 { + t.Errorf("the newer device sent %d deletion(s) itself; the older one takes them", m.deletes()) + } +} + +// Both devices changed the save while apart: that, and only that, is asked +// about. Resolution is mutual — keeping A's on A asks B in turn rather than +// overwriting it, and A is not asked again. Once B agrees, the answer spreads, +// and a third device that had changed nothing takes it without being asked. +func TestVersions_IndependentChangesAskEachSideOnceAndTheAnswerSpreads(t *testing.T) { + _, n := newMesh(t, "a", "b", "c") + a, b, c := n["a"], n["b"], n["c"] + write(t, a.dir, "slot.sav", "start") + settleAll(t, a, b, c) + + a.change(t, func(dir string) { write(t, dir, "slot.sav", "a-played") }) + b.change(t, func(dir string) { write(t, dir, "slot.sav", "b-played") }) + + res, err := syncPair(t, a, b) + if err != nil || res.Status != "conflict" { + t.Fatalf("independent changes: %+v, %v — want a conflict", res, err) + } + if _, err := a.eng.ResolveConflict(context.Background(), "game1", b.peer().ID, "keep-local"); err != nil { + t.Fatal(err) + } + bBefore := b.files(t) + settleAll(t, a, b, c) + if len(a.eng.ActiveConflicts()) != 0 { + t.Error("A was asked again after answering") + } + if len(b.eng.ActiveConflicts()) == 0 { + t.Fatal("B was never asked: A's answer must not decide B's save on its own") + } + if !sameSave(b.files(t), bBefore) { + t.Error("B's save changed before anyone there answered") + } + if _, err := b.eng.ResolveConflict(context.Background(), "game1", a.peer().ID, "keep-remote"); err != nil { + t.Fatal(err) + } + settleAll(t, a, b, c) + for _, d := range []*meshNode{b, c} { + if got := d.files(t)["slot.sav"]; got != a.files(t)["slot.sav"] { + t.Errorf("%s did not take the kept version", d.name) + } + if len(d.eng.ActiveConflicts()) != 0 { + t.Errorf("%s still has a conflict after both answered", d.name) + } + } +} + +// Both sides answer "keep mine" (or "keep both"): the later answer stands and +// both end on one save, instead of asking each other forever. +func TestVersions_TwoAnswersSettleOnTheLater(t *testing.T) { + _, n := newMesh(t, "a", "b") + a, b := n["a"], n["b"] + write(t, a.dir, "slot.sav", "start") + settleAll(t, a, b) + a.change(t, func(dir string) { write(t, dir, "slot.sav", "a-played") }) + b.change(t, func(dir string) { write(t, dir, "slot.sav", "b-played") }) + if res, _ := syncPair(t, a, b); res.Status != "conflict" { + t.Fatalf("A: %+v, want a conflict", res) + } + if res, _ := syncPair(t, b, a); res.Status != "conflict" { + t.Fatalf("B: %+v, want a conflict", res) + } + if _, err := a.eng.ResolveConflict(context.Background(), "game1", b.peer().ID, "keep-local"); err != nil { + t.Fatal(err) + } + time.Sleep(5 * time.Millisecond) + if _, err := b.eng.ResolveConflict(context.Background(), "game1", a.peer().ID, "keep-local"); err != nil { + t.Fatal(err) + } + settleAll(t, a, b) + if got := a.files(t)["slot.sav"]; got != b.files(t)["slot.sav"] { + t.Fatal("the two never settled on one save") + } + if len(a.eng.ActiveConflicts())+len(b.eng.ActiveConflicts()) != 0 { + t.Error("a conflict is still open after both answered") + } + if got, _ := os.ReadFile(filepath.Join(a.dir, "slot.sav")); string(got) != "b-played" { + t.Errorf("A holds %q; B answered last, so its save should stand", got) + } +} + +// A change the watcher never reported — made while the app was closed — still +// becomes a version once the two devices meet, instead of leaving them on one +// version with different files, each waiting for the other. +func TestVersions_AnUnnoticedChangeIsFoundWhenTheDevicesMeet(t *testing.T) { + _, n := newMesh(t, "a", "b") + a, b := n["a"], n["b"] + write(t, a.dir, "slot.sav", "start") + settleAll(t, a, b) + write(t, a.dir, "slot.sav", "played offline") // no NoteLocalChange + settleAll(t, b, a) + if got := b.files(t)["slot.sav"]; got != a.files(t)["slot.sav"] { + t.Errorf("the unnoticed change never reached b") + } +} + +// Files a sync writes are not a change made here: the pull-in-progress record +// keeps them from becoming a version, even across a restart. +func TestVersions_PulledFilesAreNotANewVersion(t *testing.T) { + _, n := newMesh(t, "a", "b") + a, b := n["a"], n["b"] + writeMany(t, a.dir, "map", 10) + settleAll(t, a, b) + before := b.version(t).Vector + + a.change(t, func(dir string) { writeMany(t, dir, "map3", 10) }) + a.blockBudget = 3 + _, _ = syncPair(t, b, a) + a.blockBudget = -1 + + // What a restart leaves: the record says a pull was running, and the + // watcher reports the half-written folder as a change. + rec, _ := b.st.GetGameVersion("game1") + rec.Pulling = true + _ = b.st.SaveGameVersion(rec) + b.eng.NoteLocalChange("game1") + if got := b.version(t).Vector; compareVersions(got, before) != versionEqual { + t.Fatalf("a half-finished pull became a version of its own: %v (was %v)", got, before) + } + settleAll(t, a, b) + if !sameSave(b.files(t), a.files(t)) { + t.Errorf("b did not finish taking a's version: %s", describe(b.files(t))) + } +} diff --git a/internal/store/migrations/0037_game_versions.sql b/internal/store/migrations/0037_game_versions.sql new file mode 100644 index 00000000..3c0c2ce1 --- /dev/null +++ b/internal/store/migrations/0037_game_versions.sql @@ -0,0 +1,42 @@ +-- Which version of a game's save this device holds, as a version vector, and +-- whether it knows of a newer one it has not finished taking yet (see +-- internal/p2p/syncengine/version.go). +-- +-- vector is a JSON object of writer key to counter. A writer key belongs to +-- one device for one game (key below), and its counter moves only when the +-- save really changes there — the game or the user wrote it, never a sync. +-- Comparing two vectors says, without guessing from files or clocks, whether +-- one device is simply behind the other or both changed independently. +-- +-- target is the newest version this device has heard of that is newer than +-- its own, '' when none. While it is set this device is outdated: it takes +-- the save only from a device holding that version, and is a source for +-- nobody — its files are not handed out, and what it lacks is not read as +-- deleted. +-- +-- counter is this device's own counter for the game. It never goes back, even +-- when the device adopts another's version, so the same key and number never +-- name two different saves. +-- +-- pulling is set while a pull towards target is under way, so the files it +-- writes are not mistaken for a new local version if the app stops half-way. +-- +-- hash is the save's content hash when vector was last set ('' unknown). Two +-- devices on one version holding different files means a change went +-- unnoticed on one of them; this says which. +-- +-- answered is the other device's version this one kept its own save over when +-- someone answered a conflict here ('' none), and answered_at when (Unix ms). +-- It travels with the version, so the other device is asked in turn rather +-- than overwritten, and this one is not asked again. +CREATE TABLE game_versions ( + game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, + key TEXT NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + vector TEXT NOT NULL DEFAULT '{}', + target TEXT NOT NULL DEFAULT '', + pulling INTEGER NOT NULL DEFAULT 0, + hash TEXT NOT NULL DEFAULT '', + answered TEXT NOT NULL DEFAULT '', + answered_at INTEGER NOT NULL DEFAULT 0 +); diff --git a/internal/store/versions.go b/internal/store/versions.go new file mode 100644 index 00000000..3803db67 --- /dev/null +++ b/internal/store/versions.go @@ -0,0 +1,71 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" +) + +// Save versions: which version of a game's save this device holds. See +// migrations/0037_game_versions.sql for the columns and +// internal/p2p/syncengine/version.go for the rules. The vectors are kept as +// the JSON the sync engine writes; this layer does not interpret them. + +// GameVersion is one game's version record on this device. +type GameVersion struct { + GameID string `db:"game_id"` + Key string `db:"key"` + Counter int64 `db:"counter"` + Vector string `db:"vector"` + Target string `db:"target"` + Pulling bool `db:"pulling"` + Hash string `db:"hash"` + // Answered is the JSON vector this device kept its save over in a + // conflict, AnsweredAt when. + Answered string `db:"answered"` + AnsweredAt int64 `db:"answered_at"` +} + +// GetGameVersion returns a game's version record, creating an empty one — +// with a fresh writer key — the first time it is asked for. A game that is not +// tracked gets a record that is not stored. +func (s *Store) GetGameVersion(gameID string) (GameVersion, error) { + var v GameVersion + err := s.db.Get(&v, `SELECT * FROM game_versions WHERE game_id = ?`, gameID) + if err == nil { + return v, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return GameVersion{}, fmt.Errorf("get game version %s: %w", gameID, err) + } + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return GameVersion{}, err + } + v = GameVersion{GameID: gameID, Key: hex.EncodeToString(b[:]), Vector: "{}"} + // OR IGNORE: two callers creating it at once keep the first key. + if _, err := s.db.Exec(`INSERT OR IGNORE INTO game_versions (game_id, key, vector) + SELECT ?, ?, '{}' WHERE EXISTS (SELECT 1 FROM games WHERE id = ?)`, + gameID, v.Key, gameID); err != nil { + return GameVersion{}, fmt.Errorf("create game version %s: %w", gameID, err) + } + if err := s.db.Get(&v, `SELECT * FROM game_versions WHERE game_id = ?`, gameID); err != nil && + !errors.Is(err, sql.ErrNoRows) { + return GameVersion{}, fmt.Errorf("get game version %s: %w", gameID, err) + } + return v, nil +} + +// SaveGameVersion writes a game's version record. The writer key is never +// changed by this: it is the record's identity. +func (s *Store) SaveGameVersion(v GameVersion) error { + _, err := s.db.Exec(`UPDATE game_versions SET counter = ?, vector = ?, target = ?, pulling = ?, hash = ?, + answered = ?, answered_at = ? WHERE game_id = ?`, + v.Counter, v.Vector, v.Target, v.Pulling, v.Hash, v.Answered, v.AnsweredAt, v.GameID) + if err != nil { + return fmt.Errorf("save game version %s: %w", v.GameID, err) + } + return nil +} From c060252a864bfb829919df46b069eb608c1dc99d Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:56:36 +0200 Subject: [PATCH 22/75] sync: settle saves from before versions by their files, or by "Use this save everywhere" Saves that existed before version tracking get a starting version named after their content, and nothing recorded says which device's is newer. Between two of those, the old file-by-file comparison used to decide - and it either asked, or merged the two file by file into a save no game wrote. Now, between saves from before versions: - if the two last agreed on a state and one still holds exactly it, the other is newer; - otherwise if one is newer in every file that differs (newer, or missing on the other side) and the other holds no file written after its newest, that one is newer; - the older device takes the newer save whole, never file by file. The side that gives way was older in every differing file, so it loses nothing newer than what it takes. - anything else is asked about. "Use this save everywhere" (game header, POST /api/games/{id}/use-everywhere) is for states nobody can rank - half-arrived copies, mixtures. The device's version then covers every save from before versions (entry "g:*"), so every such device takes it whole, keeping a snapshot first. A change made on another device since versions existed is not overruled: that is a conflict. An open conflict no longer blocks a game for good once a version newer than both sides of it arrives: it was settled elsewhere, and is cleared. --- cmd/opensave-app/frontend/package-lock.json | 3 - cmd/opensave-app/frontend/package.json.md5 | 2 +- .../frontend/src/views/game/GameHeader.svelte | 16 ++ internal/api/routes.go | 23 +++ internal/p2p/syncengine/engine.go | 18 +- internal/p2p/syncengine/version.go | 185 +++++++++++++++++- internal/p2p/syncengine/version_test.go | 151 ++++++++++++++ 7 files changed, 385 insertions(+), 13 deletions(-) diff --git a/cmd/opensave-app/frontend/package-lock.json b/cmd/opensave-app/frontend/package-lock.json index 68f07f6d..8c05f3ea 100644 --- a/cmd/opensave-app/frontend/package-lock.json +++ b/cmd/opensave-app/frontend/package-lock.json @@ -813,7 +813,6 @@ "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", @@ -1422,7 +1421,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz", "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -1506,7 +1504,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/cmd/opensave-app/frontend/package.json.md5 b/cmd/opensave-app/frontend/package.json.md5 index 4484f39c..e467a8bd 100644 --- a/cmd/opensave-app/frontend/package.json.md5 +++ b/cmd/opensave-app/frontend/package.json.md5 @@ -1 +1 @@ -0999caa038c07b2125fd005b424e738d \ No newline at end of file +b3a4214f12b2550a7e8a68506323df0c \ No newline at end of file diff --git a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte index 8eb05a2e..ceafac2a 100644 --- a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte +++ b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte @@ -13,6 +13,7 @@ import ArrowLeft from 'lucide-svelte/icons/arrow-left'; import Play from 'lucide-svelte/icons/play'; import RefreshCw from 'lucide-svelte/icons/refresh-cw'; + import Upload from 'lucide-svelte/icons/upload'; import FolderOpen from 'lucide-svelte/icons/folder-open'; import Pencil from 'lucide-svelte/icons/pencil'; import Star from 'lucide-svelte/icons/star'; @@ -43,6 +44,20 @@ const syncNow = () => run('Sync triggered', () => api.post(`/api/games/${game.id}/sync`)); const launchGame = () => run('Launching…', () => api.post(`/api/games/${game.id}/launch`)); + // For when the devices hold states nobody can rank — a copy that arrived + // only half-way, a mixture of two saves — and the person knows which device + // has the right one. Every other device takes this save whole, keeping a + // snapshot of its own first; one that changed the save on its own since is + // still asked. + async function useEverywhere() { + const ok = await askConfirm( + `Make this device's save of ${game.name} the one every other device uses? They each replace theirs with it — every file, including removing files this device does not have — and keep a snapshot of their own first. A device where the save was changed after this update is asked instead.`, + { title: 'Use this save everywhere?', confirmText: 'Use this save everywhere', danger: true } + ); + if (!ok) return; + await run(`${game.name}: this save goes to your other devices`, () => api.post(`/api/games/${game.id}/use-everywhere`)); + } + let editPath = false; let pathDraft = ''; @@ -138,6 +153,7 @@ {#if canLaunch(game)} <button class="btn" disabled={$busy} on:click={launchGame}><Play size={15} />Launch</button> {/if} + <button class="btn" disabled={$busy} on:click={useEverywhere} title="Every other device replaces its save of this game with this one"><Upload size={15} />Use this save everywhere</button> <button class="btn primary" disabled={$busy} on:click={syncNow}><RefreshCw size={15} />Sync now</button> </div> </div> diff --git a/internal/api/routes.go b/internal/api/routes.go index 65a28d0c..fbac46fe 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "fmt" @@ -49,6 +50,7 @@ func (s *Server) routes(r chi.Router) { r.Post("/api/games/{gameId}/snapshot", s.handleCreateSnapshot) r.Post("/api/games/{gameId}/rollback", s.handleRollback) + r.Post("/api/games/{gameId}/use-everywhere", s.handleUseSaveEverywhere) r.Get("/api/games/{gameId}/save-files", s.handleGameSaveFiles) r.Get("/api/games/{gameId}/snapshot/{snapshotId}/files", s.handleSnapshotFiles) r.Post("/api/games/{gameId}/snapshot/{snapshotId}/restore-file", s.handleRestoreFile) @@ -637,6 +639,27 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, snap) } +// handleUseSaveEverywhere makes this device's save the one every other device +// takes (syncengine.UseSaveEverywhere), and starts a sync to hand it out. The +// sync runs in the background: a large save takes a while, and the answer the +// person needs is that it was accepted. A paused device hands it out when it +// resumes. +func (s *Server) handleUseSaveEverywhere(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "gameId") + vec, err := s.Daemon.P2P.Sync.UseSaveEverywhere(gameID) + if err != nil { + writeError(w, http.StatusConflict, err.Error()) + return + } + s.Daemon.P2P.GoSync(func(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + _, _ = s.Daemon.P2P.SyncGame(ctx, gameID) + }) + s.BroadcastGamesUpdate() + writeJSON(w, http.StatusOK, map[string]any{"version": vec.String()}) +} + func (s *Server) handleCreateBranch(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "gameId") // copyCurrentSave defaults to true when the field is absent: a caller that diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index e646ead2..28ef7b65 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -538,13 +538,21 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re remoteData.Manifest = filterManifest(remoteData.Manifest, ignoreRules) } - // 3. Existing unresolved conflict blocks further syncing. + // 3. Existing unresolved conflict blocks further syncing — unless a + // version newer than both sides of it has turned up since (version.go): + // it was settled elsewhere, by an answer or "Use this save everywhere", + // and the question here no longer stands. e.mu.Lock() - if existing := e.activeConflicts[gameID]; existing != nil { - e.mu.Unlock() - return Result{Status: "conflict", PeerID: peer.ID, PeerName: peer.Name}, nil - } + existing := e.activeConflicts[gameID] e.mu.Unlock() + if existing != nil { + if remoteData.Version == nil || !game.AutoSync || !e.supersedes(gameID, *remoteData.Version) { + return Result{Status: "conflict", PeerID: peer.ID, PeerName: peer.Name}, nil + } + e.Log("info", fmt.Sprintf("%q: %s holds a version newer than both sides of the open conflict — taking it instead of asking", + game.Name, peer.Name)) + e.clearConflict(gameID) + } // 3b. The versions decide, when both devices keep them (version.go): who // is behind takes the newer save whole, from a device that holds it, and diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index 684f0c09..362306c4 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "sort" "strings" @@ -56,17 +57,30 @@ const ( versionConcurrent ) +// genesisAll is the entry "Use this save everywhere" adds (UseSaveEverywhere): +// a version holding it counts as having every save from before versions +// existed behind it, whatever its content was named. +const genesisAll = "g:*" + +// holds reports whether v includes entry k at counter n. +func holds(v VersionVector, k string, n int64) bool { + if v[k] >= n { + return true + } + return k != genesisAll && strings.HasPrefix(k, "g:") && v[genesisAll] >= 1 +} + // compareVersions says how a relates to b. func compareVersions(a, b VersionVector) versionOrder { aBehind, bBehind := false, false for k, bv := range b { - if a[k] < bv { + if !holds(a, k, bv) { aBehind = true break } } for k, av := range a { - if b[k] < av { + if !holds(b, k, av) { bBehind = true break } @@ -601,8 +615,24 @@ func (e *Engine) syncByVersion(ctx context.Context, game store.Game, peer Peer, } if mine.Vector.genesisOnly() || theirs.Vector.genesisOnly() { // A save from before versions existed, never compared with this one - // since: the file comparison and the shared history decide, as before. - return Result{}, false, nil + // since: nothing recorded says which is newer. The files may, when + // they say it unmistakably (transitionNewer); then the older device + // takes the newer save whole, as any device behind does. Never file by + // file, which is how devices ended up holding mixtures of two saves + // that no game ever wrote. When the files do not say, the person is + // asked — or uses "Use this save everywhere" on the device that has + // the right one. + switch transitionNewer(local, remote.Manifest, e.Store.GetAgreedHash(gameID, peer.ID)) { + case 1: + return pushTo() + case -1: + e.Log("info", fmt.Sprintf("%q: %s's save is the newer one in every file that differs — taking it whole", + game.Name, peer.Name)) + return e.pullVersion(ctx, game, peer, local, remote, theirs.Vector) + } + e.Log("warn", fmt.Sprintf("%q differs from %s's and neither is clearly the newer — asking", game.Name, peer.Name)) + e.registerConflict(gameID, peer, local, remote) + return Result{Status: "conflict", PeerID: peer.ID, PeerName: peer.Name}, true, nil } // Someone already answered: here, keeping this device's save over the @@ -825,3 +855,150 @@ func (e *Engine) forgetLogOnce(key string) { delete(e.loggedOnce, key) e.versionMu.Unlock() } + +// transitionNewer says which of two saves from before versions is the newer, +// when that is beyond doubt: 1 for local, -1 for remote, 0 when it is not. +// +// Beyond doubt means one of two things. The devices last agreed on a state +// and one of them still holds exactly it: that one has not changed since, so +// the other is newer. Or one save is newer in every file that differs — each +// is newer there or missing on the other side — and the other holds no file +// of its own written after the first's newest. That is what a save someone +// played on looks like next to one nobody touched. +// +// It is deliberately not "the newer file wins, file by file": that makes a +// mixture of two saves no game wrote. And not "the newest file wins", which a +// mixture passes as easily as a real save. Here the device that gives way was +// older in every file that differs, so it never loses anything newer than +// what it takes — at worst it takes a copy that arrived only half-way, and +// then the complete save, newer than both, replaces that too when it is +// seen. States newer each in different files fail the test both ways and are +// left to a person. +func transitionNewer(local, remote delta.Manifest, agreed string) int { + if agreed != "" { + lh, rh := local.ManifestHash(), remote.ManifestHash() + switch { + case lh == agreed && rh != agreed: + return -1 + case rh == agreed && lh != agreed: + return 1 + } + } + l, r := newerInEveryFile(local, remote), newerInEveryFile(remote, local) + switch { + case l && !r: + return 1 + case r && !l: + return -1 + } + return 0 +} + +// newerInEveryFile reports whether a is newer than b wherever they differ, +// and they do differ. +func newerInEveryFile(a, b delta.Manifest) bool { + differ := false + var aNewest delta.Milli + for p, af := range a.Files { + if af.MtimeMs > aNewest { + aNewest = af.MtimeMs + } + bf, ok := b.Files[p] + if !ok { + differ = true + continue + } + if af.Hash != bf.Hash { + differ = true + if af.MtimeMs <= bf.MtimeMs { + return false + } + } + } + for p, bf := range b.Files { + if _, ok := a.Files[p]; ok { + continue + } + differ = true + if bf.MtimeMs >= aNewest { + return false + } + } + return differ +} + +// supersedes reports whether theirs is newer than this device's version and +// than the version the open conflict is with: both sides of the question are +// behind it. +func (e *Engine) supersedes(gameID string, theirs VersionInfo) bool { + e.mu.Lock() + c := e.activeConflicts[gameID] + e.mu.Unlock() + if c == nil || c.remoteVersion == nil { + return false + } + e.versionMu.Lock() + gv, err := e.loadVersionLocked(gameID) + e.versionMu.Unlock() + if err != nil || len(gv.vec) == 0 { + return false + } + return compareVersions(gv.vec, theirs.Vector) == versionOlder && + compareVersions(c.remoteVersion.Vector, theirs.Vector) == versionOlder +} + +// UseSaveEverywhere makes this device's save of a game the one every other +// device takes: its version becomes newer than every save from before +// versions existed, on any device, so each of those takes this one whole, +// keeping a snapshot of its own first. For when several devices hold states +// nobody can rank — copies that arrived half-way, mixtures of two saves — +// and a person knows which device has the right one. +// +// A change made on another device since versions existed is not overruled: +// that device and this one have each changed the save independently, and +// the person there is asked, as for any conflict. +func (e *Engine) UseSaveEverywhere(gameID string) (VersionVector, error) { + game, err := e.Store.GetGame(gameID) + if err != nil { + return nil, err + } + if !game.AutoSync { + return nil, errors.New("automatic sync is off for this game, so it does not take part in versions") + } + m, err := delta.BuildManifest(game.SavePath) + if err != nil { + return nil, err + } + if len(m.Files) == 0 { + return nil, errors.New("this device holds no save files for this game") + } + e.versionMu.Lock() + gv, err := e.loadVersionLocked(gameID) + if err != nil || gv.rec.GameID == "" { + e.versionMu.Unlock() + if err == nil { + err = errors.New("the game is not tracked") + } + return nil, err + } + gv.vec = mergeVersions(gv.vec, VersionVector{genesisAll: 1}) + gv.rec.Counter++ + gv.vec[gv.rec.Key] = gv.rec.Counter + gv.rec.Hash = e.versionHashOf(gameID, m) + gv.target, gv.answered, gv.rec.AnsweredAt = nil, nil, 0 + gv.rec.Pulling = false + saveErr := e.saveVersionLocked(gv) + vec := gv.vec.clone() + e.versionMu.Unlock() + if saveErr != nil { + return nil, saveErr + } + e.mu.Lock() + _, conflicted := e.activeConflicts[gameID] + e.mu.Unlock() + if conflicted { + e.clearConflict(gameID) + } + e.Log("info", fmt.Sprintf("%q: this device's save is to be used everywhere — version %s", game.Name, vec)) + return vec, nil +} diff --git a/internal/p2p/syncengine/version_test.go b/internal/p2p/syncengine/version_test.go index daade3c5..8c667207 100644 --- a/internal/p2p/syncengine/version_test.go +++ b/internal/p2p/syncengine/version_test.go @@ -35,6 +35,157 @@ func TestCompareVersions(t *testing.T) { } } +// "Use this save everywhere" covers every save from before versions, not +// changes made since. +func TestCompareVersions_UseEverywhereCoversEveryOldSave(t *testing.T) { + everywhere := VersionVector{"g:aaaa": 1, genesisAll: 1, "x": 1} + if got := compareVersions(VersionVector{"g:bbbb": 1}, everywhere); got != versionOlder { + t.Errorf("an old save of other content vs everywhere = %d, want older", got) + } + if got := compareVersions(VersionVector{}, everywhere); got != versionOlder { + t.Errorf("an empty folder vs everywhere = %d, want older", got) + } + if got := compareVersions(VersionVector{"g:bbbb": 1, "y": 1}, everywhere); got != versionConcurrent { + t.Errorf("a save changed since versions vs everywhere = %d, want concurrent", got) + } +} + +func setTimes(t *testing.T, dir, rel string, at time.Time) { + t.Helper() + if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(rel)), at, at); err != nil { + t.Fatal(err) + } +} + +// Two saves from before versions, one played on since they last matched: +// that one is newer in every file that differs, and the other takes it whole +// — no question, and no mixture. +func TestVersions_TransitionTakesTheClearlyNewerSave(t *testing.T) { + m, n := newMesh(t, "played", "old") + played, old := n["played"], n["old"] + t0 := time.Now().Add(-48 * time.Hour) + t1 := time.Now().Add(-1 * time.Hour) + for _, d := range []*meshNode{played, old} { + write(t, d.dir, "slot1.sav", "day one") + write(t, d.dir, "meta.dat", "m1") + write(t, d.dir, "stale.tmp", "gone later") + setTimes(t, d.dir, "slot1.sav", t0) + setTimes(t, d.dir, "meta.dat", t0) + setTimes(t, d.dir, "stale.tmp", t0) + } + // Played on, before the update: changed, added, removed. + write(t, played.dir, "slot1.sav", "day five") + write(t, played.dir, "slot2.sav", "new slot") + _ = os.Remove(filepath.Join(played.dir, "stale.tmp")) + setTimes(t, played.dir, "slot1.sav", t1) + setTimes(t, played.dir, "slot2.sav", t1) + + for _, pair := range [][2]*meshNode{{old, played}, {played, old}} { + res, err := syncPair(t, pair[0], pair[1]) + if err != nil || res.Status == "conflict" { + t.Fatalf("%s↔%s: %+v, %v", pair[0].name, pair[1].name, res, err) + } + } + settleAll(t, played, old) + if !sameSave(old.files(t), played.files(t)) { + t.Errorf("the old device did not take the played save whole: %s", describe(old.files(t))) + } + if m.deletes() != 0 { + t.Errorf("%d deletion(s) were sent", m.deletes()) + } +} + +// Newer each in a different file: nothing says which is the save, so the +// person is asked rather than either being taken — or a mixture made. +func TestVersions_TransitionAsksWhenNeitherIsClearlyNewer(t *testing.T) { + _, n := newMesh(t, "a", "b") + a, b := n["a"], n["b"] + t0 := time.Now().Add(-48 * time.Hour) + t1 := time.Now().Add(-1 * time.Hour) + write(t, a.dir, "one.sav", "a-new") + write(t, a.dir, "two.sav", "old") + write(t, b.dir, "one.sav", "old") + write(t, b.dir, "two.sav", "b-new") + setTimes(t, a.dir, "one.sav", t1) + setTimes(t, a.dir, "two.sav", t0) + setTimes(t, b.dir, "one.sav", t0) + setTimes(t, b.dir, "two.sav", t1) + aBefore, bBefore := a.files(t), b.files(t) + + res, err := syncPair(t, a, b) + if err != nil || res.Status != "conflict" { + t.Fatalf("mixed old saves: %+v, %v — want a conflict", res, err) + } + if !sameSave(a.files(t), aBefore) || !sameSave(b.files(t), bBefore) { + t.Error("a save changed before anyone answered") + } +} + +// "Use this save everywhere" on the device with the right save: every other +// device takes it whole — even one already waiting on a question about two +// mixed states — and nobody is asked. +func TestVersions_UseThisSaveEverywhere(t *testing.T) { + m, n := newMesh(t, "right", "mixed1", "mixed2") + right, mixed1, mixed2 := n["right"], n["mixed1"], n["mixed2"] + t0 := time.Now().Add(-48 * time.Hour) + t1 := time.Now().Add(-1 * time.Hour) + writeMany(t, right.dir, "map", 12) + write(t, right.dir, "players.db", "current") + // Two mixtures nobody can rank. + writeMany(t, mixed1.dir, "map", 6) + write(t, mixed1.dir, "players.db", "old-1") + write(t, mixed1.dir, "extra1.bin", "x") + writeMany(t, mixed2.dir, "map", 4) + write(t, mixed2.dir, "players.db", "old-2") + write(t, mixed2.dir, "extra2.bin", "y") + setTimes(t, mixed1.dir, "players.db", t1) + setTimes(t, mixed1.dir, "extra1.bin", t0) + setTimes(t, mixed2.dir, "players.db", t0) + // Newer than anything mixed1 holds, so neither is newer in every file. + setTimes(t, mixed2.dir, "extra2.bin", time.Now().Add(time.Hour)) + + if res, _ := syncPair(t, mixed1, mixed2); res.Status != "conflict" { + t.Fatalf("setup: two mixtures should conflict, got %+v", res) + } + if _, err := right.eng.UseSaveEverywhere("game1"); err != nil { + t.Fatal(err) + } + settleAll(t, right, mixed1, mixed2) + want := right.files(t) + for _, d := range []*meshNode{mixed1, mixed2} { + if !sameSave(d.files(t), want) { + t.Errorf("%s did not take the save used everywhere: %s", d.name, describe(d.files(t))) + } + if len(d.eng.ActiveConflicts()) != 0 { + t.Errorf("%s still has a conflict open", d.name) + } + } + if m.deletes() != 0 { + t.Errorf("%d deletion(s) were sent", m.deletes()) + } +} + +// A change made on another device after versions existed is not overruled by +// "Use this save everywhere": that is a conflict like any other. +func TestVersions_UseEverywhereDoesNotOverruleALaterChange(t *testing.T) { + _, n := newMesh(t, "a", "b") + a, b := n["a"], n["b"] + write(t, a.dir, "slot.sav", "start") + settleAll(t, a, b) + b.change(t, func(dir string) { write(t, dir, "slot.sav", "b-played") }) + if _, err := a.eng.UseSaveEverywhere("game1"); err != nil { + t.Fatal(err) + } + bBefore := b.files(t) + settleAll(t, a, b) + if !sameSave(b.files(t), bBefore) { + t.Error("b's change since the update was replaced without anyone being asked") + } + if len(a.eng.ActiveConflicts())+len(b.eng.ActiveConflicts()) == 0 { + t.Error("nobody was asked") + } +} + // --- a network of real engines ------------------------------------------- // meshNode is one device: its own store, save folder and engine. From e240dc98b01de3edb252b6a344e558a52cc00eee Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:56:45 +0200 Subject: [PATCH 23/75] frontend: undo package-lock and md5 changes a local build made --- cmd/opensave-app/frontend/package-lock.json | 3 +++ cmd/opensave-app/frontend/package.json.md5 | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/opensave-app/frontend/package-lock.json b/cmd/opensave-app/frontend/package-lock.json index 8c05f3ea..68f07f6d 100644 --- a/cmd/opensave-app/frontend/package-lock.json +++ b/cmd/opensave-app/frontend/package-lock.json @@ -813,6 +813,7 @@ "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", @@ -1421,6 +1422,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz", "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -1504,6 +1506,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/cmd/opensave-app/frontend/package.json.md5 b/cmd/opensave-app/frontend/package.json.md5 index e467a8bd..4484f39c 100644 --- a/cmd/opensave-app/frontend/package.json.md5 +++ b/cmd/opensave-app/frontend/package.json.md5 @@ -1 +1 @@ -b3a4214f12b2550a7e8a68506323df0c \ No newline at end of file +0999caa038c07b2125fd005b424e738d \ No newline at end of file From 40db566e95c24e1290daee7a04a1f1cdb2d4f764 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:29:27 +0200 Subject: [PATCH 24/75] sync: devices on an OpenSave without save versions can no longer do damage The transition to save versions happens one device at a time, and a device still on an older build decides by the old file comparison: whatever its copy lacks it reads as deleted, so a copy that arrived half-way spread its gaps as deletions to the devices that had the whole save. That is how a restored save lost 6,927 files again to a device not yet updated. - Nothing is taken from a peer that does not keep save versions (status peer_outdated_app); it can still take from this device. - Deletions it asks for are refused, on the LAN and over the relay. Requests from builds that keep versions say so ("versioned": true). - The device list marks such a peer "needs update". - The conflict dialog offers "Use this device's save everywhere" for when neither side is the right save. Peers signal that they keep versions with "versions": true in the manifest answer (and LAN protocol revision 3). The relay path now also carries the save's version: until now, relay syncs never got one and always fell back to the old file comparison. --- .../src/components/ConflictModal.svelte | 40 ++++++++++++++++++- .../frontend/src/views/Devices.svelte | 5 +++ internal/api/server.go | 5 ++- internal/cliapp/syncsummary.go | 2 + internal/p2p/deletion_race_test.go | 26 ++++++++++-- internal/p2p/routes.go | 17 +++++++- internal/p2p/syncengine/engine.go | 18 ++++++++- internal/p2p/syncengine/transport.go | 15 +++++++ internal/p2p/syncengine/version.go | 37 +++++++++++++++++ internal/p2p/syncengine/version_test.go | 35 +++++++++++++++- internal/p2p/transport_lan.go | 4 +- internal/p2p/transport_wan.go | 2 +- internal/p2p/wanclient_handlers.go | 19 +++++++++ 13 files changed, 215 insertions(+), 10 deletions(-) diff --git a/cmd/opensave-app/frontend/src/components/ConflictModal.svelte b/cmd/opensave-app/frontend/src/components/ConflictModal.svelte index b16e4151..f24a7baa 100644 --- a/cmd/opensave-app/frontend/src/components/ConflictModal.svelte +++ b/cmd/opensave-app/frontend/src/components/ConflictModal.svelte @@ -1,6 +1,6 @@ <script> import { dialogOut } from '../lib/motion.js'; - import { conflicts, conflictResolution, games, peers, settings, toast, view } from '../lib/stores.js'; + import { conflicts, conflictResolution, games, peers, settings, toast, view, askConfirm } from '../lib/stores.js'; import { isHandheld } from '../lib/devices.js'; import { visited } from '../lib/later.js'; import { api } from '../lib/api.js'; @@ -166,6 +166,31 @@ } } + // Neither side is the right save — a copy that arrived half-way, a mixture + // — and this device holds the right one: it goes to every device, which + // each keep a snapshot of their own first. A device on which the save was + // changed after the update is still asked. + async function useEverywhere() { + if (!current || busy) return; + const [gameId] = current; + const name = gameName; + const ok = await askConfirm( + `Make this device's save of ${name} the one every device uses, ${peerName} included? Each replaces its save with this one — every file, including removing files this device does not have — and keeps a snapshot of its own first.`, + { title: 'Use this save everywhere?', confirmText: 'Use this save everywhere', danger: true } + ); + if (!ok) return; + busy = true; + try { + await api.post(`/api/games/${gameId}/use-everywhere`); + toast(`${name}: this save goes to your other devices`, 'success'); + showDiff = false; + } catch (e) { + toast(e.message, 'error'); + } finally { + busy = false; + } + } + const fmtTime = (t) => (t ? new Date(t).toLocaleString() : '—'); const fmtMs = (ms) => (ms ? new Date(ms).toLocaleString() : 'unknown'); const fmtSize = (n) => @@ -279,6 +304,10 @@ Keep both (recommended) </button> </div> + <p class="hint-line"> + Neither is the right save, and this device has it? + <button class="linkish" disabled={busy} on:click={useEverywhere}>Use this device's save everywhere</button> + </p> <p class="hint-line"> <History size={15} class="inline-icon" /> Nothing is lost whichever you pick. <strong>“Keep both”</strong> (recommended) keeps playing yours — {peerName} receives it too — and keeps {peerName}'s version here on a branch you can switch @@ -491,4 +520,13 @@ color: var(--text-faint); line-height: 1.5; } + .linkish { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent-text); + text-decoration: underline; + cursor: pointer; + } </style> diff --git a/cmd/opensave-app/frontend/src/views/Devices.svelte b/cmd/opensave-app/frontend/src/views/Devices.svelte index 641c5f3b..b9d0759a 100644 --- a/cmd/opensave-app/frontend/src/views/Devices.svelte +++ b/cmd/opensave-app/frontend/src/views/Devices.svelte @@ -99,6 +99,11 @@ <span class="badge" class:online={peer.status === 'online'} class:offline={peer.status !== 'online'}> {peer.status} </span> + {#if peer.needsUpdate} + <span class="badge warn" title="Its OpenSave keeps no save versions: nothing is taken from it, and its deletions are refused, until it is updated"> + needs update + </span> + {/if} </div> <div class="peer-meta"> {peer.address === 'relay' ? 'internet relay' : `${peer.address}:${peer.port}`} diff --git a/internal/api/server.go b/internal/api/server.go index c1c2e167..7ac35c32 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -98,12 +98,15 @@ func (s *Server) peersPayload() map[string]any { AppVersion string `json:"appVersion,omitempty"` BuildTimeMs int64 `json:"buildTimeMs,omitempty"` HasNewerBuild bool `json:"hasNewerBuild,omitempty"` + // NeedsUpdate: its OpenSave keeps no save versions, so this + // device takes nothing from it until it is updated. + NeedsUpdate bool `json:"needsUpdate,omitempty"` // What actually protects traffic with this device. Sent per peer // rather than described once in the interface, because the answer // differs per pairing and the reader cannot work out which case // they are in from a general statement. p2p.PeerProtection - }{Peer: p, PeerProtection: s.Daemon.P2P.PeerProtection(p)} + }{Peer: p, PeerProtection: s.Daemon.P2P.PeerProtection(p), NeedsUpdate: s.Daemon.P2P.Sync.PeerNeedsUpdate(p.ID)} if b, ok := builds[p.ID]; ok { entry.AppVersion = b.AppVersion entry.BuildTimeMs = b.BuildTimeMs diff --git a/internal/cliapp/syncsummary.go b/internal/cliapp/syncsummary.go index b07eeb07..ff13d852 100644 --- a/internal/cliapp/syncsummary.go +++ b/internal/cliapp/syncsummary.go @@ -101,6 +101,8 @@ func outcomeFromPeers(gameID string, peers map[string]peerSyncResult) syncOutcom waiting = who + " is waiting for a folder to be chosen for it" case "peer_holding": waiting = who + " is holding it back: its save was emptied there" + case "peer_outdated_app": + waiting = who + " runs an older OpenSave without save versions — update it there" case "waiting": if waiting == "" { waiting = "a newer version is on a device that is not online" diff --git a/internal/p2p/deletion_race_test.go b/internal/p2p/deletion_race_test.go index 09f46f32..952fcb19 100644 --- a/internal/p2p/deletion_race_test.go +++ b/internal/p2p/deletion_race_test.go @@ -58,7 +58,7 @@ func (a *deviceA) FetchManifest(ctx context.Context, peer syncengine.Peer, gameI if err != nil { return syncengine.ManifestResponse{}, err } - return syncengine.ManifestResponse{Manifest: m, ActiveBranch: "main", Proto: syncengine.ProtoMultiRoot}, nil + return syncengine.ManifestResponse{Manifest: m, ActiveBranch: "main", Proto: syncengine.ProtoVersions}, nil } func (a *deviceA) FetchBlocks(ctx context.Context, peer syncengine.Peer, ref syncengine.FileRef, blockIndices []int, blockSize int) ([]syncengine.BlockData, error) { @@ -159,7 +159,7 @@ func writeFile(t *testing.T, path, body string) { // deleteRequest is one of A's deletion requests arriving at B's LAN route. func (f *raceFixture) deleteRequest(t *testing.T, rel, root string) { t.Helper() - body, _ := json.Marshal(map[string]string{"relPath": rel, "root": root}) + body, _ := json.Marshal(map[string]any{"relPath": rel, "root": root, "versioned": true}) r := httptest.NewRequest(http.MethodPost, "/api/p2p/delete-file/"+f.gameID, bytes.NewReader(body)) r.RemoteAddr = "198.51.100.7:40000" // not a paired device's address: no lineage refresh races the test rc := chi.NewRouteContext() @@ -172,6 +172,26 @@ func (f *raceFixture) deleteRequest(t *testing.T, rel, root string) { } } +// A deletion asked for by an OpenSave that keeps no save versions is refused: +// its old file comparison reads whatever its copy lacks as deleted. +func TestADeletionFromAnOldBuildIsRefused(t *testing.T) { + f := newRaceFixture(t, false) + body, _ := json.Marshal(map[string]any{"relPath": "slot1.sav"}) + r := httptest.NewRequest(http.MethodPost, "/api/p2p/delete-file/"+f.gameID, bytes.NewReader(body)) + r.RemoteAddr = "198.51.100.7:40000" + rc := chi.NewRouteContext() + rc.URLParams.Add("gameId", f.gameID) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rc)) + w := httptest.NewRecorder() + f.e.handleDeleteFile(w, r) + if w.Code != http.StatusConflict { + t.Errorf("a deletion from an old build: %d %s, want %d", w.Code, w.Body.String(), http.StatusConflict) + } + if !f.has(f.bDir, "slot1.sav") { + t.Error("the file was deleted on an old build's word") + } +} + func (f *raceFixture) has(dir, rel string) bool { _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(rel))) return err == nil @@ -212,7 +232,7 @@ func TestASyncBetweenTwoRelayedDeletionsIsNotAConflict(t *testing.T) { os.Remove(filepath.Join(f.a.dir, "slot1.sav")) os.Remove(filepath.Join(f.a.dir, "slot2.sav")) - body, _ := json.Marshal(map[string]string{"relPath": "slot1.sav"}) + body, _ := json.Marshal(map[string]any{"relPath": "slot1.sav", "versioned": true}) if code, out := w.serveDeleteFile("/delete-file/"+f.gameID, body, "node_unknown"); code != 200 { t.Fatalf("relayed delete: %d %v", code, out) } diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index dccff6ce..7fb3e2b8 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -725,6 +725,7 @@ func (e *Engine) handleManifest(w http.ResponseWriter, r *http.Request) { Proto: ServedProto(), DeletionConfirmed: e.Sync.DeletionConfirmed(game.ID), Version: e.Sync.LocalVersion(game, manifest), + Versions: true, } if latest, err := e.Snapshots.LatestSnapshot(gameID, ""); err == nil { resp.LatestSnapshot = &syncengine.SnapshotInfo{ID: latest.ID, Timestamp: latest.Timestamp, Comment: latest.Comment} @@ -919,6 +920,11 @@ func (e *Engine) handleDeleteFile(w http.ResponseWriter, r *http.Request) { var body struct { RelPath string `json:"relPath"` Root string `json:"root"` + // Versioned: the asking device keeps save versions. One that does + // not decides deletions by the old file comparison, which reads + // whatever its copy lacks as deleted — the deletions that emptied + // whole saves when a copy had arrived only half-way. + Versioned bool `json:"versioned"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RelPath == "" { jsonError(w, http.StatusBadRequest, "relPath is required.") @@ -930,6 +936,15 @@ func (e *Engine) handleDeleteFile(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusNotFound, "Game not found.") return } + if game.AutoSync && !body.Versioned { + from := clientIP(r) + if p, ok := e.peerByAddress(from); ok { + from = p.Name + } + e.Sync.RefusedOldBuildDelete(game.Name, from) + jsonError(w, http.StatusConflict, syncengine.OldBuildDeleteMessage) + return + } base, ok := e.resolveServeRoot(gameID, game, body.Root) if !ok { jsonError(w, http.StatusNotFound, "This device has no save location named "+strconv.Quote(body.Root)+" for that game.") @@ -1179,7 +1194,7 @@ func (e *Engine) resolveServeRoot(gameID string, game store.Game, root string) ( // argument — the read still happens concurrently. var servedProto atomic.Int64 -func init() { servedProto.Store(syncengine.ProtoBatchFiles) } +func init() { servedProto.Store(syncengine.ProtoVersions) } // ServedProto reports the protocol revision advertised to peers. func ServedProto() int { return int(servedProto.Load()) } diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 28ef7b65..302170f6 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -155,6 +155,9 @@ type Engine struct { versionMu sync.Mutex pullingNow map[string]bool loggedOnce map[string]string + // peerVersions: per peer, whether its OpenSave keeps save versions + // (PeerNeedsUpdate). + peerVersions map[string]bool } // New creates an Engine. @@ -353,7 +356,7 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer // divergence from the NEXT sync, causing the peer to silently overwrite // its own changes instead of detecting the conflict and asking. switch res.Status { - case "conflict", "error", versionStatusWaiting: + case "conflict", "error", versionStatusWaiting, StatusPeerOutdatedApp: case "peer_missing", "peer_awaiting_folder", "peer_holding": // The two devices talked and finished, which is what the // per-device stamp has always recorded. But nothing of THIS game @@ -474,6 +477,19 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re return Result{}, fmt.Errorf("fetch remote manifest: %w", err) } + // 1b. A peer whose OpenSave keeps no save versions decides by the old file + // comparison: whatever its copy lacks, it reads as deleted, and a copy that + // arrived half-way spreads its gaps that way. Nothing is taken from it + // until it is updated — it can still take from here, and its deletions are + // refused where they arrive (handleDeleteFile). + e.notePeerApp(peer.ID, remoteData.KeepsVersions()) + if game.AutoSync && !remoteData.KeepsVersions() { + e.logOnce(gameID+"|"+peer.ID, fmt.Sprintf( + "%q: %s runs an OpenSave without save versions — nothing is taken from it until it is updated", + game.Name, peer.Name)) + return Result{Status: StatusPeerOutdatedApp, PeerID: peer.ID, PeerName: peer.Name}, nil + } + // 2. Branch alignment: local follows the remote's active branch. // // From the switch until the peer's save has been pulled into the branch, diff --git a/internal/p2p/syncengine/transport.go b/internal/p2p/syncengine/transport.go index ed8f8466..72cb53a5 100644 --- a/internal/p2p/syncengine/transport.go +++ b/internal/p2p/syncengine/transport.go @@ -38,6 +38,12 @@ const ProtoMultiRoot = 1 // small files (Project Zomboid map chunks) meant half a day for 50MB. const ProtoBatchFiles = 2 +// ProtoVersions is the revision at which a peer keeps save versions +// (version.go). A peer below it decides by the old file comparison, which +// reads whatever its copy lacks as deleted; nothing is taken from such a peer +// or deleted on its word (OldBuildRefused). +const ProtoVersions = 3 + // FileBlocksRequest asks for some blocks of one file, as one entry of a batch. type FileBlocksRequest struct { RelPath string `json:"relPath"` @@ -100,6 +106,15 @@ type ManifestResponse struct { // Version is the responder's version of the save (version.go), absent // from builds that predate it and for games it does not version. Version *VersionInfo `json:"version,omitempty"` + // Versions says the responder keeps save versions at all. The relay + // path sends it instead of a Proto, which there would also claim things + // that path does not serve. + Versions bool `json:"versions,omitempty"` +} + +// KeepsVersions reports whether the responder keeps save versions. +func (r ManifestResponse) KeepsVersions() bool { + return r.Versions || r.Proto >= ProtoVersions } // FileRef identifies one file inside one of a game's save locations. diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index 362306c4..603b7705 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -927,6 +927,43 @@ func newerInEveryFile(a, b delta.Manifest) bool { return differ } +// StatusPeerOutdatedApp: the peer runs an OpenSave without save versions. +// Nothing is taken from it until it is updated; it can still take from here. +const StatusPeerOutdatedApp = "peer_outdated_app" + +// OldBuildDeleteMessage is the answer to a deletion asked for by a device +// whose OpenSave does not keep save versions. +const OldBuildDeleteMessage = "this device keeps save versions and takes no deletions from an OpenSave that does not — update OpenSave on the asking device" + +// notePeerApp records whether a peer's OpenSave keeps save versions, as its +// last manifest answer said. +func (e *Engine) notePeerApp(peerID string, versions bool) { + e.versionMu.Lock() + defer e.versionMu.Unlock() + if e.peerVersions == nil { + e.peerVersions = map[string]bool{} + } + e.peerVersions[peerID] = versions +} + +// PeerNeedsUpdate reports whether a peer is known to run an OpenSave without +// save versions — one this device takes nothing from — so the person can be +// told to update it. +func (e *Engine) PeerNeedsUpdate(peerID string) bool { + e.versionMu.Lock() + defer e.versionMu.Unlock() + v, known := e.peerVersions[peerID] + return known && !v +} + +// RefusedOldBuildDelete logs, once per game and device, that a deletion asked +// for by an OpenSave without save versions was refused. +func (e *Engine) RefusedOldBuildDelete(gameID, from string) { + e.logOnce("refused|"+gameID+"|"+from, fmt.Sprintf( + "%s asked to delete files of %q; refused — its OpenSave does not keep save versions and decides by the old file comparison. Update it there.", + from, gameID)) +} + // supersedes reports whether theirs is newer than this device's version and // than the version the open conflict is with: both sides of the question are // behind it. diff --git a/internal/p2p/syncengine/version_test.go b/internal/p2p/syncengine/version_test.go index 8c667207..1fe8fa51 100644 --- a/internal/p2p/syncengine/version_test.go +++ b/internal/p2p/syncengine/version_test.go @@ -197,6 +197,9 @@ type meshNode struct { // blockBudget, when >= 0, is how many more files this device serves // before every further block request fails: a pull that stops part-way. blockBudget int + // proto is the protocol revision it answers with; below ProtoVersions it + // stands for an OpenSave that keeps no save versions. + proto int } func (n *meshNode) peer() Peer { @@ -248,6 +251,7 @@ func (t *meshTransport) FetchManifest(ctx context.Context, peer Peer, gameID str ActiveBranch: game.ActiveBranch, DeletionConfirmed: n.eng.DeletionConfirmed(gameID), Version: n.eng.LocalVersion(game, m), + Proto: n.proto, }, nil } @@ -324,7 +328,7 @@ func newMesh(t *testing.T, names ...string) (*mesh, map[string]*meshNode) { if err := st.CreateGame(store.Game{ID: "game1", Name: "Game One", SavePath: dir, AutoSync: true, MaxSnapshots: 50}); err != nil { t.Fatal(err) } - n := &meshNode{name: name, dir: dir, st: st, blockBudget: -1} + n := &meshNode{name: name, dir: dir, st: st, blockBudget: -1, proto: ProtoVersions} n.eng = New(st, snapshot.New(st), &meshTransport{m: m, self: n}) m.nodes[n.peer().ID] = n byName[name] = n @@ -696,6 +700,35 @@ func TestVersions_AnUnnoticedChangeIsFoundWhenTheDevicesMeet(t *testing.T) { } } +// A device whose OpenSave keeps no save versions is never a source: its half +// of a save is not taken, and nothing here changes on its account. It is +// reported as needing an update. +func TestVersions_NothingIsTakenFromAnOldBuild(t *testing.T) { + _, n := newMesh(t, "current", "old") + current, old := n["current"], n["old"] + writeMany(t, current.dir, "map", 10) + writeMany(t, old.dir, "map", 3) + write(t, old.dir, "only-old.bin", "x") + old.proto = ProtoBatchFiles + before := current.files(t) + + res, err := syncPair(t, current, old) + if err != nil || res.Status != StatusPeerOutdatedApp { + t.Fatalf("sync with an old build: %+v, %v — want %s", res, err, StatusPeerOutdatedApp) + } + if !sameSave(current.files(t), before) { + t.Error("the save changed on account of a device without save versions") + } + if !current.eng.PeerNeedsUpdate(old.peer().ID) { + t.Error("the old build is not reported as needing an update") + } + old.proto = ProtoVersions + _, _ = syncPair(t, current, old) + if current.eng.PeerNeedsUpdate(old.peer().ID) { + t.Error("still reported as needing an update after it was updated") + } +} + // Files a sync writes are not a change made here: the pull-in-progress record // keeps them from becoming a version, even across a restart. func TestVersions_PulledFilesAreNotANewVersion(t *testing.T) { diff --git a/internal/p2p/transport_lan.go b/internal/p2p/transport_lan.go index 4405f141..b17b6e1b 100644 --- a/internal/p2p/transport_lan.go +++ b/internal/p2p/transport_lan.go @@ -134,7 +134,9 @@ func (t *lanTransport) FetchFileBatch(ctx context.Context, peer syncengine.Peer, } func (t *lanTransport) DeleteRemote(ctx context.Context, peer syncengine.Peer, ref syncengine.FileRef) error { - return t.postJSON(ctx, peer, peerURL(peer, "/delete-file/"+ref.GameID), map[string]any{"relPath": ref.RelPath, "root": ref.Root}, nil) + // "versioned": this device keeps save versions; a peer that does too + // refuses deletions from one that does not (handleDeleteFile). + return t.postJSON(ctx, peer, peerURL(peer, "/delete-file/"+ref.GameID), map[string]any{"relPath": ref.RelPath, "root": ref.Root, "versioned": true}, nil) } // TriggerPeerPull tells a peer that this device holds newer content. diff --git a/internal/p2p/transport_wan.go b/internal/p2p/transport_wan.go index e16e545e..ac490288 100644 --- a/internal/p2p/transport_wan.go +++ b/internal/p2p/transport_wan.go @@ -90,7 +90,7 @@ func (t *wanTransport) FetchBlocks(ctx context.Context, peer syncengine.Peer, re } func (t *wanTransport) DeleteRemote(ctx context.Context, peer syncengine.Peer, ref syncengine.FileRef) error { - _, err := t.wan.Request(ctx, peer.ID, "/delete-file/"+ref.GameID, "POST", map[string]any{"relPath": ref.RelPath, "root": ref.Root}) + _, err := t.wan.Request(ctx, peer.ID, "/delete-file/"+ref.GameID, "POST", map[string]any{"relPath": ref.RelPath, "root": ref.Root, "versioned": true}) return err } diff --git a/internal/p2p/wanclient_handlers.go b/internal/p2p/wanclient_handlers.go index 95203dd3..00c2009e 100644 --- a/internal/p2p/wanclient_handlers.go +++ b/internal/p2p/wanclient_handlers.go @@ -617,9 +617,17 @@ func (w *WanClient) serveManifest(route string, body json.RawMessage, peerID str return 500, map[string]string{"error": err.Error()} } w.engine.Sync.NoteServed(game.ID, peerID, manifest) // syncengine/served.go + // "versions" says this device keeps save versions, and "version" is its + // version of this save (syncengine/version.go). Not "proto": over the + // relay that would also claim extra save locations and batched blocks, + // which this path does not serve. resp := map[string]any{ "gameId": gameID, "activeBranch": game.ActiveBranch, "manifest": manifest, "deletionConfirmed": w.engine.Sync.DeletionConfirmed(game.ID), + "versions": true, + } + if v := w.engine.Sync.LocalVersion(game, manifest); v != nil { + resp["version"] = v } if latest, err := w.engine.Snapshots.LatestSnapshot(gameID, ""); err == nil { resp["latestSnapshot"] = syncengine.SnapshotInfo{ID: latest.ID, Timestamp: latest.Timestamp, Comment: latest.Comment} @@ -678,6 +686,9 @@ func (w *WanClient) serveDeleteFile(route string, rawBody json.RawMessage, fromP gameID := route[strings.LastIndex(route, "/")+1:] var body struct { RelPath string `json:"relPath"` + // Versioned: the asking device keeps save versions (see the LAN + // route, handleDeleteFile). + Versioned bool `json:"versioned"` } if err := json.Unmarshal(rawBody, &body); err != nil || body.RelPath == "" { return 400, map[string]string{"error": "relPath is required."} @@ -686,6 +697,14 @@ func (w *WanClient) serveDeleteFile(route string, rawBody json.RawMessage, fromP if err != nil { return 404, map[string]string{"error": "Game not found."} } + if game.AutoSync && !body.Versioned { + from := fromPeerID + if p, err := w.engine.Store.GetPeer(fromPeerID); err == nil && p.Name != "" { + from = p.Name + } + w.engine.Sync.RefusedOldBuildDelete(game.Name, from) + return 409, map[string]string{"error": syncengine.OldBuildDeleteMessage} + } if !delta.IsSafePath(game.SavePath, body.RelPath) { return 403, map[string]string{"error": "invalid path"} } From 18f1626590f554dfb057491e397bc9fe36e0d6f9 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:54:41 +0200 Subject: [PATCH 25/75] sync: a save from before versions is settled by its most recent work, not asked about Before versions, devices that never ran a game held copies relayed from the others at different times, and syncs file by file had left some holding mixtures. Two such copies, neither newer in every file, were asked about - a question about states nobody made, whose right answer is always the copy with the newer work. Now, after the two existing rules (unchanged since the last agreed state; newer in every differing file), the save holding the most recent work wins: the latest-written of the files where the two differ, then the one newer in more of them. The other takes it whole, never file by file, keeping a snapshot of its own first. Only an exact tie is asked about. Changes made since versions existed are not decided this way: they are versions of their own, and a real conflict between them is still asked. --- internal/p2p/syncengine/version.go | 76 ++++++++++++++++++++++--- internal/p2p/syncengine/version_test.go | 59 +++++++++++++------ 2 files changed, 108 insertions(+), 27 deletions(-) diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index 603b7705..2b32ab1a 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -615,22 +615,20 @@ func (e *Engine) syncByVersion(ctx context.Context, game store.Game, peer Peer, } if mine.Vector.genesisOnly() || theirs.Vector.genesisOnly() { // A save from before versions existed, never compared with this one - // since: nothing recorded says which is newer. The files may, when - // they say it unmistakably (transitionNewer); then the older device - // takes the newer save whole, as any device behind does. Never file by - // file, which is how devices ended up holding mixtures of two saves - // that no game ever wrote. When the files do not say, the person is - // asked — or uses "Use this save everywhere" on the device that has - // the right one. + // since: nothing recorded says which is newer, so the files decide + // (transitionNewer) and the older device takes the newer save whole, + // as any device behind does — never file by file, which is how devices + // ended up holding mixtures of two saves that no game ever wrote. Only + // an exact tie is asked about. switch transitionNewer(local, remote.Manifest, e.Store.GetAgreedHash(gameID, peer.ID)) { case 1: return pushTo() case -1: - e.Log("info", fmt.Sprintf("%q: %s's save is the newer one in every file that differs — taking it whole", + e.Log("info", fmt.Sprintf("%q: %s's save holds the more recent work — taking it whole (this device's is kept in a snapshot)", game.Name, peer.Name)) return e.pullVersion(ctx, game, peer, local, remote, theirs.Vector) } - e.Log("warn", fmt.Sprintf("%q differs from %s's and neither is clearly the newer — asking", game.Name, peer.Name)) + e.Log("warn", fmt.Sprintf("%q differs from %s's and neither holds more recent work — asking", game.Name, peer.Name)) e.registerConflict(gameID, peer, local, remote) return Result{Status: "conflict", PeerID: peer.ID, PeerName: peer.Name}, true, nil } @@ -891,6 +889,66 @@ func transitionNewer(local, remote delta.Manifest, agreed string) int { case r && !l: return -1 } + return newestWork(local, remote) +} + +// newestWork settles two saves from before versions that neither rule above +// could: the one holding the most recent work — the latest-written of the +// files where they differ — is the save, and if that is a tie, the one newer +// in more of those files. The other takes it whole, keeping a snapshot of its +// own first. +// +// Before versions, devices that never ran a game held copies relayed from the +// others at different times, and syncs file by file had left some holding +// mixtures; those were asked about, about states nobody made. Taking the one +// with the most recent work is what syncing is for — an older state is never +// what anyone wants handed on — and whatever the other held stays in its +// snapshot. Changes made since versions existed are not decided this way: +// those are versions of their own, and a real conflict between them is asked. +// +// 0 only for an exact tie, which leaves it to a person. +func newestWork(a, b delta.Manifest) int { + var aNewest, bNewest delta.Milli + aCount, bCount := 0, 0 + consider := func(p string) { + af, inA := a.Files[p] + bf, inB := b.Files[p] + if inA && inB && af.Hash == bf.Hash { + return + } + if inA && af.MtimeMs > aNewest { + aNewest = af.MtimeMs + } + if inB && bf.MtimeMs > bNewest { + bNewest = bf.MtimeMs + } + if inA && inB { + switch { + case af.MtimeMs > bf.MtimeMs: + aCount++ + case bf.MtimeMs > af.MtimeMs: + bCount++ + } + } + } + for p := range a.Files { + consider(p) + } + for p := range b.Files { + if _, inA := a.Files[p]; !inA { + consider(p) + } + } + switch { + case aNewest > bNewest: + return 1 + case bNewest > aNewest: + return -1 + case aCount > bCount: + return 1 + case bCount > aCount: + return -1 + } return 0 } diff --git a/internal/p2p/syncengine/version_test.go b/internal/p2p/syncengine/version_test.go index 1fe8fa51..1d93d203 100644 --- a/internal/p2p/syncengine/version_test.go +++ b/internal/p2p/syncengine/version_test.go @@ -95,26 +95,55 @@ func TestVersions_TransitionTakesTheClearlyNewerSave(t *testing.T) { } } -// Newer each in a different file: nothing says which is the save, so the -// person is asked rather than either being taken — or a mixture made. -func TestVersions_TransitionAsksWhenNeitherIsClearlyNewer(t *testing.T) { - _, n := newMesh(t, "a", "b") +// Two copies relayed from other devices at different times, each newer in a +// different file — the state syncing file by file used to leave. Nobody is +// asked: the one holding the more recent work is the save, and the other +// takes it whole, keeping its own in a snapshot. Never a mixture. +func TestVersions_TransitionTakesTheMostRecentWorkWithoutAsking(t *testing.T) { + m, n := newMesh(t, "a", "b") a, b := n["a"], n["b"] t0 := time.Now().Add(-48 * time.Hour) - t1 := time.Now().Add(-1 * time.Hour) + t1 := time.Now().Add(-2 * time.Hour) + t2 := time.Now().Add(-1 * time.Hour) write(t, a.dir, "one.sav", "a-new") write(t, a.dir, "two.sav", "old") write(t, b.dir, "one.sav", "old") - write(t, b.dir, "two.sav", "b-new") + write(t, b.dir, "two.sav", "b-newest") setTimes(t, a.dir, "one.sav", t1) setTimes(t, a.dir, "two.sav", t0) setTimes(t, b.dir, "one.sav", t0) - setTimes(t, b.dir, "two.sav", t1) + setTimes(t, b.dir, "two.sav", t2) + want := b.files(t) + + for _, pair := range [][2]*meshNode{{a, b}, {b, a}} { + if res, err := syncPair(t, pair[0], pair[1]); err != nil || res.Status == "conflict" { + t.Fatalf("%s↔%s: %+v, %v — want no question", pair[0].name, pair[1].name, res, err) + } + } + settleAll(t, a, b) + if !sameSave(a.files(t), want) || !sameSave(b.files(t), want) { + t.Errorf("not both on the save with the most recent work: a %s, b %s", describe(a.files(t)), describe(b.files(t))) + } + if m.deletes() != 0 { + t.Errorf("%d deletion(s) were sent", m.deletes()) + } +} + +// Only an exact tie — the same latest time, newer in as many files — is asked +// about: there is nothing to tell the two apart. +func TestVersions_TransitionAsksOnlyOnAnExactTie(t *testing.T) { + _, n := newMesh(t, "a", "b") + a, b := n["a"], n["b"] + t1 := time.Now().Add(-1 * time.Hour) + write(t, a.dir, "slot.sav", "a-content") + write(t, b.dir, "slot.sav", "b-content") + setTimes(t, a.dir, "slot.sav", t1) + setTimes(t, b.dir, "slot.sav", t1) aBefore, bBefore := a.files(t), b.files(t) res, err := syncPair(t, a, b) if err != nil || res.Status != "conflict" { - t.Fatalf("mixed old saves: %+v, %v — want a conflict", res, err) + t.Fatalf("an exact tie: %+v, %v — want a question", res, err) } if !sameSave(a.files(t), aBefore) || !sameSave(b.files(t), bBefore) { t.Error("a save changed before anyone answered") @@ -131,21 +160,15 @@ func TestVersions_UseThisSaveEverywhere(t *testing.T) { t1 := time.Now().Add(-1 * time.Hour) writeMany(t, right.dir, "map", 12) write(t, right.dir, "players.db", "current") - // Two mixtures nobody can rank. - writeMany(t, mixed1.dir, "map", 6) + // Two states nothing tells apart: one file each, the same time. write(t, mixed1.dir, "players.db", "old-1") - write(t, mixed1.dir, "extra1.bin", "x") - writeMany(t, mixed2.dir, "map", 4) write(t, mixed2.dir, "players.db", "old-2") - write(t, mixed2.dir, "extra2.bin", "y") setTimes(t, mixed1.dir, "players.db", t1) - setTimes(t, mixed1.dir, "extra1.bin", t0) - setTimes(t, mixed2.dir, "players.db", t0) - // Newer than anything mixed1 holds, so neither is newer in every file. - setTimes(t, mixed2.dir, "extra2.bin", time.Now().Add(time.Hour)) + setTimes(t, mixed2.dir, "players.db", t1) + _ = t0 if res, _ := syncPair(t, mixed1, mixed2); res.Status != "conflict" { - t.Fatalf("setup: two mixtures should conflict, got %+v", res) + t.Fatalf("setup: an exact tie should be asked about, got %+v", res) } if _, err := right.eng.UseSaveEverywhere("game1"); err != nil { t.Fatal(err) From e91f203beb7cea9027f98713706f645b175b5e24 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:38:43 +0200 Subject: [PATCH 26/75] sync: batches of small files are no longer refused as too large The responder limits a batch to 16 MB counted as every requested block at the file's full block size. 256 files of 4 KB claim exactly 16 MB that way, so any batch of small files with a few larger ones among it was refused as "batch too large" - and the whole pull with it, again on every retry. A save of many small files (Project Zomboid) could not be pulled at all. - The requester also bounds a batch by what the responder counts, with room to spare, so its batches fit any responder of this protocol. - The responder counts each file at most at its real size. - A batch refused as too large anyway is fetched again as two halves rather than failing the pull. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/routes.go | 41 +++++++++++++------ internal/p2p/syncengine/batch.go | 42 ++++++++++++++++--- internal/p2p/syncengine/batch_test.go | 58 +++++++++++++++++++++++++-- 3 files changed, 120 insertions(+), 21 deletions(-) diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index 7fb3e2b8..9f9f3850 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -834,19 +834,6 @@ func (e *Engine) handleFileBatch(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusBadRequest, fmt.Sprintf("at most %d files per batch", fileBatchMaxFiles)) return } - var requested int64 - for _, f := range body.Files { - bs := f.BlockSize - if bs <= 0 { - bs = 64 * 1024 - } - requested += int64(len(f.BlockIndices)) * int64(bs) - } - if requested > fileBatchMaxBytes { - jsonError(w, http.StatusBadRequest, "batch too large") - return - } - game, err := e.trackedGameForPeer(gameID) if err != nil { jsonError(w, http.StatusNotFound, "Game not found.") @@ -858,6 +845,34 @@ func (e *Engine) handleFileBatch(w http.ResponseWriter, r *http.Request) { return } singleFile, _ := delta.ResolveLocalSaveFilePath(base) + + // What the answer will hold: each file's requested blocks at its block + // size, but never more than the file itself. Counting every block at full + // size took 256 files of 4 KB for 16 MB — the whole limit — and refused + // any batch of small files with a few larger ones among them, which failed + // whole pulls of saves made of many small files. + var requested int64 + for _, f := range body.Files { + bs := f.BlockSize + if bs <= 0 { + bs = 64 * 1024 + } + n := int64(len(f.BlockIndices)) * int64(bs) + if f.RelPath != "" && delta.IsSafePath(base, f.RelPath) { + p := delta.LocalNameFor(base, f.RelPath) + if singleFile { + p = base + } + if info, err := os.Stat(p); err == nil && info.Size() < n { + n = info.Size() + } + } + requested += n + } + if requested > fileBatchMaxBytes { + jsonError(w, http.StatusBadRequest, "batch too large") + return + } gz := wantsGzip(body.Encodings) out := make([]syncengine.FileBlocks, 0, len(body.Files)) diff --git a/internal/p2p/syncengine/batch.go b/internal/p2p/syncengine/batch.go index ae688f40..7a15d94e 100644 --- a/internal/p2p/syncengine/batch.go +++ b/internal/p2p/syncengine/batch.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "strings" "sync" "time" @@ -30,8 +31,25 @@ const ( // responder accepts (p2p.fileBatchMaxFiles / fileBatchMaxBytes). batchMaxFiles = 256 batchMaxBytes = 4 << 20 + // batchMaxClaimed bounds a request as the responder measures it: every + // block requested at the file's full block size, whatever the file's real + // size. 256 files of 4 KB claim 16 MB that way, which is the responder's + // whole limit (p2p.fileBatchMaxBytes), so any file of two blocks in such + // a batch had it refused as "batch too large" — and the whole pull with + // it, over and over, on saves of many small files. Kept under the limit + // with room to spare. + batchMaxClaimed = 12 << 20 ) +// claimedBytes is what a file costs a batch as the responder counts it. +func claimedBytes(f delta.FileEntry, indices []int) int64 { + bs := f.BlockSize + if bs <= 0 { + bs = 64 * 1024 + } + return int64(len(indices)) * int64(bs) +} + // batchWorkers is how many batch requests are in flight at once: enough to // keep the link and the responder's disk busy while this side writes. A var // only so a test can make the order deterministic. @@ -55,20 +73,22 @@ func changedBytes(f delta.FileEntry, indices []int) int64 { return n } -// groupBatches splits jobs into requests of at most batchMaxFiles files and -// batchMaxBytes of blocks. +// groupBatches splits jobs into requests of at most batchMaxFiles files, +// batchMaxBytes of blocks, and batchMaxClaimed as the responder counts them. func groupBatches(jobs []batchJob) [][]batchJob { var out [][]batchJob var cur []batchJob - var curBytes int64 + var curBytes, curClaimed int64 for _, j := range jobs { b := changedBytes(j.remote, j.indices) - if len(cur) > 0 && (len(cur) >= batchMaxFiles || curBytes+b > batchMaxBytes) { + c := claimedBytes(j.remote, j.indices) + if len(cur) > 0 && (len(cur) >= batchMaxFiles || curBytes+b > batchMaxBytes || curClaimed+c > batchMaxClaimed) { out = append(out, cur) - cur, curBytes = nil, 0 + cur, curBytes, curClaimed = nil, 0, 0 } cur = append(cur, j) curBytes += b + curClaimed += c } if len(cur) > 0 { out = append(out, cur) @@ -161,6 +181,18 @@ func (e *Engine) pullOneBatch(ctx context.Context, batcher BatchFetcher, peer Pe if err == nil || errors.Is(err, ErrBatchUnsupported) { break } + // Refused as too large — a responder that counts or limits batches + // differently: the same request will be refused again, so it goes + // as two halves instead. Asking again unchanged failed the whole pull. + if strings.Contains(err.Error(), "batch too large") && len(batch) > 1 { + half := len(batch) / 2 + first, err := e.pullOneBatch(ctx, batcher, peer, gameID, root, batch[:half], throttle, tracker, onProgress) + if err != nil { + return first, err + } + second, err := e.pullOneBatch(ctx, batcher, peer, gameID, root, batch[half:], throttle, tracker, onProgress) + return append(first, second...), err + } e.Log("warn", fmt.Sprintf("batch fetch attempt %d/%d of %d files failed: %v", attempt, maxAttempts, len(batch), err)) if attempt < maxAttempts { select { diff --git a/internal/p2p/syncengine/batch_test.go b/internal/p2p/syncengine/batch_test.go index 7550df1a..c468cf6c 100644 --- a/internal/p2p/syncengine/batch_test.go +++ b/internal/p2p/syncengine/batch_test.go @@ -24,6 +24,9 @@ type batchTransport struct { batchCalls int blockCalls int failPath string // answered with a per-file error + // maxFiles, when set, refuses bigger batches the way a responder that + // counts differently does. + maxFiles int } func (b *batchTransport) FetchManifest(ctx context.Context, peer Peer, gameID string, q ManifestQuery) (ManifestResponse, error) { @@ -46,6 +49,9 @@ func (b *batchTransport) FetchFileBatch(ctx context.Context, peer Peer, gameID, if len(files) > batchMaxFiles { return nil, fmt.Errorf("batch of %d files is over the limit", len(files)) } + if b.maxFiles > 0 && len(files) > b.maxFiles { + return nil, fmt.Errorf(`peer returned 400: {"error":"batch too large"}`) + } out := make([]FileBlocks, 0, len(files)) for _, f := range files { if f.RelPath == b.failPath { @@ -116,10 +122,11 @@ func TestPull_BatchesSmallFiles(t *testing.T) { } assertSameTree(t, env.remoteDir, env.localDir) - // 600 small files at 256 per request -> 3 requests; the big file alone + // 600 one-block files: each claims a 64 KB block as the responder counts, + // so 192 fit a request (batchMaxClaimed) -> 4 requests; the big file alone // goes block by block. - if bt.batchCalls != 3 { - t.Errorf("batch requests = %d, want 3", bt.batchCalls) + if bt.batchCalls != 4 { + t.Errorf("batch requests = %d, want 4", bt.batchCalls) } if bt.blockCalls != 1 { t.Errorf("per-file block requests = %d, want 1 (the big file)", bt.blockCalls) @@ -183,3 +190,48 @@ func TestGroupBatches_RespectsLimits(t *testing.T) { } } } + +// Many small files, some of several blocks: what the responder counts — +// every block at full block size — must stay under its limit too. 256 files +// of one 64 KB block already claim 16 MB, and each file of more blocks tipped +// a batch over it: "batch too large", and the whole pull failed. +func TestGroupBatches_StaysUnderWhatTheResponderCounts(t *testing.T) { + var jobs []batchJob + for i := 0; i < 2000; i++ { + blocks := []delta.Block{{Index: 0, Length: 4 << 10}} + indices := []int{0} + if i%10 == 0 { + blocks = []delta.Block{{Index: 0, Length: 64 << 10}, {Index: 1, Length: 64 << 10}, {Index: 2, Length: 10 << 10}} + indices = []int{0, 1, 2} + } + jobs = append(jobs, batchJob{relPath: fmt.Sprint(i), + remote: delta.FileEntry{BlockSize: 64 << 10, Blocks: blocks}, indices: indices}) + } + for _, b := range groupBatches(jobs) { + var claimed int64 + for _, j := range b { + claimed += claimedBytes(j.remote, j.indices) + } + if claimed > 16<<20 { + t.Errorf("a batch of %d files claims %d bytes, over the responder's limit", len(b), claimed) + } + } +} + +// A responder that refuses a batch as too large gets it again in halves, +// rather than the pull failing. +func TestPull_ABatchRefusedAsTooLargeIsSplit(t *testing.T) { + env, bt := setupBatchEngine(t, ProtoBatchFiles) + bt.maxFiles = 10 + for i := 0; i < 40; i++ { + write(t, env.remoteDir, fmt.Sprintf("chunks/c%02d.bin", i), fmt.Sprint("chunk ", i)) + } + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatalf("sync failed though the batches could be split: %v", err) + } + for i := 0; i < 40; i++ { + if _, err := os.Stat(filepath.Join(env.localDir, "chunks", fmt.Sprintf("c%02d.bin", i))); err != nil { + t.Fatalf("c%02d.bin did not arrive", i) + } + } +} From 3f85e47483cd38224f95325c4adebd2921195113 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:50:18 +0200 Subject: [PATCH 27/75] sync: closest devices first, and no fixed 30-second limit on block fetches Network-aware order: - Each device records how fast its connection to every other one is: from the pulls it makes, and from a short speed test (/api/p2p/speedtest, 2 MB, at most every 6 hours) when nothing has measured it. Before anything is measured, the address kind stands in: home network fast, VPN (Tailscale's range) slower, the internet relay slowest. - A sync goes to the fastest devices first. A device that is behind takes the newer save from the fastest device holding it. - Having asked a close device to take a newer save, a sync waits for it to finish (it reports sync-complete) before asking a much slower one, bounded by how long the transfer should take. The save reaches the close devices at full speed, and the slow ones can then take it from whichever device is nearest to them. - The device list shows each connection's kind and measured speed. Block fetches on direct addresses had a fixed 30-second limit. "Direct" includes VPN addresses, and a few MB of blocks at a VPN relay's hundred-odd KB a second outlasted it: the request failed, and with it the whole pull, over and over. The limit now grows with what is asked for, as it already did over the internet relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../frontend/src/views/Devices.svelte | 14 + internal/api/server.go | 7 +- internal/p2p/engine.go | 34 +++ internal/p2p/routes.go | 36 +++ internal/p2p/syncengine/engine.go | 35 ++- internal/p2p/syncengine/linkspeed.go | 273 ++++++++++++++++++ internal/p2p/syncengine/linkspeed_test.go | 106 +++++++ internal/p2p/transport_lan.go | 48 ++- internal/p2p/transport_wan.go | 11 + internal/p2p/wanclient_handlers.go | 1 + internal/store/migrations/0038_peer_links.sql | 15 + internal/store/peer_links.go | 37 +++ 12 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 internal/p2p/syncengine/linkspeed.go create mode 100644 internal/p2p/syncengine/linkspeed_test.go create mode 100644 internal/store/migrations/0038_peer_links.sql create mode 100644 internal/store/peer_links.go diff --git a/cmd/opensave-app/frontend/src/views/Devices.svelte b/cmd/opensave-app/frontend/src/views/Devices.svelte index b9d0759a..b03a143f 100644 --- a/cmd/opensave-app/frontend/src/views/Devices.svelte +++ b/cmd/opensave-app/frontend/src/views/Devices.svelte @@ -76,6 +76,15 @@ }; const fmtTime = (t) => (t ? new Date(t).toLocaleString() : 'never'); + + // How this device reaches the peer, and how fast it has found that to be. + const linkKinds = { lan: 'home network', vpn: 'VPN (e.g. Tailscale)', relay: 'internet relay', internet: 'internet' }; + const fmtRate = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB/s' : Math.max(1, Math.round(b / 1024)) + ' KB/s'); + function linkLabel(link) { + const kind = linkKinds[link.kind] ?? link.kind; + if (!link.bytesPerSec) return `${kind} · speed not measured yet`; + return `${kind} · ${fmtRate(link.bytesPerSec)} (measured ${fmtTime(link.measuredMs)})`; + } </script> <div class="head"> @@ -110,6 +119,11 @@ · last synced {fmtTime(peer.lastSynced)} {#if peer.appVersion}· OpenSave {peer.appVersion}{/if} </div> + {#if peer.link} + <div class="peer-meta" title="Saves go to the fastest connections first, and a device that is behind takes the newer save from the fastest device that has it."> + {linkLabel(peer.link)} + </div> + {/if} <!-- Per device, next to how it is reached, because that is the context the answer depends on. --> <div class="peer-protection"> diff --git a/internal/api/server.go b/internal/api/server.go index 7ac35c32..c9dc694a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -101,12 +101,17 @@ func (s *Server) peersPayload() map[string]any { // NeedsUpdate: its OpenSave keeps no save versions, so this // device takes nothing from it until it is updated. NeedsUpdate bool `json:"needsUpdate,omitempty"` + // Link is how fast this device has found its connection to it, + // and what kind of address it is reached on: what decides the + // order devices are synced in. + Link syncengine.LinkStat `json:"link"` // What actually protects traffic with this device. Sent per peer // rather than described once in the interface, because the answer // differs per pairing and the reader cannot work out which case // they are in from a general statement. p2p.PeerProtection - }{Peer: p, PeerProtection: s.Daemon.P2P.PeerProtection(p), NeedsUpdate: s.Daemon.P2P.Sync.PeerNeedsUpdate(p.ID)} + }{Peer: p, PeerProtection: s.Daemon.P2P.PeerProtection(p), NeedsUpdate: s.Daemon.P2P.Sync.PeerNeedsUpdate(p.ID), + Link: s.Daemon.P2P.Sync.Link(syncengine.Peer{ID: p.ID, Name: p.Name, Address: p.Address, Port: p.Port, IsWan: p.Address == "relay"})} if b, ok := builds[p.ID]; ok { entry.AppVersion = b.AppVersion entry.BuildTimeMs = b.BuildTimeMs diff --git a/internal/p2p/engine.go b/internal/p2p/engine.go index ca3e8fce..4b053026 100644 --- a/internal/p2p/engine.go +++ b/internal/p2p/engine.go @@ -125,6 +125,10 @@ type Engine struct { pingMisses map[string]int // probeMu serializes PingPairedPeers rounds. probeMu sync.Mutex + // probingLinks: peers whose connection is being speed-tested now + // (probeLinkSoon). + probeLinksMu sync.Mutex + probingLinks map[string]bool // When each game last got a safety snapshot before a peer's deletions // (snapshotBeforePeerDeletions). peerDeleteSnapMu sync.Mutex @@ -470,6 +474,7 @@ func (e *Engine) PingPairedPeers(ctx context.Context) { e.clearPingMisses(p.ID) e.noteAnswered(p.ID) newStatus = "online" + e.probeLinkSoon(p) } else if misses := e.notePingMiss(p.ID); misses >= offlineStrikes || !e.heardThisRun(p) { newStatus = "offline" } @@ -486,6 +491,35 @@ func (e *Engine) PingPairedPeers(ctx context.Context) { } } +// probeLinkSoon tests the connection to a peer that just answered, in the +// background, when nothing has measured it for a while +// (syncengine/linkspeed.go). One test per peer at a time. +func (e *Engine) probeLinkSoon(p store.Peer) { + if e.Sync == nil || p.Address == "relay" { + return + } + e.probeLinksMu.Lock() + if e.probingLinks == nil { + e.probingLinks = map[string]bool{} + } + if e.probingLinks[p.ID] { + e.probeLinksMu.Unlock() + return + } + e.probingLinks[p.ID] = true + e.probeLinksMu.Unlock() + go func() { + defer func() { + e.probeLinksMu.Lock() + delete(e.probingLinks, p.ID) + e.probeLinksMu.Unlock() + }() + if e.Sync.ProbeLinkIfDue(context.Background(), syncengine.Peer{ID: p.ID, Name: p.Name, Address: p.Address, Port: p.Port}) { + e.notifyPeerUpdate() + } + }() +} + // ErrNoPeersOnline is a sync with no other device to sync with: none of the // paired devices answered. Not a failure of anything — the save goes over when // one is back — which is why it is told apart from errors that are. diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index 9f9f3850..5c03e6d9 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -2,6 +2,7 @@ package p2p import ( "compress/gzip" + "crypto/rand" "context" "encoding/json" "errors" @@ -43,6 +44,7 @@ func (e *Engine) RegisterRoutes(r chi.Router) { r.Get("/api/p2p/games", e.handlePeerGameList) r.Post("/api/p2p/sync-event/{gameId}", e.handleSyncEvent) r.Get("/api/p2p/app-binary", e.handleAppBinary) + r.Get("/api/p2p/speedtest", e.handleSpeedTest) // What moves save data, or starts a sync: refused while paused. r.Group(func(r chi.Router) { @@ -56,6 +58,34 @@ func (e *Engine) RegisterRoutes(r chi.Router) { }) } +// speedTestMax bounds one speed test, and speedTestData is what it sends: +// random, so nothing along the way can compress it into a better figure. +const speedTestMax = 8 << 20 + +var speedTestData = func() []byte { + b := make([]byte, 1<<20) + _, _ = rand.Read(b) + return b +}() + +// handleSpeedTest sends n bytes (at most speedTestMax) for a peer to time: +// how it learns how close this device is (syncengine/linkspeed.go). +func (e *Engine) handleSpeedTest(w http.ResponseWriter, r *http.Request) { + n, _ := strconv.Atoi(r.URL.Query().Get("n")) + if n <= 0 || n > speedTestMax { + n = speedTestMax + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(n)) + for n > 0 { + chunk := min(n, len(speedTestData)) + if _, err := w.Write(speedTestData[:chunk]); err != nil { + return + } + n -= chunk + } +} + func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { @@ -1103,6 +1133,10 @@ func (e *Engine) handleSyncEvent(w http.ResponseWriter, r *http.Request) { if e.Sync.Progress.OnSyncError != nil { e.Sync.Progress.OnSyncError(gameID, ev) } + // A sync here waiting for this peer to finish need not wait on. + if peer, ok := e.peerByAddress(clientIP(r)); ok { + e.Sync.NotePeerPulled(gameID, peer.ID) + } } jsonOK(w, map[string]any{"success": true}) } @@ -1230,6 +1264,8 @@ func SetServedProto(v int) int { // freshly-pushed files deliberately stay out of it (see persistLineage), so // deleting one here would pull it back instead of propagating the delete. func (e *Engine) peerFinishedPulling(gameID string, peer syncengine.Peer, data map[string]any) { + // A sync here waiting for this peer before slower ones goes on. + defer e.Sync.NotePeerPulled(gameID, peer.ID) // Recorded first, and synchronously: the peer said exactly which files it // wrote, so the lineage can be updated now rather than after a manifest // round trip. That round trip is what left a window in which deleting a diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 302170f6..4a37bc8d 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -158,6 +158,13 @@ type Engine struct { // peerVersions: per peer, whether its OpenSave keeps save versions // (PeerNeedsUpdate). peerVersions map[string]bool + + // linkMu guards links, the measured connection to each peer, and + // pullWaits, syncs waiting for a close peer to finish taking a save + // (linkspeed.go). + linkMu sync.Mutex + links map[string]store.PeerLink + pullWaits map[string]chan struct{} } // New creates an Engine. @@ -325,13 +332,32 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer results := map[string]Result{} busy := false - for _, peer := range onlinePeers { + // Fastest connections first (linkspeed.go): a device that is behind takes + // the newer save from the closest device holding it, and a newer save + // here reaches the close devices before the slow ones. + onlinePeers = e.OrderBySpeed(onlinePeers) + for i, peer := range onlinePeers { // Hard per-peer cap: a wedged transport must never hold // activeSyncs forever (which would silently block every future // sync of this game until an app restart). peerCtx, cancel := context.WithTimeout(ctx, perPeerSyncTimeout) res, err := e.SyncWithPeer(peerCtx, gameID, peer) cancel() + // A close device asked to take this save is given the time to, before + // a much slower one is asked: it gets it at full speed, and the slow + // one can then take it from whichever device is nearest to it. + if err == nil && res.Status == "triggered_peer_pull" && e.waitsForClose(onlinePeers, i) { + var size int64 + if game, gerr := e.Store.GetGame(gameID); gerr == nil { + if m, merr := e.ReadManifest(ctx, gameID, game.SavePath); merr == nil { + for _, f := range m.Files { + size += f.Size + } + } + } + e.Log("info", fmt.Sprintf("waiting for %s (close by) to take %s before the slower devices", peer.Name, gameID)) + e.waitForPull(ctx, gameID, peer, size) + } if err == nil && res.Status == "peer_busy" { // Nothing was compared, so nothing is stamped, and it is not a // failure: the peer was writing this game for a sync of its own. @@ -1634,6 +1660,13 @@ func (e *Engine) pullFiles(ctx context.Context, peer Peer, gameID string, game s tracker := newProgressTracker(totalBytes) throttle := e.throttleFor(peer.Wan()) + // What this transfer says about the connection (linkspeed.go), measured + // however it ends: a pull cut off half-way still moved what it moved. + pullStarted := time.Now() + defer func() { + moved, _, _ := tracker.stats() + e.noteLinkRate(peer, moved, time.Since(pullStarted)) + }() // Progress reporter shared by the per-file loop and the block-group // loop inside each file. Without in-file reporting, a single large diff --git a/internal/p2p/syncengine/linkspeed.go b/internal/p2p/syncengine/linkspeed.go new file mode 100644 index 00000000..b19543b1 --- /dev/null +++ b/internal/p2p/syncengine/linkspeed.go @@ -0,0 +1,273 @@ +package syncengine + +import ( + "context" + "fmt" + "net" + "sort" + "time" + + "github.com/opensave/opensave/internal/store" +) + +// Network-aware syncing. +// +// Devices are not equally close. Two on the same home network move a save at +// tens of MB a second; one reached over a VPN relay or the internet relay may +// manage a hundred KB. A save that goes to every device at once spends hours +// crawling over the slow links while the device next door, which could have +// had it in seconds and then handed it on, waits in the same queue. +// +// So each device keeps how fast it has found its connection to every other — +// measured from the transfers it makes, and from a short speed test when it +// has nothing recent — and works fastest first: a sync goes to the close +// devices first, and a device that is behind takes the newer save from the +// fastest device that holds it. Before anything is measured, the kind of +// address stands in: a home-network address is taken for fast, a VPN address +// (Tailscale's range) for slower, the internet relay for slowest. + +// LinkStat is what this device knows about its connection to a peer. +type LinkStat struct { + // BytesPerSec is the measured rate, 0 when nothing is measured yet. + BytesPerSec float64 `json:"bytesPerSec"` + MeasuredMs int64 `json:"measuredMs"` + // Kind is what the address says: "lan", "vpn", "relay" or "internet". + Kind string `json:"kind"` +} + +const ( + // minMeasureBytes and minMeasureTime: smaller transfers say more about + // round trips than about the link, and are not counted. + minMeasureBytes = 512 << 10 + minMeasureTime = 500 * time.Millisecond + // probeEvery is how often a link is tested again when no transfer has + // measured it since; probeBytes how much a test moves. + probeEvery = 6 * time.Hour + probeBytes = 2 << 20 +) + +// prior rates, by address kind, used until a link is measured. +var priorRate = map[string]float64{ + "lan": 40 << 20, + "vpn": 2 << 20, + "internet": 1 << 20, + "relay": 256 << 10, +} + +var ( + _, tailscaleNet, _ = net.ParseCIDR("100.64.0.0/10") + privateNets = func() []*net.IPNet { + var out []*net.IPNet + for _, c := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "fc00::/7", "fe80::/10"} { + _, n, _ := net.ParseCIDR(c) + out = append(out, n) + } + return out + }() +) + +// linkKind says what a peer's address is. +func linkKind(peer Peer) string { + if peer.Wan() { + return "relay" + } + ip := net.ParseIP(peer.Address) + if ip == nil { + return "internet" + } + if ip.IsLoopback() { + return "lan" + } + if tailscaleNet.Contains(ip) { + return "vpn" + } + for _, n := range privateNets { + if n.Contains(ip) { + return "lan" + } + } + return "internet" +} + +func (e *Engine) loadLinksLocked() { + if e.links != nil { + return + } + e.links = map[string]store.PeerLink{} + if e.Store == nil { + return + } + if links, err := e.Store.PeerLinks(); err == nil { + e.links = links + } +} + +// Link is what this device knows about its connection to a peer. +func (e *Engine) Link(peer Peer) LinkStat { + e.linkMu.Lock() + defer e.linkMu.Unlock() + e.loadLinksLocked() + l := e.links[peer.ID] + return LinkStat{BytesPerSec: l.BytesPerSec, MeasuredMs: l.MeasuredMs, Kind: linkKind(peer)} +} + +// expectedRate is how fast a transfer with a peer is expected to go: what was +// measured, or the address kind's guess. +func (e *Engine) expectedRate(peer Peer) float64 { + l := e.Link(peer) + if l.BytesPerSec > 0 { + return l.BytesPerSec + } + return priorRate[l.Kind] +} + +// noteLinkRate records a transfer with a peer: bytes in d. +func (e *Engine) noteLinkRate(peer Peer, bytes int64, d time.Duration) { + if bytes < minMeasureBytes || d < minMeasureTime { + return + } + rate := float64(bytes) / d.Seconds() + e.linkMu.Lock() + defer e.linkMu.Unlock() + e.loadLinksLocked() + l := e.links[peer.ID] + l.PeerID = peer.ID + if l.BytesPerSec > 0 { + // A running average, weighted to the latest: a link changes (a VPN + // that found a direct path, a laptop that moved networks), and the + // order should follow it within a transfer or two. + l.BytesPerSec = 0.4*l.BytesPerSec + 0.6*rate + } else { + l.BytesPerSec = rate + } + l.MeasuredMs = time.Now().UnixMilli() + e.links[peer.ID] = l + if e.Store != nil { + _ = e.Store.SavePeerLink(l) + } +} + +// OrderBySpeed sorts peers fastest first, keeping the given order among +// equals. +func (e *Engine) OrderBySpeed(peers []Peer) []Peer { + out := append([]Peer(nil), peers...) + rates := make(map[string]float64, len(out)) + for _, p := range out { + rates[p.ID] = e.expectedRate(p) + } + sort.SliceStable(out, func(i, j int) bool { return rates[out[i].ID] > rates[out[j].ID] }) + return out +} + +// SpeedProber is implemented by transports that can test a link: fetch n +// bytes of nothing in particular from the peer and say how long it took. +type SpeedProber interface { + ProbeSpeed(ctx context.Context, peer Peer, n int) (int64, time.Duration, error) +} + +// ProbeLinkIfDue tests the link to a peer when nothing has measured it for a +// while. Not over the internet relay, which is the slowest link whatever a +// test says and would only be loaded further. Reports whether it ran. +func (e *Engine) ProbeLinkIfDue(ctx context.Context, peer Peer) bool { + prober, ok := e.Transport.(SpeedProber) + if !ok || peer.Wan() { + return false + } + e.linkMu.Lock() + e.loadLinksLocked() + l := e.links[peer.ID] + now := time.Now() + due := now.Sub(time.UnixMilli(l.MeasuredMs)) > probeEvery && now.Sub(time.UnixMilli(l.ProbedMs)) > probeEvery + if due { + l.PeerID = peer.ID + l.ProbedMs = now.UnixMilli() + e.links[peer.ID] = l + if e.Store != nil { + _ = e.Store.SavePeerLink(l) + } + } + e.linkMu.Unlock() + if !due { + return false + } + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + n, d, err := prober.ProbeSpeed(ctx, peer, probeBytes) + if err != nil { + e.Log("info", fmt.Sprintf("speed test with %s did not finish: %v", peer.Name, err)) + return true + } + e.noteLinkRate(peer, n, d) + e.Log("info", fmt.Sprintf("connection to %s: %s/s (%s)", peer.Name, humanBytes(int64(e.Link(peer).BytesPerSec)), linkKind(peer))) + return true +} + +// Waiting for close devices before slow ones. + +// fastLink is the rate from which a device counts as close, and slowerBy how +// much slower the next device must be for this one to be waited for first. +const ( + fastLink = 4 << 20 + slowerBy = 4.0 + maxWaitFor = 20 * time.Minute +) + +// NotePeerPulled is told that a peer finished (or gave up) pulling a game from +// this device, and lets a sync waiting for it go on. +func (e *Engine) NotePeerPulled(gameID, peerID string) { + e.linkMu.Lock() + ch := e.pullWaits[gameID+"|"+peerID] + delete(e.pullWaits, gameID+"|"+peerID) + e.linkMu.Unlock() + if ch != nil { + close(ch) + } +} + +// waitForPull waits, after asking a close peer to take this device's save, +// until it has — before the slow devices are asked, so the save reaches the +// close one at full speed and the slow ones can then take it from whichever +// device is closest to them. Bounded by how long the transfer should take, +// and given up when the context ends. +func (e *Engine) waitForPull(ctx context.Context, gameID string, peer Peer, bytes int64) { + ch := make(chan struct{}) + e.linkMu.Lock() + if e.pullWaits == nil { + e.pullWaits = map[string]chan struct{}{} + } + e.pullWaits[gameID+"|"+peer.ID] = ch + e.linkMu.Unlock() + defer func() { + e.linkMu.Lock() + if e.pullWaits[gameID+"|"+peer.ID] == ch { + delete(e.pullWaits, gameID+"|"+peer.ID) + } + e.linkMu.Unlock() + }() + wait := 30*time.Second + time.Duration(3*float64(bytes)/e.expectedRate(peer)*float64(time.Second)) + if wait > maxWaitFor { + wait = maxWaitFor + } + select { + case <-ch: + case <-time.After(wait): + e.Log("info", fmt.Sprintf("%s has not finished taking %s yet; going on with the other devices", peer.Name, gameID)) + case <-ctx.Done(): + } +} + +// waitsForClose reports whether, having asked peers[i] to take this device's +// save, the sync should wait for it before going on: it is a close device, +// and a much slower one is still to come. +func (e *Engine) waitsForClose(peers []Peer, i int) bool { + r := e.expectedRate(peers[i]) + if r < fastLink { + return false + } + for _, p := range peers[i+1:] { + if e.expectedRate(p)*slowerBy <= r { + return true + } + } + return false +} diff --git a/internal/p2p/syncengine/linkspeed_test.go b/internal/p2p/syncengine/linkspeed_test.go new file mode 100644 index 00000000..3e8544dc --- /dev/null +++ b/internal/p2p/syncengine/linkspeed_test.go @@ -0,0 +1,106 @@ +package syncengine + +import ( + "context" + "testing" + "time" +) + +func TestLinkKind(t *testing.T) { + cases := map[string]string{ + "192.168.178.20": "lan", + "10.0.0.5": "lan", + "127.0.0.1": "lan", + "100.99.121.82": "vpn", + "203.0.113.9": "internet", + } + for addr, want := range cases { + if got := linkKind(Peer{Address: addr}); got != want { + t.Errorf("%s: %s, want %s", addr, got, want) + } + } + if got := linkKind(Peer{Address: "relay"}); got != "relay" { + t.Errorf("relay: %s", got) + } +} + +// Before anything is measured the kind of address orders the devices; once a +// link is measured, the measurement does. +func TestOrderBySpeed(t *testing.T) { + env := setupEngine(t) + e := env.engine + home := Peer{ID: "home", Address: "192.168.178.20"} + vpn := Peer{ID: "vpn", Address: "100.99.121.82"} + relay := Peer{ID: "relay", Address: "relay"} + + got := e.OrderBySpeed([]Peer{relay, vpn, home}) + if got[0].ID != "home" || got[1].ID != "vpn" || got[2].ID != "relay" { + t.Errorf("by address: %v", ids(got)) + } + + // A VPN link that turns out fast, and a home one that turns out slow. + e.noteLinkRate(vpn, 200<<20, 2*time.Second) + e.noteLinkRate(home, 1<<20, 10*time.Second) + got = e.OrderBySpeed([]Peer{home, vpn, relay}) + if got[0].ID != "vpn" { + t.Errorf("by measurement: %v", ids(got)) + } + // Remembered across restarts. + e2 := New(env.store, nil, env.transport) + if e2.Link(vpn).BytesPerSec == 0 { + t.Error("the measured link was not kept") + } +} + +func TestNoteLinkRate_IgnoresTinyTransfers(t *testing.T) { + env := setupEngine(t) + p := Peer{ID: "p", Address: "192.168.1.2"} + env.engine.noteLinkRate(p, 10<<10, 2*time.Second) + if env.engine.Link(p).BytesPerSec != 0 { + t.Error("a transfer of a few KB was taken as the link's speed") + } +} + +// A close device is waited for before a much slower one, and the wait ends as +// soon as it reports that it has finished. +func TestWaitForClose(t *testing.T) { + env := setupEngine(t) + e := env.engine + home := Peer{ID: "home", Address: "192.168.178.20"} + vpn := Peer{ID: "vpn", Address: "100.99.121.82"} + peers := e.OrderBySpeed([]Peer{vpn, home}) + if !e.waitsForClose(peers, 0) { + t.Fatal("a close device is not waited for before a slow one") + } + if e.waitsForClose(peers, 1) { + t.Error("the last device is waited for, with nothing after it") + } + if e.waitsForClose(e.OrderBySpeed([]Peer{home}), 0) { + t.Error("waited with no slower device to follow") + } + + done := make(chan struct{}) + start := time.Now() + go func() { + e.waitForPull(context.Background(), "game1", home, 1<<30) + close(done) + }() + time.Sleep(100 * time.Millisecond) + e.NotePeerPulled("game1", home.ID) + select { + case <-done: + if time.Since(start) > 5*time.Second { + t.Error("the wait did not end when the device reported") + } + case <-time.After(10 * time.Second): + t.Fatal("the wait did not end when the device reported") + } +} + +func ids(ps []Peer) []string { + out := make([]string, len(ps)) + for i, p := range ps { + out[i] = p.ID + } + return out +} diff --git a/internal/p2p/transport_lan.go b/internal/p2p/transport_lan.go index b17b6e1b..44dcf3d6 100644 --- a/internal/p2p/transport_lan.go +++ b/internal/p2p/transport_lan.go @@ -92,15 +92,59 @@ func (t *lanTransport) FetchBlocks(ctx context.Context, peer syncengine.Peer, re // No encodings advertised: on a LAN the wire is typically faster than the // compressor, so the bytes saved cost more than they're worth. Responses // are still decoded, so a peer that compresses anyway is handled. - err := t.postJSON(ctx, peer, peerURL(peer, "/blocks/"+ref.GameID), map[string]any{ + // + // Given time for what it asks for. "LAN" here is any direct address, + // a VPN one (Tailscale) included, and a few MB of blocks at a VPN relay's + // hundred-odd KB a second outlasted the fixed 30 seconds every request + // used to get: the request failed, and with it the whole pull, over and + // over. Sized like the relay's (slowLinkBytesPerSec), within the bulk + // client's ceiling. + expected := int64(len(blockIndices)) * int64(max(blockSize, 1)) + ctx, cancel := context.WithTimeout(ctx, 30*time.Second+time.Duration(expected/(slowLinkBytesPerSec/2))*time.Second) + defer cancel() + raw, err := json.Marshal(map[string]any{ "relPath": ref.RelPath, "root": ref.Root, "blockIndices": blockIndices, "blockSize": blockSize, - }, &resp) + }) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, peerURL(peer, "/blocks/"+ref.GameID), bytes.NewReader(raw)) if err != nil { return nil, err } + req.Header.Set("Content-Type", "application/json") + t.sign(req, peer, raw) + if err := doJSONWith(lanBulkClient, req, &resp); err != nil { + return nil, err + } return decodeBlocks(resp.Blocks) } +// ProbeSpeed times fetching n bytes from the peer's speed test +// (syncengine.SpeedProber). The clock starts once the answer begins to +// arrive, so the figure is the link's rate rather than its round trip. +func (t *lanTransport) ProbeSpeed(ctx context.Context, peer syncengine.Peer, n int) (int64, time.Duration, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, peerURL(peer, fmt.Sprintf("/speedtest?n=%d", n)), nil) + if err != nil { + return 0, 0, err + } + t.sign(req, peer, nil) + resp, err := lanBulkClient.Do(req) + if err != nil { + return 0, 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, 0, fmt.Errorf("peer returned %d", resp.StatusCode) + } + start := time.Now() + got, err := io.Copy(io.Discard, resp.Body) + if err != nil { + return got, time.Since(start), err + } + return got, time.Since(start), nil +} + // FetchFileBatch fetches the blocks of many files in one request. Only asked // of a peer that advertised syncengine.ProtoBatchFiles. func (t *lanTransport) FetchFileBatch(ctx context.Context, peer syncengine.Peer, gameID, root string, files []syncengine.FileBlocksRequest) ([]syncengine.FileBlocks, error) { diff --git a/internal/p2p/transport_wan.go b/internal/p2p/transport_wan.go index ac490288..b2b5d90d 100644 --- a/internal/p2p/transport_wan.go +++ b/internal/p2p/transport_wan.go @@ -3,6 +3,7 @@ package p2p import ( "context" "encoding/json" + "errors" "fmt" "time" @@ -168,6 +169,16 @@ func (t *routingTransport) FetchFileBatch(ctx context.Context, peer syncengine.P return b.FetchFileBatch(ctx, peer, gameID, root, files) } +// ProbeSpeed tests LAN links only (syncengine.SpeedProber); the relay is the +// slowest link whatever a test would say. +func (t *routingTransport) ProbeSpeed(ctx context.Context, peer syncengine.Peer, n int) (int64, time.Duration, error) { + p, ok := t.lan.(syncengine.SpeedProber) + if peer.Wan() || !ok { + return 0, 0, errors.New("no speed test over the relay") + } + return p.ProbeSpeed(ctx, peer, n) +} + func (t *routingTransport) DeleteRemote(ctx context.Context, peer syncengine.Peer, ref syncengine.FileRef) error { return t.pick(peer).DeleteRemote(ctx, peer, ref) } diff --git a/internal/p2p/wanclient_handlers.go b/internal/p2p/wanclient_handlers.go index 00c2009e..00693e8c 100644 --- a/internal/p2p/wanclient_handlers.go +++ b/internal/p2p/wanclient_handlers.go @@ -172,6 +172,7 @@ func (w *WanClient) handleMessage(ctx context.Context, msg RelayMessage) { if w.engine.Sync.Progress.OnSyncError != nil { w.engine.Sync.Progress.OnSyncError(msg.GameID, ev) } + w.engine.Sync.NotePeerPulled(msg.GameID, msg.From) } case "request": diff --git a/internal/store/migrations/0038_peer_links.sql b/internal/store/migrations/0038_peer_links.sql new file mode 100644 index 00000000..4cff6cfb --- /dev/null +++ b/internal/store/migrations/0038_peer_links.sql @@ -0,0 +1,15 @@ +-- How fast this device has measured its connection to each paired device: +-- from transfers it made, and from a short speed test when there is nothing +-- recent (see internal/p2p/syncengine/linkspeed.go). Syncs go to the fastest +-- connections first, so a save reaches the devices close to it before it +-- crawls over a slow relay — and those can then hand it on. +-- +-- bytes_per_sec is a running average; measured_ms when it last changed; +-- probed_ms when a speed test last ran, so a slow link is not tested over and +-- over. +CREATE TABLE peer_links ( + peer_id TEXT PRIMARY KEY, + bytes_per_sec REAL NOT NULL DEFAULT 0, + measured_ms INTEGER NOT NULL DEFAULT 0, + probed_ms INTEGER NOT NULL DEFAULT 0 +); diff --git a/internal/store/peer_links.go b/internal/store/peer_links.go new file mode 100644 index 00000000..d65b3a37 --- /dev/null +++ b/internal/store/peer_links.go @@ -0,0 +1,37 @@ +package store + +import "fmt" + +// PeerLink is how fast this device has measured its connection to a peer. +// See migrations/0038_peer_links.sql. +type PeerLink struct { + PeerID string `db:"peer_id"` + BytesPerSec float64 `db:"bytes_per_sec"` + MeasuredMs int64 `db:"measured_ms"` + ProbedMs int64 `db:"probed_ms"` +} + +// PeerLinks returns every recorded link, by peer. +func (s *Store) PeerLinks() (map[string]PeerLink, error) { + var rows []PeerLink + if err := s.db.Select(&rows, `SELECT * FROM peer_links`); err != nil { + return nil, fmt.Errorf("list peer links: %w", err) + } + out := make(map[string]PeerLink, len(rows)) + for _, r := range rows { + out[r.PeerID] = r + } + return out, nil +} + +// SavePeerLink records a peer's link, replacing what was there. +func (s *Store) SavePeerLink(l PeerLink) error { + _, err := s.db.Exec(`INSERT INTO peer_links (peer_id, bytes_per_sec, measured_ms, probed_ms) VALUES (?, ?, ?, ?) + ON CONFLICT(peer_id) DO UPDATE SET bytes_per_sec = excluded.bytes_per_sec, + measured_ms = excluded.measured_ms, probed_ms = excluded.probed_ms`, + l.PeerID, l.BytesPerSec, l.MeasuredMs, l.ProbedMs) + if err != nil { + return fmt.Errorf("save peer link %s: %w", l.PeerID, err) + } + return nil +} From 0ce397a963b990bd3b17fc4ca11e82b03bce7d34 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:07:29 +0200 Subject: [PATCH 28/75] sync: an unmeasured link is speed-tested again soon; "older version" said once A speed test that failed - a peer not yet on a build that answers one - counted as done for six hours, so every link stayed unmeasured and the order fell back to address guesses all that time. A link never measured is now tested again ten minutes after a failed test. "<peer> has an older version - asking it to take this one" was logged on every pass while the peer was still taking it, every 20 seconds during a long pull. Logged once per state now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/syncengine/linkspeed.go | 14 ++++++- internal/p2p/syncengine/linkspeed_test.go | 47 +++++++++++++++++++++++ internal/p2p/syncengine/version.go | 5 ++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/internal/p2p/syncengine/linkspeed.go b/internal/p2p/syncengine/linkspeed.go index b19543b1..179e2eb9 100644 --- a/internal/p2p/syncengine/linkspeed.go +++ b/internal/p2p/syncengine/linkspeed.go @@ -44,6 +44,8 @@ const ( // measured it since; probeBytes how much a test moves. probeEvery = 6 * time.Hour probeBytes = 2 << 20 + // probeRetry is how soon a speed test that failed is tried again. + probeRetry = 10 * time.Minute ) // prior rates, by address kind, used until a link is measured. @@ -177,7 +179,13 @@ func (e *Engine) ProbeLinkIfDue(ctx context.Context, peer Peer) bool { e.loadLinksLocked() l := e.links[peer.ID] now := time.Now() - due := now.Sub(time.UnixMilli(l.MeasuredMs)) > probeEvery && now.Sub(time.UnixMilli(l.ProbedMs)) > probeEvery + // A link never measured is tried again soon after a test that failed; + // one measured before only when that has gone stale. + retryAfter := probeEvery + if l.BytesPerSec == 0 { + retryAfter = probeRetry + } + due := now.Sub(time.UnixMilli(l.MeasuredMs)) > probeEvery && now.Sub(time.UnixMilli(l.ProbedMs)) > retryAfter if due { l.PeerID = peer.ID l.ProbedMs = now.UnixMilli() @@ -194,6 +202,10 @@ func (e *Engine) ProbeLinkIfDue(ctx context.Context, peer Peer) bool { defer cancel() n, d, err := prober.ProbeSpeed(ctx, peer, probeBytes) if err != nil { + // A link never measured is tried again after probeRetry (see due + // above): the peer may simply not have been ready — or not yet on a + // build that answers a speed test, which once left every link + // unmeasured for hours. e.Log("info", fmt.Sprintf("speed test with %s did not finish: %v", peer.Name, err)) return true } diff --git a/internal/p2p/syncengine/linkspeed_test.go b/internal/p2p/syncengine/linkspeed_test.go index 3e8544dc..c87657ab 100644 --- a/internal/p2p/syncengine/linkspeed_test.go +++ b/internal/p2p/syncengine/linkspeed_test.go @@ -2,6 +2,7 @@ package syncengine import ( "context" + "errors" "testing" "time" ) @@ -97,6 +98,52 @@ func TestWaitForClose(t *testing.T) { } } +type probeTransport struct { + *fakeTransport + fail bool + calls int +} + +func (p *probeTransport) ProbeSpeed(ctx context.Context, peer Peer, n int) (int64, time.Duration, error) { + p.calls++ + if p.fail { + return 0, 0, errors.New("peer returned 404") + } + return int64(n), time.Second, nil +} + +// A speed test that fails — a peer not yet updated to answer one — is tried +// again after minutes, not hours; a measured link is not tested again soon. +func TestProbeLink_RetriesAFailureSoon(t *testing.T) { + env := setupEngine(t) + pt := &probeTransport{fakeTransport: env.transport, fail: true} + env.engine.Transport = pt + p := Peer{ID: "p", Name: "P", Address: "192.168.1.9"} + + if !env.engine.ProbeLinkIfDue(context.Background(), p) { + t.Fatal("an unmeasured link was not tested") + } + if env.engine.ProbeLinkIfDue(context.Background(), p) { + t.Fatal("tested again straight after a failure") + } + // Eleven minutes later. + env.engine.linkMu.Lock() + l := env.engine.links[p.ID] + l.ProbedMs = time.Now().Add(-11 * time.Minute).UnixMilli() + env.engine.links[p.ID] = l + env.engine.linkMu.Unlock() + pt.fail = false + if !env.engine.ProbeLinkIfDue(context.Background(), p) { + t.Fatal("a failed test was not tried again after ten minutes") + } + if env.engine.Link(p).BytesPerSec == 0 { + t.Fatal("the successful test was not recorded") + } + if env.engine.ProbeLinkIfDue(context.Background(), p) { + t.Error("a freshly measured link was tested again") + } +} + func ids(ps []Peer) []string { out := make([]string, len(ps)) for i, p := range ps { diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index 2b32ab1a..b42d5fa9 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -549,8 +549,9 @@ func (e *Engine) syncByVersion(ctx context.Context, game store.Game, peer Peer, return Result{Status: versionStatusWaiting, PeerID: peer.ID, PeerName: peer.Name}, true, nil } pushTo := func() (Result, bool, error) { - e.forgetLogOnce(gameID + "|" + peer.ID) - e.Log("info", fmt.Sprintf("%q: %s has an older version (%s, this device %s) — asking it to take this one", + // Once per state: a peer still taking it is asked again on every + // pass until it has, and that is not news each time. + e.logOnce(gameID+"|"+peer.ID, fmt.Sprintf("%q: %s has an older version (%s, this device %s) — asking it to take this one", game.Name, peer.Name, theirs.Vector, mine.Vector)) e.Transport.TriggerPeerPull(peer, gameID) return Result{Status: "triggered_peer_pull", Direction: "push", PeerID: peer.ID, PeerName: peer.Name}, true, nil From f3f0b6b7f708bca0b8a4e195cd6cd6e2005fd5e7 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:31:09 +0200 Subject: [PATCH 29/75] sync: a long pull measures its link every 30 seconds, not only at its end A pull of a large save over a slow link takes an hour, and the link's speed was recorded once, when it ended: all that time the device list said "not measured" and the sync order fell back to address guesses. The link is now measured over each 30-second window while the pull runs, and the rest when it ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/syncengine/engine.go | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 4a37bc8d..81b8f3f2 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -1660,13 +1660,24 @@ func (e *Engine) pullFiles(ctx context.Context, peer Peer, gameID string, game s tracker := newProgressTracker(totalBytes) throttle := e.throttleFor(peer.Wan()) - // What this transfer says about the connection (linkspeed.go), measured - // however it ends: a pull cut off half-way still moved what it moved. - pullStarted := time.Now() - defer func() { + // What this transfer says about the connection (linkspeed.go): every + // measureWindow while it runs — a pull of a large save takes an hour over a + // slow link, and one measurement at its end left the link unmeasured all + // that time — and what is left when it ends, however it ends: a pull cut + // off half-way still moved what it moved. + const measureWindow = 30 * time.Second + var measureMu sync.Mutex + measuredAt, measuredBytes := time.Now(), int64(0) + measure := func(final bool) { moved, _, _ := tracker.stats() - e.noteLinkRate(peer, moved, time.Since(pullStarted)) - }() + measureMu.Lock() + defer measureMu.Unlock() + if d := time.Since(measuredAt); final || d >= measureWindow { + e.noteLinkRate(peer, moved-measuredBytes, d) + measuredAt, measuredBytes = time.Now(), moved + } + } + defer measure(true) // Progress reporter shared by the per-file loop and the block-group // loop inside each file. Without in-file reporting, a single large @@ -1683,6 +1694,7 @@ func (e *Engine) pullFiles(ctx context.Context, peer Peer, gameID string, game s } lastReport = time.Now() reportMu.Unlock() + measure(false) bytesPulled, speed, pct := tracker.stats() ev := ProgressEvent{PeerName: peer.Name, BytesTransferred: bytesPulled, TotalBytes: totalBytes, SpeedBytesPerSec: speed, Percentage: pct} From 0ad611f6ab0e839d1e00e7eca8db9921f269c0e3 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:41:16 +0200 Subject: [PATCH 30/75] sync: two old saves with no file in common are asked about, not replaced A save kept as a single file is keyed by its name, so the same save named differently on two devices (an emulator's <profile>.sav) shares no path with the other. The rule settling saves from before versions by their most recent work then made the newer one the save, and the other device would have removed its own file for one with a different name. Saves with no file in common are not two versions of the same files: they are asked about. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/syncengine/version.go | 15 +++++++++++++++ internal/p2p/syncengine/version_test.go | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index b42d5fa9..f17cfbe4 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -883,6 +883,12 @@ func transitionNewer(local, remote delta.Manifest, agreed string) int { return 1 } } + // Two saves with no file in common are not two versions of the same + // files — a save kept as one file named differently on each device, say + // — and nothing about their times says one should replace the other. + if len(local.Files) > 0 && len(remote.Files) > 0 && !shareAPath(local, remote) { + return 0 + } l, r := newerInEveryFile(local, remote), newerInEveryFile(remote, local) switch { case l && !r: @@ -953,6 +959,15 @@ func newestWork(a, b delta.Manifest) int { return 0 } +func shareAPath(a, b delta.Manifest) bool { + for p := range a.Files { + if _, ok := b.Files[p]; ok { + return true + } + } + return false +} + // newerInEveryFile reports whether a is newer than b wherever they differ, // and they do differ. func newerInEveryFile(a, b delta.Manifest) bool { diff --git a/internal/p2p/syncengine/version_test.go b/internal/p2p/syncengine/version_test.go index 1d93d203..7e3215b1 100644 --- a/internal/p2p/syncengine/version_test.go +++ b/internal/p2p/syncengine/version_test.go @@ -129,6 +129,27 @@ func TestVersions_TransitionTakesTheMostRecentWorkWithoutAsking(t *testing.T) { } } +// Two saves with no file in common — one file, named differently on each +// device — are not two versions of the same files: asked about, never one +// replacing the other because it is newer. +func TestVersions_TransitionAsksWhenNoFileIsShared(t *testing.T) { + _, n := newMesh(t, "a", "b") + a, b := n["a"], n["b"] + write(t, a.dir, "alice.sav", "A's world") + write(t, b.dir, "bob.sav", "B's world") + setTimes(t, a.dir, "alice.sav", time.Now().Add(-time.Hour)) + setTimes(t, b.dir, "bob.sav", time.Now().Add(-48*time.Hour)) + bBefore := b.files(t) + for _, pair := range [][2]*meshNode{{a, b}, {b, a}} { + if res, err := syncPair(t, pair[0], pair[1]); err != nil || res.Status != "conflict" { + t.Fatalf("%s↔%s: %+v, %v — want a question", pair[0].name, pair[1].name, res, err) + } + } + if !sameSave(b.files(t), bBefore) { + t.Error("b's save was replaced by one with no file in common") + } +} + // Only an exact tie — the same latest time, newer in as many files — is asked // about: there is nothing to tell the two apart. func TestVersions_TransitionAsksOnlyOnAnExactTie(t *testing.T) { From 526b9912d92cbdba8f5e013fb249a2453c23cfd9 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:50:43 +0200 Subject: [PATCH 31/75] sync: a fast link's speed test is kept, not discarded as too short Over a home network the 2 MB speed test is over in a fraction of a second, and the rule that ignores transfers under half a second - meant for syncs, whose small transfers say more about round trips than about the link - threw it away: the fastest link stayed unmeasured, and with nothing measured it sorted behind the measured slow ones. A test that quick is now run again at 8 MB, and a test counts however short it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/syncengine/linkspeed.go | 16 ++++++++++++++-- internal/p2p/syncengine/linkspeed_test.go | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/internal/p2p/syncengine/linkspeed.go b/internal/p2p/syncengine/linkspeed.go index 179e2eb9..21af61f7 100644 --- a/internal/p2p/syncengine/linkspeed.go +++ b/internal/p2p/syncengine/linkspeed.go @@ -125,7 +125,12 @@ func (e *Engine) expectedRate(peer Peer) float64 { // noteLinkRate records a transfer with a peer: bytes in d. func (e *Engine) noteLinkRate(peer Peer, bytes int64, d time.Duration) { - if bytes < minMeasureBytes || d < minMeasureTime { + e.recordLinkRate(peer, bytes, d, minMeasureTime) +} + +// recordLinkRate records bytes in d, when d is at least minTime. +func (e *Engine) recordLinkRate(peer Peer, bytes int64, d, minTime time.Duration) { + if bytes < minMeasureBytes || d < minTime || d <= 0 { return } rate := float64(bytes) / d.Seconds() @@ -201,6 +206,11 @@ func (e *Engine) ProbeLinkIfDue(ctx context.Context, peer Peer) bool { ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() n, d, err := prober.ProbeSpeed(ctx, peer, probeBytes) + if err == nil && d < minMeasureTime { + // Over a home network the test is over before it measures much: + // once more, larger, for a figure worth having. + n, d, err = prober.ProbeSpeed(ctx, peer, 4*probeBytes) + } if err != nil { // A link never measured is tried again after probeRetry (see due // above): the peer may simply not have been ready — or not yet on a @@ -209,7 +219,9 @@ func (e *Engine) ProbeLinkIfDue(ctx context.Context, peer Peer) bool { e.Log("info", fmt.Sprintf("speed test with %s did not finish: %v", peer.Name, err)) return true } - e.noteLinkRate(peer, n, d) + // A test is a measurement however short: it moved nothing but the test, + // so no round trips of a sync's own are in it. + e.recordLinkRate(peer, n, d, time.Millisecond) e.Log("info", fmt.Sprintf("connection to %s: %s/s (%s)", peer.Name, humanBytes(int64(e.Link(peer).BytesPerSec)), linkKind(peer))) return true } diff --git a/internal/p2p/syncengine/linkspeed_test.go b/internal/p2p/syncengine/linkspeed_test.go index c87657ab..968dcc87 100644 --- a/internal/p2p/syncengine/linkspeed_test.go +++ b/internal/p2p/syncengine/linkspeed_test.go @@ -102,6 +102,8 @@ type probeTransport struct { *fakeTransport fail bool calls int + // perMB is how long each MB takes; a second when unset. + perMB time.Duration } func (p *probeTransport) ProbeSpeed(ctx context.Context, peer Peer, n int) (int64, time.Duration, error) { @@ -109,6 +111,9 @@ func (p *probeTransport) ProbeSpeed(ctx context.Context, peer Peer, n int) (int6 if p.fail { return 0, 0, errors.New("peer returned 404") } + if p.perMB > 0 { + return int64(n), time.Duration(n>>20) * p.perMB, nil + } return int64(n), time.Second, nil } @@ -144,6 +149,23 @@ func TestProbeLink_RetriesAFailureSoon(t *testing.T) { } } +// A home network finishes the test before it has measured much: it is run +// again larger, and the result is kept — not discarded as too short, which +// left the fastest link unmeasured and sorted behind the slow ones. +func TestProbeLink_AFastLinkIsMeasured(t *testing.T) { + env := setupEngine(t) + pt := &probeTransport{fakeTransport: env.transport, perMB: 20 * time.Millisecond} + env.engine.Transport = pt + p := Peer{ID: "lan", Name: "LAN", Address: "192.168.178.20"} + env.engine.ProbeLinkIfDue(context.Background(), p) + if got := env.engine.Link(p).BytesPerSec; got < 20<<20 { + t.Errorf("a 50 MB/s link measured as %.0f B/s", got) + } + if pt.calls != 2 { + t.Errorf("%d test(s), want the short one and a larger one", pt.calls) + } +} + func ids(ps []Peer) []string { out := make([]string, len(ps)) for i, p := range ps { From c2cd2f981faf3f1b11e8d6cca07e9d2ec8897b62 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:44:43 +0200 Subject: [PATCH 32/75] snapshots: never of a save a sync is writing, never incomplete A pull of a save of many files overflowed the watcher's event queue again and again, and each overflow was taken for the game saving: an automatic snapshot of a half-pulled save every few seconds - 76 on one device for one pull - which pushed the complete ones out of retention. - The watcher asks whether a sync is writing the game (BeingWritten: now, or in the last ten seconds). If so, an overflow is taken for the sync's, and the burst is looked at only once the sync has finished. - A snapshot that could not read every file is discarded, not kept: it would stand in the history as a save that never existed. The watcher leaves the change unrecorded and takes it again at its next pass; a sync that needed the copy before replacing files does not replace them. - OpenSave's own temporary files (*.opensave.tmp) are never archived. - A sync that finds both sides already identical records the peer's latest snapshot only for a game with no history here at all. It recorded one per peer: a full copy of the same save for every device, gigabytes for a save of a quarter-million files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/daemon/daemon.go | 3 +++ internal/p2p/syncengine/engine.go | 21 ++++++++++++++++++++- internal/p2p/syncengine/quick.go | 2 +- internal/p2p/syncengine/settle.go | 23 +++++++++++++++++++++++ internal/snapshot/snapshot.go | 22 +++++++++++++++++++--- internal/snapshot/zip.go | 5 +++++ internal/snapshot/zip_roots.go | 4 ++++ internal/watcher/owntouch_test.go | 29 +++++++++++++++++++++++++++++ internal/watcher/watcher.go | 27 ++++++++++++++++++++++++++- 9 files changed, 130 insertions(+), 6 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 4e2ffcf0..3cb23f00 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -232,6 +232,9 @@ func New(opts Options) (*Daemon, error) { _, err := snaps.Create(gameID, "", true) return err }, + SyncWriting: func(gameID string) bool { + return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.BeingWritten(gameID) + }, OnChanged: func(gameID string) { // A change made here, by the game or the user: a new version of // the save, recorded before anyone is offered it. diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 81b8f3f2..7c5e8f63 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -131,6 +131,8 @@ type Engine struct { // on this device right now (settle.go). settleMu sync.Mutex gates map[string]*saveGate + // writtenAt is when a sync last stopped writing each game (BeingWritten). + writtenAt map[string]time.Time // servedMu guards served: the save states this device recently handed // each peer, per game (served.go). servedMu sync.Mutex @@ -781,7 +783,7 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // // Last, after the other locations have synced: it zips every location, // and taking that time before them delayed their sync for nothing. - if remoteData.LatestSnapshot != nil && len(localManifest.Files) > 0 { + if remoteData.LatestSnapshot != nil && len(localManifest.Files) > 0 && !e.hasSnapshot(gameID, game) { e.recordMirrorSnapshot(gameID, game, peer, *remoteData.LatestSnapshot, fmt.Sprintf("Synced from peer: %s (%s)", peer.Name, remoteData.LatestSnapshot.Comment)) } @@ -1994,6 +1996,23 @@ func fetchWithRetry(ctx context.Context, t Transport, peer Peer, ref FileRef, return nil, lastErr } +// hasSnapshot reports whether the game has any snapshot on its active branch +// here. +// +// A sync that finds both sides already identical records the peer's latest +// snapshot only for a game with no history at all — saves identical from the +// start (Steam Cloud, a copied folder) would otherwise never get one. It used +// to record it for every peer it compared with: a full copy of the same save +// per device, again with each new snapshot over there, which for a save of a +// quarter-million files was gigabytes of identical archives. +func (e *Engine) hasSnapshot(gameID string, game store.Game) bool { + if e.Snapshots == nil { + return false + } + _, err := e.Snapshots.LatestSnapshot(gameID, game.ActiveBranch) + return err == nil +} + // recordMirrorSnapshot zips the (just-updated) local save under the peer's // snapshot id so both devices show the same history entry. func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer, remoteSnap SnapshotInfo, comment string) { diff --git a/internal/p2p/syncengine/quick.go b/internal/p2p/syncengine/quick.go index 74f5b5f2..cf0c3906 100644 --- a/internal/p2p/syncengine/quick.go +++ b/internal/p2p/syncengine/quick.go @@ -75,7 +75,7 @@ func (e *Engine) quickInSync(ctx context.Context, gameID string, game store.Game "peerName": e.deviceName(), "manifestHash": agreed, }) - if resp.LatestSnapshot != nil && len(local.Files) > 0 { + if resp.LatestSnapshot != nil && len(local.Files) > 0 && !e.hasSnapshot(gameID, game) { e.recordMirrorSnapshot(gameID, game, peer, *resp.LatestSnapshot, fmt.Sprintf("Synced from peer: %s (%s)", peer.Name, resp.LatestSnapshot.Comment)) } diff --git a/internal/p2p/syncengine/settle.go b/internal/p2p/syncengine/settle.go index 34942e10..0e6a732a 100644 --- a/internal/p2p/syncengine/settle.go +++ b/internal/p2p/syncengine/settle.go @@ -131,10 +131,33 @@ func (e *Engine) Writing(gameID string) (done func()) { once = true g := e.gateLocked(gameID) g.writers-- + if e.writtenAt == nil { + e.writtenAt = map[string]time.Time{} + } + e.writtenAt[gameID] = time.Now() e.wakeLocked(gameID, g) } } +// writeEcho is how long after a sync stops writing a game its file events may +// still be arriving. +const writeEcho = 10 * time.Second + +// BeingWritten reports whether a sync on this device is writing a game's save +// now, or was a moment ago — what the watcher asks before taking a burst it +// could not attribute (an overflow of file events) for a change of the game's. +// A pull of a save of many files overflows the event queue again and again, +// and each one read as the game saving: an automatic snapshot of a half-pulled +// save every few seconds, which pushed the real ones out of retention. +func (e *Engine) BeingWritten(gameID string) bool { + e.settleMu.Lock() + defer e.settleMu.Unlock() + if g := e.gates[gameID]; g != nil && (g.writers > 0 || g.waitingWriters > 0) { + return true + } + return time.Since(e.writtenAt[gameID]) < writeEcho +} + // Reading holds the game's save still for a sync to read it: it waits until // nothing is writing it, then keeps writers out until done is called. It // returns ErrSettling if ctx ends first. Hold it only for the reading itself — diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index f483bde4..f595fd90 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -41,6 +41,18 @@ import ( // before starting one. type UploadHook func(zipPath, remoteFileName string) +// IncompleteError is a snapshot discarded because files of the save could not +// be read — in use by the game, or changing while it was archived. +type IncompleteError struct { + Game string + Skipped []string +} + +func (e *IncompleteError) Error() string { + return fmt.Sprintf("snapshot of %q discarded: %d file(s) could not be read (e.g. %s) — taken again at the next chance", + e.Game, len(e.Skipped), e.Skipped[0]) +} + // Manager performs snapshot/branch operations against the store and // filesystem. type Manager struct { @@ -249,9 +261,13 @@ func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSys return store.Snapshot{}, fmt.Errorf("zip save data: %w", err) } defer os.Remove(stagingPath) // no-op once renamed - if len(skipped) > 0 && m.Log != nil { - sample := skipped[0] - m.Log("warn", fmt.Sprintf("snapshot of %q skipped %d unreadable file(s), e.g. %s", game.Name, len(skipped), sample)) + // A snapshot missing files is not kept: it would stand in the history as + // a save that never existed — and, counted against retention, push out a + // complete one. The caller tries again at the next chance (the watcher + // leaves the change unrecorded, so its next pass takes it again; a sync + // that needed the copy before replacing files does not replace them). + if len(skipped) > 0 { + return store.Snapshot{}, &IncompleteError{Game: game.Name, Skipped: skipped} } if comment == "" { diff --git a/internal/snapshot/zip.go b/internal/snapshot/zip.go index 803c60a8..804f7cf8 100644 --- a/internal/snapshot/zip.go +++ b/internal/snapshot/zip.go @@ -95,6 +95,11 @@ func zipPathCapturing(sourcePath, outPath string, reuse *reuseSource) (skipped [ } return nil } + // OpenSave's own temporary file for a file being pulled: never part + // of the save, and gone by the time it is read. + if strings.HasSuffix(rel, delta.TmpSuffix) { + return nil + } info, infoErr := d.Info() if infoErr != nil { skipped = append(skipped, path) diff --git a/internal/snapshot/zip_roots.go b/internal/snapshot/zip_roots.go index c3618b6c..fac78559 100644 --- a/internal/snapshot/zip_roots.go +++ b/internal/snapshot/zip_roots.go @@ -144,6 +144,10 @@ func archiveInto(w *zip.Writer, sourcePath, prefix, root string, reuse *reuseSou _, err := w.CreateHeader(&zip.FileHeader{Name: prefix + rel + "/", Method: zip.Store}) return err } + // OpenSave's own temporary file for a file being pulled (zip.go). + if strings.HasSuffix(rel, delta.TmpSuffix) { + return nil + } info, infoErr := d.Info() if infoErr != nil { skipped = append(skipped, path) diff --git a/internal/watcher/owntouch_test.go b/internal/watcher/owntouch_test.go index 6a1e8134..2e4e241f 100644 --- a/internal/watcher/owntouch_test.go +++ b/internal/watcher/owntouch_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sync/atomic" "testing" "time" @@ -103,6 +104,34 @@ func TestOwnNewFolders_AreNotSnapshotted(t *testing.T) { } } +// While a sync is writing the game, the watcher looks at what changed only +// once it has finished — never at a save half-way between two states — and a +// change of the game's is still snapshotted then. +func TestWhileASyncWrites_TheWatcherWaits(t *testing.T) { + saveDir := t.TempDir() + col := newCollector() + var writing atomic.Bool + cb := col.callbacks() + cb.SyncWriting = func(string) bool { return writing.Load() } + eng := New(cb) + defer eng.Stop() + if err := eng.Watch("game1", saveDir); err != nil { + t.Fatal(err) + } + writing.Store(true) + if err := os.WriteFile(filepath.Join(saveDir, "slot1.sav"), []byte("played"), 0o666); err != nil { + t.Fatal(err) + } + time.Sleep(5 * time.Second) + if n := col.snapshotCount(); n != 0 { + t.Fatalf("%d snapshot(s) while a sync was writing the game", n) + } + writing.Store(false) + if !waitFor(t, 10*time.Second, func() bool { return col.snapshotCount() >= 1 }) { + t.Fatal("the game's change was not snapshotted once the sync had finished") + } +} + // A burst that mixes OpenSave's own changes with one from the game is a real // change and is snapshotted. func TestMixedChanges_AreSnapshotted(t *testing.T) { diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 3c9b7ee9..da7da332 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -119,6 +119,16 @@ type Callbacks struct { OnChanged func(gameID string) // Log receives human-readable watcher activity. May be nil. Log func(level, msg string) + // SyncWriting reports whether a sync on this device is writing the game + // now, or was a moment ago. While it is, a burst the watcher cannot + // attribute — an overflow of file events — is taken for the sync's, and + // the burst is looked at once the sync has finished. May be nil. + SyncWriting func(gameID string) bool +} + +// syncWriting reports whether a sync is writing a game (Callbacks.SyncWriting). +func (e *Engine) syncWriting(gameID string) bool { + return e.cb.SyncWriting != nil && e.cb.SyncWriting(gameID) } // Engine owns one watch goroutine per tracked game. @@ -751,7 +761,16 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { // directory and allocates no second buffer for one it already // has. gw.rewatch.Store(true) - gw.otherEvents = true // the missed events could be anyone's + // The missed events could be anyone's — unless a sync is + // writing this game, which is what overflows the queue: a + // pull of a save of many files does, again and again, and + // each was taken for the game saving, an automatic snapshot + // of a half-pulled save every few seconds. + if e.syncWriting(gw.gameID) { + gw.ownEvents = true + } else { + gw.otherEvents = true + } } else { e.log("warn", fmt.Sprintf("watching %q: %v", gw.gameID, err)) continue @@ -761,6 +780,12 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { case <-debounceC: debounce = nil debounceC = nil + // A sync still writing this game: look at the burst once it has + // finished, not at a save half-way between two states. + if e.syncWriting(gw.gameID) { + resetDebounce() + continue + } // Re-register, so a folder that went unwatched is watched from // here on, and read the tree below, so it is found now. Doing only // one of those leaves it correct today and silent tomorrow. From 6357bcb111b775f785ed5c98940c06f090b0ac62 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:02:45 +0200 Subject: [PATCH 33/75] snapshots: named by their content, and kept once A snapshot is now named by what it holds: a hash over every file's name in the archive and its content hash - the hashes the sync compares, so the same save gives the same value on every device (snapshot.ContentKey, recorded in snapshots.content_hash). - A snapshot taken for a sync with a peer is not archived when this device already has a snapshot of exactly those files: the peer sends what its snapshot holds (SnapshotInfo.ContentHash), and its id is recorded as an alias of the one here (snapshot_aliases), so it is not asked about again. Without that from an older peer, the archive is made, named, and dropped if it duplicates one. - A copy taken before a sync replaces the save is the snapshot that already holds those files, when nothing automatic would remove that one first (pinned, taken by hand, or the branch's newest). - Snapshots that already exist are named and merged (at start, two minutes in, and POST /api/snapshots/merge-duplicates): of copies of the same files on a branch, a pinned one is kept, else one taken by hand, else the newest; pinned ones are never removed; the others' ids become aliases. - A peer's snapshot that could not read every file is discarded, like any other incomplete snapshot. One save of 780 MB had been archived six times on one device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/api/routes.go | 9 + internal/daemon/daemon.go | 31 ++++ internal/p2p/routes.go | 2 +- internal/p2p/syncengine/engine.go | 38 +++- internal/p2p/syncengine/transport.go | 4 + internal/p2p/wanclient_handlers.go | 2 +- internal/snapshot/content.go | 174 ++++++++++++++++++ internal/snapshot/content_test.go | 119 ++++++++++++ internal/snapshot/shared_test.go | 4 +- internal/snapshot/snapshot.go | 19 ++ .../migrations/0039_snapshot_content.sql | 16 ++ internal/store/snapshot_content.go | 92 +++++++++ internal/store/snapshots.go | 7 +- 13 files changed, 507 insertions(+), 10 deletions(-) create mode 100644 internal/snapshot/content.go create mode 100644 internal/snapshot/content_test.go create mode 100644 internal/store/migrations/0039_snapshot_content.sql create mode 100644 internal/store/snapshot_content.go diff --git a/internal/api/routes.go b/internal/api/routes.go index fbac46fe..1c0a6149 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -58,6 +58,7 @@ func (s *Server) routes(r chi.Router) { r.Patch("/api/games/{gameId}/snapshot/{snapshotId}", s.handleEditSnapshot) r.Get("/api/games/{gameId}/sessions", s.handleGameSessions) r.Get("/api/snapshots/check", s.handleSnapshotChecks) + r.Post("/api/snapshots/merge-duplicates", s.handleMergeDuplicates) r.Get("/api/games/{gameId}/snapshot/{snapshotId}/compare/{otherId}", s.handleCompareSnapshots) r.Post("/api/snapshots/check", s.handleVerifySnapshots) r.Post("/api/games/{gameId}/session", s.handleMarkSession) @@ -639,6 +640,14 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, snap) } +// handleMergeDuplicates keeps every game's identical snapshots once, now +// rather than at the next start (daemon.MergeSnapshotDuplicates). +func (s *Server) handleMergeDuplicates(w http.ResponseWriter, r *http.Request) { + merged, freed := s.Daemon.MergeSnapshotDuplicates() + s.BroadcastGamesUpdate() + writeJSON(w, http.StatusOK, map[string]any{"merged": merged, "freedBytes": freed}) +} + // handleUseSaveEverywhere makes this device's save the one every other device // takes (syncengine.UseSaveEverywhere), and starts a sync to hand it out. The // sync runs in the background: a large save takes a while, and the answer the diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 3cb23f00..523a1bb6 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -83,6 +83,9 @@ type Daemon struct { newGames newGameState // scanMu runs one save scan at a time. See ScanForSaves. scanMu sync.Mutex + // mergeMu runs one merge of identical snapshots at a time + // (MergeSnapshotDuplicates). + mergeMu sync.Mutex // uploads counts cloud mirrors still running, so Stop can wait for them // rather than letting process exit truncate one. @@ -275,6 +278,10 @@ func (d *Daemon) Start() error { if err != nil { return err } + // Snapshots that hold the same files are kept once (snapshot/content.go). + // In the background, once things have settled: naming a snapshot taken + // before contents were recorded reads its whole archive. + time.AfterFunc(2*time.Minute, func() { d.MergeSnapshotDuplicates() }) // Switch games tracked under a made-up name get their real one, when an // emulator here knows it by now. d.nameSwitchGames() @@ -994,6 +1001,30 @@ func (d *Daemon) checkSavePathShape(abs string) error { return nil } +// MergeSnapshotDuplicates keeps every game's identical snapshots once +// (snapshot.Manager.MergeDuplicates), one game at a time. Returns how many +// were merged and the bytes freed. +func (d *Daemon) MergeSnapshotDuplicates() (merged int, freed int64) { + d.mergeMu.Lock() + defer d.mergeMu.Unlock() + games, err := d.Store.ListGames() + if err != nil { + return 0, 0 + } + for _, g := range games { + n, f, err := d.Snapshots.MergeDuplicates(g.ID) + if err != nil { + continue + } + merged += n + freed += f + } + if merged > 0 { + d.Log.Log("info", fmt.Sprintf("kept identical snapshots once: %d merged, %d MB freed", merged, freed>>20)) + } + return merged, freed +} + // EnsureImportedSnapshot registers a snapshot restored from an .sscb // backup: creates the branch if missing and inserts the metadata row // unless that snapshot id is already known (idempotent re-imports). diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index 5c03e6d9..471377b1 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -758,7 +758,7 @@ func (e *Engine) handleManifest(w http.ResponseWriter, r *http.Request) { Versions: true, } if latest, err := e.Snapshots.LatestSnapshot(gameID, ""); err == nil { - resp.LatestSnapshot = &syncengine.SnapshotInfo{ID: latest.ID, Timestamp: latest.Timestamp, Comment: latest.Comment} + resp.LatestSnapshot = &syncengine.SnapshotInfo{ID: latest.ID, Timestamp: latest.Timestamp, Comment: latest.Comment, ContentHash: latest.ContentHash} } // The asker already holds the state both devices last agreed on and says // so; if this device still holds it too, "unchanged" is the whole answer. diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 7c5e8f63..0a8d9761 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -2016,8 +2016,17 @@ func (e *Engine) hasSnapshot(gameID string, game store.Game) bool { // recordMirrorSnapshot zips the (just-updated) local save under the peer's // snapshot id so both devices show the same history entry. func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer, remoteSnap SnapshotInfo, comment string) { - if _, err := e.Store.GetSnapshot(remoteSnap.ID); err == nil { - return // already mirrored + if _, known := e.Store.ResolveSnapshotID(remoteSnap.ID); known { + return // already mirrored, or known as the same files as one here + } + // The peer says what its snapshot holds: if a snapshot here holds exactly + // that, it is the same snapshot, and the save is not archived again. + if remoteSnap.ContentHash != "" { + if existing, ok, _ := e.Store.SnapshotByContent(gameID, game.ActiveBranch, remoteSnap.ContentHash); ok && + snapshot.ArchiveExists(existing.ZipPath) { + _ = e.Store.AddSnapshotAlias(remoteSnap.ID, existing.ID) + return + } } settings, err := e.Store.GetSettings() @@ -2042,16 +2051,32 @@ func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer, if rootsErr != nil { mirrorRoots = nil } - if _, err := snapshot.ZipRoots(game.SavePath, mirrorRoots, zipPath); err != nil { + skipped, captured, err := snapshot.ZipRootsCapturing(game.SavePath, mirrorRoots, zipPath) + if err != nil { + os.Remove(zipPath) e.Log("warn", fmt.Sprintf("mirror snapshot zip failed: %v", err)) return } + // Incomplete is not kept (snapshot.IncompleteError); the next sync with + // the peer records it again. + if len(skipped) > 0 { + os.Remove(zipPath) + e.Log("info", fmt.Sprintf("snapshot of %q from %s discarded: %d file(s) could not be read; taken again at the next sync", + game.Name, peer.Name, len(skipped))) + return + } + key := snapshot.ContentKey(captured) + if existing, ok, _ := e.Store.SnapshotByContent(gameID, game.ActiveBranch, key); ok && snapshot.ArchiveExists(existing.ZipPath) { + os.Remove(zipPath) + _ = e.Store.AddSnapshotAlias(remoteSnap.ID, existing.ID) + return + } info, err := os.Stat(zipPath) if err != nil { return } - _ = e.Store.CreateSnapshot(store.Snapshot{ + if err := e.Store.CreateSnapshot(store.Snapshot{ ID: remoteSnap.ID, GameID: gameID, BranchName: game.ActiveBranch, @@ -2060,7 +2085,10 @@ func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer, IsSystemAuto: true, ZipPath: zipPath, SizeBytes: info.Size(), - }) + ContentHash: key, + }); err == nil { + _ = e.Store.RecordSnapshotFiles(remoteSnap.ID, captured) + } } // humanBytes formats a byte count as a short human-readable string. diff --git a/internal/p2p/syncengine/transport.go b/internal/p2p/syncengine/transport.go index 72cb53a5..c2afbf8d 100644 --- a/internal/p2p/syncengine/transport.go +++ b/internal/p2p/syncengine/transport.go @@ -26,6 +26,10 @@ type SnapshotInfo struct { ID string `json:"id"` Timestamp string `json:"timestamp"` Comment string `json:"comment"` + // ContentHash names what the snapshot holds (snapshot.ContentKey): a + // device that already has a snapshot of exactly those files records this + // one's id as an alias of it rather than archiving the save again. + ContentHash string `json:"contentHash,omitempty"` } // ProtoMultiRoot is the protocol revision at which a peer understands games diff --git a/internal/p2p/wanclient_handlers.go b/internal/p2p/wanclient_handlers.go index 00693e8c..448f3894 100644 --- a/internal/p2p/wanclient_handlers.go +++ b/internal/p2p/wanclient_handlers.go @@ -631,7 +631,7 @@ func (w *WanClient) serveManifest(route string, body json.RawMessage, peerID str resp["version"] = v } if latest, err := w.engine.Snapshots.LatestSnapshot(gameID, ""); err == nil { - resp["latestSnapshot"] = syncengine.SnapshotInfo{ID: latest.ID, Timestamp: latest.Timestamp, Comment: latest.Comment} + resp["latestSnapshot"] = syncengine.SnapshotInfo{ID: latest.ID, Timestamp: latest.Timestamp, Comment: latest.Comment, ContentHash: latest.ContentHash} } else { resp["latestSnapshot"] = nil } diff --git a/internal/snapshot/content.go b/internal/snapshot/content.go new file mode 100644 index 00000000..a579f349 --- /dev/null +++ b/internal/snapshot/content.go @@ -0,0 +1,174 @@ +package snapshot + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "sort" + "strings" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/store" +) + +// Snapshot content. +// +// A snapshot is named by what it holds: every file's name in the archive and +// the SHA-256 of its content, hashed together. The file hashes are the ones the +// sync compares (the hash cache both use), so the same save gives the same +// value on every device — which is how a device knows the snapshot another +// device took is one it already has, and keeps the save archived once. +// +// Before this, a save of a quarter-million files was archived again for every +// device it was compared with, and again on every new snapshot over there: +// gigabytes of identical archives for one 780 MB save. + +// contentEntry is one file of a snapshot: its name in the archive and its hash. +type contentEntry struct{ name, hash string } + +func contentKeyOf(entries []contentEntry) string { + sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name }) + h := sha256.New() + for _, e := range entries { + h.Write([]byte(e.name)) + h.Write([]byte{0}) + h.Write([]byte(e.hash)) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// archiveName is the name a captured file has in the archive. +func archiveName(c store.CapturedFile) string { + if c.Root == "" { + return c.Path + } + return RootPrefix + c.Root + "/" + c.Path +} + +// ContentKey names what a snapshot holds, from its recorded files. +func ContentKey(files []store.CapturedFile) string { + entries := make([]contentEntry, 0, len(files)) + for _, f := range files { + entries = append(entries, contentEntry{archiveName(f), f.Hash}) + } + return contentKeyOf(entries) +} + +// ContentKeyOfArchive names what a snapshot holds by reading its archive: for +// a snapshot recorded without its file list (one taken for a sync with a peer). +func ContentKeyOfArchive(zipPath string) (string, error) { + path, done, err := OpenArchive(zipPath) + if err != nil { + return "", err + } + defer done() + r, err := zip.OpenReader(path) + if err != nil { + return "", err + } + defer r.Close() + entries := make([]contentEntry, 0, len(r.File)) + for _, f := range r.File { + if f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/") || strings.HasSuffix(f.Name, delta.TmpSuffix) { + continue + } + rc, err := f.Open() + if err != nil { + return "", fmt.Errorf("%s: %w", f.Name, err) + } + h := sha256.New() + _, err = io.Copy(h, rc) + rc.Close() + if err != nil { + return "", fmt.Errorf("%s: %w", f.Name, err) + } + entries = append(entries, contentEntry{f.Name, hex.EncodeToString(h.Sum(nil))}) + } + return contentKeyOf(entries), nil +} + +// contentHashOf works out what a snapshot holds, from its file list when it +// has one and from its archive otherwise. +func (m *Manager) contentHashOf(snap store.Snapshot) (string, error) { + if files, err := m.Store.SnapshotFiles(snap.ID); err == nil && len(files) > 0 { + return ContentKey(files), nil + } + return ContentKeyOfArchive(snap.ZipPath) +} + +// isNewest reports whether id is the newest snapshot on a game's branch. +func (m *Manager) isNewest(gameID, branch, id string) bool { + snaps, err := m.Store.ListSnapshots(gameID, branch) + return err == nil && len(snaps) > 0 && snaps[0].ID == id +} + +// MergeDuplicates names every snapshot of a game by its content and keeps each +// content archived once per branch: of snapshots holding the same files, one +// keeps its archive — a pinned one, else one taken by hand, else the newest, +// which retention keeps longest — +// and the others are removed, their ids kept as aliases of it so a device that +// knows a snapshot by one of them still finds it. Pinned snapshots are never +// removed. Returns how many were merged and the bytes freed. +func (m *Manager) MergeDuplicates(gameID string) (merged int, freed int64, err error) { + missing, err := m.Store.SnapshotsWithoutContentHash(gameID) + if err != nil { + return 0, 0, err + } + for _, snap := range missing { + if !ArchiveExists(snap.ZipPath) { + continue + } + key, err := m.contentHashOf(snap) + if err != nil { + if m.Log != nil { + m.Log("warn", fmt.Sprintf("could not read snapshot %s to name its content: %v", snap.ID, err)) + } + continue + } + _ = m.Store.SetSnapshotContentHash(snap.ID, key) + } + + snaps, err := m.Store.AllSnapshotsOfGame(gameID) + if err != nil { + return 0, 0, err + } + groups := map[string][]store.Snapshot{} + for _, s := range snaps { + if s.ContentHash == "" { + continue + } + k := s.BranchName + "\x00" + s.ContentHash + groups[k] = append(groups[k], s) + } + for _, group := range groups { + if len(group) < 2 { + continue + } + keep := group[len(group)-1] // newest (oldest first) + for _, s := range group { + if s.Pinned && !keep.Pinned || s.Pinned == keep.Pinned && !s.IsSystemAuto && keep.IsSystemAuto { + keep = s + } + } + for _, s := range group { + if s.ID == keep.ID || s.Pinned { + continue + } + _ = m.Store.RepointSnapshotAliases(s.ID, keep.ID) + f, err := m.DeleteSnapshot(gameID, s.ID) + if err != nil { + continue + } + _ = m.Store.AddSnapshotAlias(s.ID, keep.ID) + merged++ + freed += f + if m.Log != nil { + m.Log("info", fmt.Sprintf("snapshot %s held the same files as %s; kept once", s.ID, keep.ID)) + } + } + } + return merged, freed, nil +} diff --git a/internal/snapshot/content_test.go b/internal/snapshot/content_test.go new file mode 100644 index 00000000..90326dee --- /dev/null +++ b/internal/snapshot/content_test.go @@ -0,0 +1,119 @@ +package snapshot + +import ( + "testing" +) + +// The content a snapshot records and the content read back from its archive +// are named alike — which is what lets a device that only has another's +// archive, or only its file list, tell they hold the same save. +func TestContentKey_FromFilesAndFromArchiveAgree(t *testing.T) { + env := setup(t) + writeSave(t, env.saveDir, "slot1.sav", "one") + writeSave(t, env.saveDir, "sub/slot2.sav", "two") + snap, err := env.mgr.Create("game1", "", true) + if err != nil { + t.Fatal(err) + } + files, err := env.store.SnapshotFiles(snap.ID) + if err != nil || len(files) == 0 { + t.Fatalf("no file list recorded: %v", err) + } + fromArchive, err := ContentKeyOfArchive(snap.ZipPath) + if err != nil { + t.Fatal(err) + } + if got := ContentKey(files); got != fromArchive { + t.Errorf("from the file list %s, from the archive %s", got, fromArchive) + } + stored, _ := env.store.GetSnapshot(snap.ID) + if stored.ContentHash != fromArchive { + t.Errorf("recorded content %q, want %q", stored.ContentHash, fromArchive) + } +} + +// A copy taken before a sync replaces a save already archived is that archive, +// not a second one. +func TestBeforeReplacing_AnArchivedSaveIsNotArchivedAgain(t *testing.T) { + env := setup(t) + writeSave(t, env.saveDir, "slot1.sav", "progress") + first, err := env.mgr.Create("game1", "", true) + if err != nil { + t.Fatal(err) + } + copy, err := env.mgr.CreateBeforeReplacing("game1", "before sync") + if err != nil { + t.Fatal(err) + } + if copy.ID != first.ID { + t.Errorf("a second archive %s of the save %s already holds", copy.ID, first.ID) + } + snaps, _ := env.store.ListSnapshots("game1", "main") + if len(snaps) != 1 { + t.Errorf("%d snapshots, want 1", len(snaps)) + } +} + +// Several snapshots of the same files — one per device the save was compared +// with — are kept once: the newest unless one is pinned or taken by hand; +// pinned ones are never removed; the others' ids still lead to it. +func TestMergeDuplicates(t *testing.T) { + env := setup(t) + // Room for every snapshot below: this is about merging, not retention. + game, err := env.store.GetGame("game1") + if err != nil { + t.Fatal(err) + } + game.MaxSnapshots = 50 + if err := env.store.UpdateGame(game); err != nil { + t.Fatal(err) + } + writeSave(t, env.saveDir, "slot1.sav", "the save") + var same []string + for i := 0; i < 3; i++ { + s, err := env.mgr.Create("game1", "", true) + if err != nil { + t.Fatal(err) + } + same = append(same, s.ID) + } + pinned, err := env.mgr.Create("game1", "pinned copy", true) + if err != nil { + t.Fatal(err) + } + if err := env.store.SetSnapshotPinned(pinned.ID, true); err != nil { + t.Fatal(err) + } + writeSave(t, env.saveDir, "slot1.sav", "played on") + other, err := env.mgr.Create("game1", "", true) + if err != nil { + t.Fatal(err) + } + // As if taken before contents were recorded. + for _, id := range append(same, pinned.ID, other.ID) { + _ = env.store.SetSnapshotContentHash(id, "") + } + + merged, _, err := env.mgr.MergeDuplicates("game1") + if err != nil { + t.Fatal(err) + } + if merged != 3 { + t.Errorf("merged %d, want the 3 unpinned copies of the pinned one", merged) + } + if _, err := env.store.GetSnapshot(pinned.ID); err != nil { + t.Error("the pinned snapshot was removed") + } + if _, err := env.store.GetSnapshot(other.ID); err != nil { + t.Error("a snapshot of different content was removed") + } + for _, id := range same { + if got, ok := env.store.ResolveSnapshotID(id); !ok || got != pinned.ID { + t.Errorf("%s leads to %q (%v), want the snapshot kept, %s", id, got, ok, pinned.ID) + } + } + // Run again: nothing more to do. + if again, _, _ := env.mgr.MergeDuplicates("game1"); again != 0 { + t.Errorf("a second pass merged %d more", again) + } +} diff --git a/internal/snapshot/shared_test.go b/internal/snapshot/shared_test.go index f125591e..73049548 100644 --- a/internal/snapshot/shared_test.go +++ b/internal/snapshot/shared_test.go @@ -558,7 +558,9 @@ func TestSwitchingToABranchWhoseNewestIsCompacted(t *testing.T) { env := setup(t) snaps, originals := threeSnapshots(t, env) // Over to another branch: the switch keeps the save on main first, and - // that copy is main's newest. + // that copy is main's newest. Changed first, or the copy would hold the + // same files as the third snapshot, and be that snapshot (content.go). + writeSave(t, env.saveDir, "slot_switch.sav", "before the switch") if _, err := env.mgr.CreateBranch("game1", "other", true); err != nil { t.Fatal(err) } diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index f595fd90..e3185235 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -270,6 +270,23 @@ func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSys return store.Snapshot{}, &IncompleteError{Game: game.Name, Skipped: skipped} } + // A copy taken before a sync replaces the save, of files a snapshot on this + // branch already holds: that one is the copy, and the save is not archived + // again (content.go). Syncs took one of these each time, mostly of a save + // already archived. Only when nothing automatic will remove it ahead of + // newer ones — pinned, taken by hand, or the branch's newest — or the save + // would be left with no copy once retention reached it. Snapshots the + // game's changes or a person ask for are always taken: the watcher takes + // them only when something changed, and a person asked. + contentKey := ContentKey(captured) + if existing, ok, _ := m.Store.SnapshotByContent(gameID, branch, contentKey); !current && ok && ArchiveExists(existing.ZipPath) && + (existing.Pinned || !existing.IsSystemAuto || m.isNewest(gameID, branch, existing.ID)) { + if m.Log != nil { + m.Log("info", fmt.Sprintf("%q holds the same files as snapshot %s; not archived again", game.Name, existing.ID)) + } + return existing, nil + } + if comment == "" { if isSystemAuto { comment = "Auto backup" @@ -283,6 +300,8 @@ func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSys return store.Snapshot{}, err } snapshotID := snap.ID + _ = m.Store.SetSnapshotContentHash(snapshotID, contentKey) + snap.ContentHash = contentKey // What this snapshot holds, file by file, recorded once and never // revisited. It is what lets a later caller ask whether some exact content diff --git a/internal/store/migrations/0039_snapshot_content.sql b/internal/store/migrations/0039_snapshot_content.sql new file mode 100644 index 00000000..46e0a102 --- /dev/null +++ b/internal/store/migrations/0039_snapshot_content.sql @@ -0,0 +1,16 @@ +-- What a snapshot holds, as one value: a hash over every file's name in the +-- archive and its content hash (snapshot.ContentKey). The same files give the +-- same value on every device, so two snapshots of one save — taken here, or +-- taken on another device and recorded here as "Synced from peer" — are known +-- to be the same, and the archive is kept once. '' until computed. +ALTER TABLE snapshots ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_snapshots_content ON snapshots(game_id, branch_name, content_hash); + +-- Another snapshot id that names the same content as a snapshot kept here: a +-- peer's snapshot whose save this device already has archived. Known, so it is +-- not archived again, and not asked about again on every sync. +CREATE TABLE snapshot_aliases ( + alias_id TEXT PRIMARY KEY, + snapshot_id TEXT NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE +); diff --git a/internal/store/snapshot_content.go b/internal/store/snapshot_content.go new file mode 100644 index 00000000..f9df1be2 --- /dev/null +++ b/internal/store/snapshot_content.go @@ -0,0 +1,92 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" +) + +// Snapshot content: which snapshots hold the same files, so a save is +// archived once however many times — and on however many devices — it is +// snapshotted. See migrations/0039_snapshot_content.sql. + +// SetSnapshotContentHash records what a snapshot holds. +func (s *Store) SetSnapshotContentHash(id, hash string) error { + if _, err := s.db.Exec(`UPDATE snapshots SET content_hash = ? WHERE id = ?`, hash, id); err != nil { + return fmt.Errorf("set content hash of %s: %w", id, err) + } + return nil +} + +// SnapshotByContent finds a snapshot of a game's branch holding exactly the +// content hash names; ok is false when there is none. The newest, so a copy +// kept in its place is the one retention keeps longest. +func (s *Store) SnapshotByContent(gameID, branch, hash string) (snap Snapshot, ok bool, err error) { + if hash == "" { + return Snapshot{}, false, nil + } + err = s.db.Get(&snap, `SELECT * FROM snapshots WHERE game_id = ? AND branch_name = ? AND content_hash = ? + ORDER BY timestamp DESC LIMIT 1`, gameID, branch, hash) + if errors.Is(err, sql.ErrNoRows) { + return Snapshot{}, false, nil + } + if err != nil { + return Snapshot{}, false, fmt.Errorf("find snapshot by content: %w", err) + } + return snap, true, nil +} + +// AddSnapshotAlias records that alias (another device's snapshot id) names the +// same content as snapshotID here. +func (s *Store) AddSnapshotAlias(alias, snapshotID string) error { + if alias == "" || alias == snapshotID { + return nil + } + if _, err := s.db.Exec(`INSERT INTO snapshot_aliases (alias_id, snapshot_id) VALUES (?, ?) + ON CONFLICT(alias_id) DO UPDATE SET snapshot_id = excluded.snapshot_id`, alias, snapshotID); err != nil { + return fmt.Errorf("add snapshot alias %s: %w", alias, err) + } + return nil +} + +// RepointSnapshotAliases moves every alias of from onto to, before from is +// removed in favour of to. +func (s *Store) RepointSnapshotAliases(from, to string) error { + if _, err := s.db.Exec(`UPDATE snapshot_aliases SET snapshot_id = ? WHERE snapshot_id = ?`, to, from); err != nil { + return fmt.Errorf("repoint aliases of %s: %w", from, err) + } + return nil +} + +// ResolveSnapshotID says which snapshot here an id names: itself when it is +// one, the snapshot it is an alias of when it is that; ok false when neither. +func (s *Store) ResolveSnapshotID(id string) (string, bool) { + var found string + if err := s.db.Get(&found, `SELECT id FROM snapshots WHERE id = ?`, id); err == nil { + return found, true + } + if err := s.db.Get(&found, `SELECT snapshot_id FROM snapshot_aliases WHERE alias_id = ?`, id); err == nil { + return found, true + } + return "", false +} + +// SnapshotsWithoutContentHash lists a game's snapshots whose content has not +// been computed yet. +func (s *Store) SnapshotsWithoutContentHash(gameID string) ([]Snapshot, error) { + var out []Snapshot + if err := s.db.Select(&out, `SELECT * FROM snapshots WHERE game_id = ? AND content_hash = '' ORDER BY timestamp`, gameID); err != nil { + return nil, fmt.Errorf("list snapshots without content hash: %w", err) + } + return out, nil +} + +// AllSnapshotsOfGame lists every snapshot of a game, on every branch, oldest +// first. +func (s *Store) AllSnapshotsOfGame(gameID string) ([]Snapshot, error) { + var out []Snapshot + if err := s.db.Select(&out, `SELECT * FROM snapshots WHERE game_id = ? ORDER BY timestamp`, gameID); err != nil { + return nil, fmt.Errorf("list snapshots of %s: %w", gameID, err) + } + return out, nil +} diff --git a/internal/store/snapshots.go b/internal/store/snapshots.go index bf0efc23..19ed403c 100644 --- a/internal/store/snapshots.go +++ b/internal/store/snapshots.go @@ -26,13 +26,16 @@ type Snapshot struct { // if it was not (migration 0032; internal/snapshot/verify.go). CheckedMs int64 `db:"checked_ms" json:"checkedMs"` Problem string `db:"problem" json:"problem"` + // ContentHash names what the snapshot holds (snapshot.ContentKey), the + // same on every device for the same files; '' until computed. + ContentHash string `db:"content_hash" json:"contentHash"` } // CreateSnapshot inserts a new snapshot record. func (s *Store) CreateSnapshot(snap Snapshot) error { _, err := s.db.NamedExec(` - INSERT INTO snapshots (id, game_id, branch_name, timestamp, comment, is_system_auto, zip_path, size_bytes) - VALUES (:id, :game_id, :branch_name, :timestamp, :comment, :is_system_auto, :zip_path, :size_bytes)`, + INSERT INTO snapshots (id, game_id, branch_name, timestamp, comment, is_system_auto, zip_path, size_bytes, content_hash) + VALUES (:id, :game_id, :branch_name, :timestamp, :comment, :is_system_auto, :zip_path, :size_bytes, :content_hash)`, snap) if err != nil { return fmt.Errorf("create snapshot %s: %w", snap.ID, err) From 40d0e557512d0697bb95753fe4653d5e3048bbb1 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:58:20 +0200 Subject: [PATCH 34/75] snapshots: steps on the way to a newer snapshot are removed An automatic snapshot every file of which a newer snapshot on the same branch holds unchanged is only a step on the way there - a pull that stopped part-way, a save that only gained files - and restoring it gives nothing the newer one lacks but the absence of what was added since. The snapshot flood of an interrupted pull left dozens of these per game. They are removed after identical copies are merged (PruneContained), at start and on POST /api/snapshots/merge-duplicates. The other way round is kept: a newer snapshot contained in an older one is a save something was deleted from, on purpose. Pinned snapshots and ones taken by hand are never removed. A snapshot without a recorded file list has it read from its archive once, and recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/daemon/daemon.go | 15 ++-- internal/snapshot/content.go | 135 +++++++++++++++++++++++++++--- internal/snapshot/content_test.go | 60 +++++++++++++ 3 files changed, 192 insertions(+), 18 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 523a1bb6..38161613 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1012,15 +1012,18 @@ func (d *Daemon) MergeSnapshotDuplicates() (merged int, freed int64) { return 0, 0 } for _, g := range games { - n, f, err := d.Snapshots.MergeDuplicates(g.ID) - if err != nil { - continue + if n, f, err := d.Snapshots.MergeDuplicates(g.ID); err == nil { + merged += n + freed += f + } + // And steps on the way to a newer snapshot that holds them whole. + if n, f, err := d.Snapshots.PruneContained(g.ID); err == nil { + merged += n + freed += f } - merged += n - freed += f } if merged > 0 { - d.Log.Log("info", fmt.Sprintf("kept identical snapshots once: %d merged, %d MB freed", merged, freed>>20)) + d.Log.Log("info", fmt.Sprintf("snapshots cleaned up: %d identical or held whole by a newer one, %d MB freed", merged, freed>>20)) } return merged, freed } diff --git a/internal/snapshot/content.go b/internal/snapshot/content.go index a579f349..f8645d2c 100644 --- a/internal/snapshot/content.go +++ b/internal/snapshot/content.go @@ -60,43 +60,154 @@ func ContentKey(files []store.CapturedFile) string { // ContentKeyOfArchive names what a snapshot holds by reading its archive: for // a snapshot recorded without its file list (one taken for a sync with a peer). func ContentKeyOfArchive(zipPath string) (string, error) { - path, done, err := OpenArchive(zipPath) + files, err := ArchiveFiles(zipPath) if err != nil { return "", err } + return ContentKey(files), nil +} + +// ArchiveFiles reads a snapshot's archive and lists its files with their +// content hashes, as the snapshot would have recorded them. +func ArchiveFiles(zipPath string) ([]store.CapturedFile, error) { + path, done, err := OpenArchive(zipPath) + if err != nil { + return nil, err + } defer done() r, err := zip.OpenReader(path) if err != nil { - return "", err + return nil, err } defer r.Close() - entries := make([]contentEntry, 0, len(r.File)) + out := make([]store.CapturedFile, 0, len(r.File)) for _, f := range r.File { if f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/") || strings.HasSuffix(f.Name, delta.TmpSuffix) { continue } rc, err := f.Open() if err != nil { - return "", fmt.Errorf("%s: %w", f.Name, err) + return nil, fmt.Errorf("%s: %w", f.Name, err) } h := sha256.New() _, err = io.Copy(h, rc) rc.Close() if err != nil { - return "", fmt.Errorf("%s: %w", f.Name, err) + return nil, fmt.Errorf("%s: %w", f.Name, err) + } + c := store.CapturedFile{Path: f.Name, Hash: hex.EncodeToString(h.Sum(nil))} + if rest, ok := strings.CutPrefix(f.Name, RootPrefix); ok { + if root, p, ok := strings.Cut(rest, "/"); ok { + c.Root, c.Path = root, p + } } - entries = append(entries, contentEntry{f.Name, hex.EncodeToString(h.Sum(nil))}) + out = append(out, c) } - return contentKeyOf(entries), nil + return out, nil } -// contentHashOf works out what a snapshot holds, from its file list when it -// has one and from its archive otherwise. -func (m *Manager) contentHashOf(snap store.Snapshot) (string, error) { +// filesOf is what a snapshot holds, from its recorded file list — read from +// its archive once, and recorded, when it has none. +func (m *Manager) filesOf(snap store.Snapshot) ([]store.CapturedFile, error) { if files, err := m.Store.SnapshotFiles(snap.ID); err == nil && len(files) > 0 { - return ContentKey(files), nil + return files, nil + } + files, err := ArchiveFiles(snap.ZipPath) + if err != nil { + return nil, err + } + if len(files) > 0 { + _ = m.Store.RecordSnapshotFiles(snap.ID, files) + } + return files, nil +} + +// contentHashOf works out what a snapshot holds (filesOf). +func (m *Manager) contentHashOf(snap store.Snapshot) (string, error) { + files, err := m.filesOf(snap) + if err != nil { + return "", err + } + return ContentKey(files), nil +} + +// PruneContained removes automatic snapshots that a newer snapshot on the same +// branch holds entirely: every file of the older one is in the newer one, with +// the same content. Such a snapshot is only a step on the way to the newer +// one — a pull that stopped part-way, a save that only gained files — and +// restoring it would give nothing the newer one lacks but the absence of what +// was added since. The other way round is kept: a newer snapshot contained in +// an older one is a save something was deleted from, on purpose. Pinned +// snapshots and ones taken by hand are never removed. +func (m *Manager) PruneContained(gameID string) (removed int, freed int64, err error) { + snaps, err := m.Store.AllSnapshotsOfGame(gameID) // oldest first + if err != nil { + return 0, 0, err + } + byBranch := map[string][]store.Snapshot{} + for _, s := range snaps { + if ArchiveExists(s.ZipPath) { + byBranch[s.BranchName] = append(byBranch[s.BranchName], s) + } + } + for _, list := range byBranch { + gone := map[string]bool{} + sizes := map[string]int{} + // From the newest down: what a snapshot already removed held is held + // by the newer one that held it, so it need not be looked at as one. + for li := len(list) - 1; li > 0; li-- { + newer := list[li] + if gone[newer.ID] { + continue + } + newerFiles, ferr := m.filesOf(newer) + if ferr != nil || len(newerFiles) == 0 { + continue + } + have := make(map[string]string, len(newerFiles)) + for _, f := range newerFiles { + have[archiveName(f)] = f.Hash + } + for si := 0; si < li; si++ { + older := list[si] + if gone[older.ID] || older.Pinned || !older.IsSystemAuto { + continue + } + if n, ok := sizes[older.ID]; ok && n > len(newerFiles) { + continue + } + olderFiles, ferr := m.filesOf(older) + if ferr != nil { + continue + } + sizes[older.ID] = len(olderFiles) + if len(olderFiles) == 0 || len(olderFiles) > len(newerFiles) { + continue + } + contained := true + for _, f := range olderFiles { + if have[archiveName(f)] != f.Hash { + contained = false + break + } + } + if !contained { + continue + } + f, derr := m.DeleteSnapshot(gameID, older.ID) + if derr != nil { + continue + } + gone[older.ID] = true + removed++ + freed += f + if m.Log != nil { + m.Log("info", fmt.Sprintf("snapshot %s removed: every file of it is in the newer %s", older.ID, newer.ID)) + } + } + } } - return ContentKeyOfArchive(snap.ZipPath) + return removed, freed, nil } // isNewest reports whether id is the newest snapshot on a game's branch. diff --git a/internal/snapshot/content_test.go b/internal/snapshot/content_test.go index 90326dee..ca41ce2d 100644 --- a/internal/snapshot/content_test.go +++ b/internal/snapshot/content_test.go @@ -1,6 +1,8 @@ package snapshot import ( + "os" + "path/filepath" "testing" ) @@ -32,6 +34,64 @@ func TestContentKey_FromFilesAndFromArchiveAgree(t *testing.T) { } } +// Steps on the way to a newer snapshot — every file of them in it, unchanged — +// go. A newer snapshot that lacks files (something was deleted) does not make +// the older one redundant; pinned and hand-taken snapshots are never removed. +func TestPruneContained(t *testing.T) { + env := setup(t) + game, err := env.store.GetGame("game1") + if err != nil { + t.Fatal(err) + } + game.MaxSnapshots = 50 + if err := env.store.UpdateGame(game); err != nil { + t.Fatal(err) + } + snap := func(auto bool) string { + t.Helper() + s, err := env.mgr.Create("game1", "", auto) + if err != nil { + t.Fatal(err) + } + return s.ID + } + writeSave(t, env.saveDir, "a.bin", "a") + step1 := snap(true) + writeSave(t, env.saveDir, "b.bin", "b") + step2 := snap(true) + pinnedStep := snap(false) // same files as step2, by hand, then pinned below + _ = env.store.SetSnapshotPinned(pinnedStep, true) + writeSave(t, env.saveDir, "c.bin", "c") + full := snap(true) + // Then a file is deleted on purpose. + if err := os.Remove(filepath.Join(env.saveDir, "c.bin")); err != nil { + t.Fatal(err) + } + writeSave(t, env.saveDir, "a.bin", "a, played on") + after := snap(true) + + removed, _, err := env.mgr.PruneContained("game1") + if err != nil { + t.Fatal(err) + } + gone := func(id string) bool { _, err := env.store.GetSnapshot(id); return err != nil } + if !gone(step1) || !gone(step2) { + t.Errorf("steps on the way to a newer snapshot kept: step1 gone=%v step2 gone=%v", gone(step1), gone(step2)) + } + if gone(pinnedStep) { + t.Error("a pinned snapshot was removed") + } + if gone(full) { + t.Error("removed a snapshot holding a file deleted later on purpose") + } + if gone(after) { + t.Error("removed the newest snapshot") + } + if removed != 2 { + t.Errorf("removed %d, want 2", removed) + } +} + // A copy taken before a sync replaces a save already archived is that archive, // not a second one. func TestBeforeReplacing_AnArchivedSaveIsNotArchivedAgain(t *testing.T) { From 60c889b9d45c59cb0d18e49c5a4e092231e0ec98 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:05:27 +0200 Subject: [PATCH 35/75] snapshots: steps on the way to a newer snapshot go, largest first, as they arise - Older automatic snapshots that a newer one on the same branch holds entirely - a pull that stopped part-way, a save that only gained files - are removed: the largest snapshots are tried first as the ones holding others, passes repeat until one removes nothing, and from then on each new snapshot (its own or a peer's) is checked against the older ones as it is taken (PruneOnCreate, set by the daemon). A full pass runs only for games with snapshots whose content is not named yet; POST /api/snapshots/merge-duplicates runs it for all. - Never the other way round: a newer snapshot contained in an older one is a save something was deleted from on purpose. Pinned and hand-taken snapshots are never removed. - A snapshot removed for one holding exactly the same files leaves its id as an alias: restoring, listing files, previewing and comparing by it reach the snapshot kept. Deleting does not follow aliases. - A copy taken before replacing the save stands in for an existing snapshot of the same files only when that one is the branch's newest - what a branch switch puts back. Fixes found on the way: - The watcher waits out only a sync writing the game now, not the seconds after: a change the game made straight after a pull was snapshotted late. - Taking a newer version after following a peer onto a branch read the save back through the gate the same sync still held, and waited for itself until its time ran out (30 minutes). It reads it directly. - e2e: branchHolds read the oldest snapshot (the API lists them oldest first), not the newest its comment says; tests that counted snapshots of unchanged saves now change the save first or check what is recoverable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/cli_features_test.go | 16 ++-- e2e/cloud_and_peers_test.go | 3 + e2e/history_test.go | 51 ++++++++++- e2e/keep_both_test.go | 7 +- e2e/multiroot_scenarios_test.go | 9 +- internal/api/routes.go | 19 ++++- internal/api/routes_backup.go | 4 +- internal/api/routes_verify.go | 2 +- internal/api/server_test.go | 6 +- internal/daemon/daemon.go | 19 ++++- internal/p2p/syncengine/engine.go | 5 ++ internal/p2p/syncengine/settle.go | 17 +++- internal/p2p/syncengine/version.go | 8 +- internal/snapshot/content.go | 132 +++++++++++++++++++++-------- internal/snapshot/content_test.go | 44 ++++++++++ internal/snapshot/snapshot.go | 20 ++++- internal/watcher/owntouch_test.go | 1 + internal/watcher/watcher.go | 19 +++-- 18 files changed, 312 insertions(+), 70 deletions(-) diff --git a/e2e/cli_features_test.go b/e2e/cli_features_test.go index 6c656fa1..15877e00 100644 --- a/e2e/cli_features_test.go +++ b/e2e/cli_features_test.go @@ -176,10 +176,10 @@ func TestCLI_SnapshotAndRollbackRestoresEverything(t *testing.T) { c.mustRun("add", "History Game", dir) c.mustRun("snapshot", "history-game", "-m", "known good") - // Two: the one tracking took automatically, and the one just asked for. - // The manual one is newest, and is the one to roll back to. + // One: the one asked for holds the same files as the one tracking took, + // and takes its place (snapshot/content.go). It is the one to roll back to. ids := c.snapshotIDs("history-game") - if len(ids) != 2 { + if len(ids) != 1 { t.Fatalf("expected the initial snapshot plus the manual one, got %v", ids) } snap := ids[0] @@ -410,6 +410,9 @@ func TestCLI_SnapshotAll(t *testing.T) { c.mustRun("add", "All One", one) c.mustRun("add", "All Two", two) before := len(c.snapshotIDs("all-one")) + len(c.snapshotIDs("all-two")) + // Played on: a snapshot of the same files would be the one already there. + c.saveDir("all-one", map[string]string{"a.sav": "1, played on"}) + c.saveDir("all-two", map[string]string{"b.sav": "2, played on"}) out := c.mustRun("snapshot", "--all", "before", "the", "reinstall") if !strings.Contains(out, "2 game") { @@ -630,7 +633,9 @@ func TestCLI_SnapshotDeleteAndPrune(t *testing.T) { dir := c.saveDir("pruning", map[string]string{"slot1.sav": "v1"}) c.mustRun("add", "Pruning", dir) - for _, v := range []string{"v1", "v2", "v3"} { + // Each a change: a snapshot of the same files as one already there would + // be that one (snapshot/content.go). + for _, v := range []string{"v2", "v3", "v4"} { c.saveDir("pruning", map[string]string{"slot1.sav": v}) c.mustRun("snapshot", "pruning", "-m", v) } @@ -671,6 +676,7 @@ func TestCLI_UntrackAllRequiresConfirmation(t *testing.T) { dir := c.saveDir("guard", map[string]string{"slot1.sav": "keep me"}) c.mustRun("add", "Guarded", dir) + c.saveDir("guard", map[string]string{"slot1.sav": "keep me, played on"}) c.mustRun("snapshot", "guarded") c.mustFail("untrack-all") @@ -694,7 +700,7 @@ func TestCLI_UntrackAllRequiresConfirmation(t *testing.T) { if out := c.mustRun("status"); strings.Contains(out, "Guarded") { t.Errorf("untrack-all --yes did not clear the library:\n%s", out) } - if got := c.readSave(dir, "slot1.sav"); got != "keep me" { + if got := c.readSave(dir, "slot1.sav"); got != "keep me, played on" { t.Errorf("untrack-all touched the save on disk: %q", got) } } diff --git a/e2e/cloud_and_peers_test.go b/e2e/cloud_and_peers_test.go index eaf6ee1f..5fdb30a5 100644 --- a/e2e/cloud_and_peers_test.go +++ b/e2e/cloud_and_peers_test.go @@ -197,6 +197,9 @@ func TestCloud_SyncLocalRepairsATruncatedRemoteCopy(t *testing.T) { a.WriteSave("slot1.sav", strings.Repeat("save data ", 500)) gameID := a.TrackGame("Repair Game") + // A change first: a snapshot of the same files as the one tracking took + // would be that one (snapshot/content.go). + a.WriteSave("slot1.sav", strings.Repeat("more save data ", 500)) a.API(http.MethodPost, "/api/games/"+gameID+"/snapshot", map[string]any{"comment": "full"}, nil) name := waitForUpload(t, cloudDir)[0] diff --git a/e2e/history_test.go b/e2e/history_test.go index b2b7bcff..e5e98b70 100644 --- a/e2e/history_test.go +++ b/e2e/history_test.go @@ -1,11 +1,15 @@ package e2e import ( + "archive/zip" "fmt" + "io" "net/http" "testing" "time" + "github.com/opensave/opensave/internal/snapshot" + "github.com/opensave/opensave/testutil" ) @@ -14,6 +18,7 @@ type snapshotWire struct { Comment string `json:"comment"` Branch string `json:"branch"` IsSystemAuto bool `json:"isSystemAuto"` + ZipPath string `json:"zipPath"` } type gameWire struct { @@ -82,11 +87,49 @@ func TestHistory_SnapshotAndRollback(t *testing.T) { } // Rolling back is itself non-destructive: the pre-rollback state must - // still be reachable, or "undo" would be a one-way door. - snaps := snapshotsOn(a, gameID, "main") - if len(snaps) < 3 { - t.Errorf("expected a safety snapshot of the pre-rollback state, have %d snapshots", len(snaps)) + // still be reachable, or "undo" would be a one-way door. Asked of the + // snapshots themselves, not counted: a state already snapshotted is not + // archived a second time. + if !snapshotHolds(t, a, gameID, "slot1.sav", "level-9") || !snapshotHolds(t, a, gameID, "inventory.dat", "cursed sword") { + t.Error("the pre-rollback state is in no snapshot") + } +} + +// snapshotHolds reports whether any snapshot on a game's main branch holds the +// archive entry name (a path in the save; ".opensave-locations/<name>/<path>" +// in another location) with exactly content. +func snapshotHolds(t *testing.T, td *testutil.TestDaemon, gameID, name, content string) bool { + t.Helper() + for _, s := range snapshotsOn(td, gameID, "main") { + path, done, err := snapshot.OpenArchive(s.ZipPath) + if err != nil { + continue + } + zr, err := zip.OpenReader(path) + if err != nil { + done() + continue + } + for _, f := range zr.File { + if f.Name != name { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + b, _ := io.ReadAll(rc) + rc.Close() + if string(b) == content { + zr.Close() + done() + return true + } + } + zr.Close() + done() } + return false } // Branches let two playthroughs coexist. Switching between them has to carry diff --git a/e2e/keep_both_test.go b/e2e/keep_both_test.go index 897bedd8..c3b5f35e 100644 --- a/e2e/keep_both_test.go +++ b/e2e/keep_both_test.go @@ -54,9 +54,12 @@ func branchHolds(t *testing.T, st branchState, branch, rel string) string { if len(snaps) == 0 { return "" } - path, done, err := snapshot.OpenArchive(snaps[0].ZipPath) + // The API lists a branch's snapshots oldest first (gamePayload), so the + // newest — what a switch puts back — is the last. + newest := snaps[len(snaps)-1] + path, done, err := snapshot.OpenArchive(newest.ZipPath) if err != nil { - t.Fatalf("open %s: %v", snaps[0].ZipPath, err) + t.Fatalf("open %s: %v", newest.ZipPath, err) } defer done() zr, err := zip.OpenReader(path) diff --git a/e2e/multiroot_scenarios_test.go b/e2e/multiroot_scenarios_test.go index e6659650..3ab77668 100644 --- a/e2e/multiroot_scenarios_test.go +++ b/e2e/multiroot_scenarios_test.go @@ -10,6 +10,7 @@ import ( "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/p2p/syncengine" + "github.com/opensave/opensave/internal/snapshot" "github.com/opensave/opensave/testutil" ) @@ -426,9 +427,11 @@ func TestMultiRootScenario_ResolveALocationConflictKeepingTheirs(t *testing.T) { if len(a.Daemon.P2P.Sync.ActiveRootConflicts()) != 0 { t.Error("the conflict is still open after being resolved") } - // Undoable: a snapshot was taken before anything was overwritten. - if after := len(snapshotsOn(a, gameID, "main")); after <= before { - t.Errorf("snapshots %d -> %d; no safety snapshot was taken before overwriting", before, after) + // Undoable: what was overwritten is in a snapshot — taken before the + // overwrite, or already there from when it was written. + _ = before + if !snapshotHolds(t, a, gameID, snapshot.RootPrefix+"config/settings.ini", "A-version") { + t.Error("the overwritten version is in no snapshot") } if got := a.ReadSave("save.sav"); got != "primary v1" { t.Errorf("resolving a location changed the save folder: %q", got) diff --git a/internal/api/routes.go b/internal/api/routes.go index 1c0a6149..c13e5b1c 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -640,10 +640,25 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, snap) } +// snapshotID is the request's snapshot id, followed when it is kept as an +// alias of the snapshot holding the same files (snapshot/content.go). For +// reading a snapshot only: deleting or editing takes the id as given, so an +// old id can never reach the snapshot that took its place. +func (s *Server) snapshotID(r *http.Request) string { + return s.resolveID(chi.URLParam(r, "snapshotId")) +} + +func (s *Server) resolveID(id string) string { + if resolved, ok := s.Daemon.Store.ResolveSnapshotID(id); ok { + return resolved + } + return id +} + // handleMergeDuplicates keeps every game's identical snapshots once, now // rather than at the next start (daemon.MergeSnapshotDuplicates). func (s *Server) handleMergeDuplicates(w http.ResponseWriter, r *http.Request) { - merged, freed := s.Daemon.MergeSnapshotDuplicates() + merged, freed := s.Daemon.MergeSnapshotDuplicates(true) s.BroadcastGamesUpdate() writeJSON(w, http.StatusOK, map[string]any{"merged": merged, "freedBytes": freed}) } @@ -1005,7 +1020,7 @@ func (s *Server) handleSyncResume(w http.ResponseWriter, r *http.Request) { // handlePreviewRestore says what restoring a snapshot would change, file by // file, without changing anything. func (s *Server) handlePreviewRestore(w http.ResponseWriter, r *http.Request) { - preview, err := s.Daemon.Snapshots.PreviewRestore(chi.URLParam(r, "gameId"), chi.URLParam(r, "snapshotId")) + preview, err := s.Daemon.Snapshots.PreviewRestore(chi.URLParam(r, "gameId"), s.snapshotID(r)) if err != nil { writeError(w, notFoundToStatus(err), err.Error()) return diff --git a/internal/api/routes_backup.go b/internal/api/routes_backup.go index 2be3bcaa..3d4125d0 100644 --- a/internal/api/routes_backup.go +++ b/internal/api/routes_backup.go @@ -25,7 +25,7 @@ import ( // granular-restore browser in the UI). func (s *Server) handleSnapshotFiles(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "gameId") - snapshotID := chi.URLParam(r, "snapshotId") + snapshotID := s.snapshotID(r) snap, err := s.Daemon.Store.GetSnapshot(snapshotID) if err != nil || snap.GameID != gameID { @@ -59,7 +59,7 @@ func (s *Server) handleSnapshotFiles(w http.ResponseWriter, r *http.Request) { // save location, taking a safety snapshot first. func (s *Server) handleRestoreFile(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "gameId") - snapshotID := chi.URLParam(r, "snapshotId") + snapshotID := s.snapshotID(r) var body struct { RelPath string `json:"relPath"` diff --git a/internal/api/routes_verify.go b/internal/api/routes_verify.go index af9641ea..e3732368 100644 --- a/internal/api/routes_verify.go +++ b/internal/api/routes_verify.go @@ -31,7 +31,7 @@ func (s *Server) handleVerifySnapshots(w http.ResponseWriter, r *http.Request) { // handleCompareSnapshots says what changed from one of a game's snapshots to // another (snapshot.Manager.Compare). func (s *Server) handleCompareSnapshots(w http.ResponseWriter, r *http.Request) { - c, err := s.Daemon.Snapshots.Compare(chi.URLParam(r, "gameId"), chi.URLParam(r, "snapshotId"), chi.URLParam(r, "otherId")) + c, err := s.Daemon.Snapshots.Compare(chi.URLParam(r, "gameId"), s.snapshotID(r), s.resolveID(chi.URLParam(r, "otherId"))) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/api/server_test.go b/internal/api/server_test.go index f7a9ebd5..dfefa6a0 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -578,7 +578,11 @@ func TestDeleteSnapshotAndBranch(t *testing.T) { ts.do(t, http.MethodPost, "/api/games", map[string]string{"name": "Del Game", "savePath": ts.saveDir}) waitInitialSnapshot(t, ts, "del-game") - // A manual second snapshot so we have one to delete. + // A manual second snapshot so we have one to delete — of a changed save: + // one of the same files would be the first snapshot, kept once. + if err := os.WriteFile(filepath.Join(ts.saveDir, "s.sav"), []byte("y"), 0o666); err != nil { + t.Fatal(err) + } ts.do(t, http.MethodPost, "/api/games/del-game/snapshot", map[string]string{"comment": "manual"}) snaps, _ := ts.daemon.Store.ListSnapshots("del-game", "main") if len(snaps) < 2 { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 38161613..3722b8d8 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -151,6 +151,9 @@ func New(opts Options) (*Daemon, error) { snaps := snapshot.New(s) snaps.Log = log.Log + // Each new snapshot takes the place of older automatic ones it holds + // whole (snapshot/content.go). + snaps.PruneOnCreate = true d := &Daemon{ Paths: paths, @@ -238,6 +241,9 @@ func New(opts Options) (*Daemon, error) { SyncWriting: func(gameID string) bool { return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.BeingWritten(gameID) }, + SyncWritingNow: func(gameID string) bool { + return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.WritingNow(gameID) + }, OnChanged: func(gameID string) { // A change made here, by the game or the user: a new version of // the save, recorded before anyone is offered it. @@ -281,7 +287,7 @@ func (d *Daemon) Start() error { // Snapshots that hold the same files are kept once (snapshot/content.go). // In the background, once things have settled: naming a snapshot taken // before contents were recorded reads its whole archive. - time.AfterFunc(2*time.Minute, func() { d.MergeSnapshotDuplicates() }) + time.AfterFunc(2*time.Minute, func() { d.MergeSnapshotDuplicates(false) }) // Switch games tracked under a made-up name get their real one, when an // emulator here knows it by now. d.nameSwitchGames() @@ -1003,8 +1009,9 @@ func (d *Daemon) checkSavePathShape(abs string) error { // MergeSnapshotDuplicates keeps every game's identical snapshots once // (snapshot.Manager.MergeDuplicates), one game at a time. Returns how many -// were merged and the bytes freed. -func (d *Daemon) MergeSnapshotDuplicates() (merged int, freed int64) { +// were merged and the bytes freed. all looks at every game; otherwise only at +// games with snapshots whose content is not named yet. +func (d *Daemon) MergeSnapshotDuplicates(all bool) (merged int, freed int64) { d.mergeMu.Lock() defer d.mergeMu.Unlock() games, err := d.Store.ListGames() @@ -1012,6 +1019,12 @@ func (d *Daemon) MergeSnapshotDuplicates() (merged int, freed int64) { return 0, 0 } for _, g := range games { + // Once per game: snapshots taken since are named as they are taken, + // and checked against the older ones then (PruneOnCreate). A game + // whose snapshots all have their content named has had its pass. + if unnamed, err := d.Store.SnapshotsWithoutContentHash(g.ID); !all && (err != nil || len(unnamed) == 0) { + continue + } if n, f, err := d.Snapshots.MergeDuplicates(g.ID); err == nil { merged += n freed += f diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 0a8d9761..ba160ace 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -2088,6 +2088,11 @@ func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer, ContentHash: key, }); err == nil { _ = e.Store.RecordSnapshotFiles(remoteSnap.ID, captured) + if e.Snapshots != nil { + if snap, gerr := e.Store.GetSnapshot(remoteSnap.ID); gerr == nil { + e.Snapshots.PruneContainedBy(snap, captured) + } + } } } diff --git a/internal/p2p/syncengine/settle.go b/internal/p2p/syncengine/settle.go index 0e6a732a..a66c1c8d 100644 --- a/internal/p2p/syncengine/settle.go +++ b/internal/p2p/syncengine/settle.go @@ -150,14 +150,25 @@ const writeEcho = 10 * time.Second // and each one read as the game saving: an automatic snapshot of a half-pulled // save every few seconds, which pushed the real ones out of retention. func (e *Engine) BeingWritten(gameID string) bool { - e.settleMu.Lock() - defer e.settleMu.Unlock() - if g := e.gates[gameID]; g != nil && (g.writers > 0 || g.waitingWriters > 0) { + if e.WritingNow(gameID) { return true } + e.settleMu.Lock() + defer e.settleMu.Unlock() return time.Since(e.writtenAt[gameID]) < writeEcho } +// WritingNow reports whether a sync on this device is writing a game's save at +// this moment — what the watcher waits out before looking at a burst, so it +// never judges a save half-way between two states. Not the moments after: +// a change the game makes then is the game's, and its snapshot is not held up. +func (e *Engine) WritingNow(gameID string) bool { + e.settleMu.Lock() + defer e.settleMu.Unlock() + g := e.gates[gameID] + return g != nil && (g.writers > 0 || g.waitingWriters > 0) +} + // Reading holds the game's save still for a sync to read it: it waits until // nothing is writing it, then keeps writers out until done is called. It // returns ErrSettling if ctx ends first. Hold it only for the reading itself — diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index f17cfbe4..70e38484 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -743,8 +743,12 @@ func (e *Engine) pullVersion(ctx context.Context, game store.Game, peer Peer, } applied() - // Only a save that is now exactly the peer's takes its version. - fresh, err := e.ReadManifest(ctx, gameID, game.SavePath) + // Only a save that is now exactly the peer's takes its version. Read + // straight from disk, not through the gate (settle.go): this sync is the + // one that wrote it, and after following a peer onto a branch it still + // holds the save — waiting for that would wait for itself until the + // sync's time ran out. + fresh, err := delta.BuildManifest(game.SavePath) if err != nil { return Result{}, true, err } diff --git a/internal/snapshot/content.go b/internal/snapshot/content.go index f8645d2c..b29a5371 100644 --- a/internal/snapshot/content.go +++ b/internal/snapshot/content.go @@ -139,70 +139,85 @@ func (m *Manager) contentHashOf(snap store.Snapshot) (string, error) { // was added since. The other way round is kept: a newer snapshot contained in // an older one is a save something was deleted from, on purpose. Pinned // snapshots and ones taken by hand are never removed. +// +// The largest snapshots are looked at first as the ones that may hold others — +// they hold the most — and passes repeat until one removes nothing. After +// that, each new snapshot is checked against the older ones as it is taken +// (PruneContainedBy), which is all it takes to stay that way. func (m *Manager) PruneContained(gameID string) (removed int, freed int64, err error) { + for { + n, f, err := m.pruneContainedPass(gameID) + removed += n + freed += f + if err != nil || n == 0 { + return removed, freed, err + } + } +} + +func (m *Manager) pruneContainedPass(gameID string) (removed int, freed int64, err error) { snaps, err := m.Store.AllSnapshotsOfGame(gameID) // oldest first if err != nil { return 0, 0, err } byBranch := map[string][]store.Snapshot{} + counts := map[string]int{} for _, s := range snaps { - if ArchiveExists(s.ZipPath) { - byBranch[s.BranchName] = append(byBranch[s.BranchName], s) + if !ArchiveExists(s.ZipPath) { + continue + } + files, ferr := m.filesOf(s) + if ferr != nil { + continue } + counts[s.ID] = len(files) + byBranch[s.BranchName] = append(byBranch[s.BranchName], s) } for _, list := range byBranch { + order := make(map[string]int, len(list)) // position in time + for i, s := range list { + order[s.ID] = i + } + holders := append([]store.Snapshot(nil), list...) + sort.SliceStable(holders, func(i, j int) bool { return counts[holders[i].ID] > counts[holders[j].ID] }) gone := map[string]bool{} - sizes := map[string]int{} - // From the newest down: what a snapshot already removed held is held - // by the newer one that held it, so it need not be looked at as one. - for li := len(list) - 1; li > 0; li-- { - newer := list[li] - if gone[newer.ID] { + for _, holder := range holders { + if gone[holder.ID] || counts[holder.ID] == 0 { continue } - newerFiles, ferr := m.filesOf(newer) - if ferr != nil || len(newerFiles) == 0 { + holderFiles, ferr := m.filesOf(holder) + if ferr != nil { continue } - have := make(map[string]string, len(newerFiles)) - for _, f := range newerFiles { + have := make(map[string]string, len(holderFiles)) + for _, f := range holderFiles { have[archiveName(f)] = f.Hash } - for si := 0; si < li; si++ { - older := list[si] - if gone[older.ID] || older.Pinned || !older.IsSystemAuto { - continue - } - if n, ok := sizes[older.ID]; ok && n > len(newerFiles) { + for _, older := range list[:order[holder.ID]] { + if gone[older.ID] || older.Pinned || !older.IsSystemAuto || counts[older.ID] == 0 || counts[older.ID] > len(holderFiles) { continue } olderFiles, ferr := m.filesOf(older) - if ferr != nil { + if ferr != nil || !containedIn(olderFiles, have) { continue } - sizes[older.ID] = len(olderFiles) - if len(olderFiles) == 0 || len(olderFiles) > len(newerFiles) { - continue - } - contained := true - for _, f := range olderFiles { - if have[archiveName(f)] != f.Hash { - contained = false - break - } - } - if !contained { - continue + identical := len(olderFiles) == len(holderFiles) + if identical { + _ = m.Store.RepointSnapshotAliases(older.ID, holder.ID) } f, derr := m.DeleteSnapshot(gameID, older.ID) if derr != nil { continue } + if identical { + // The same files: its id still leads to them. + _ = m.Store.AddSnapshotAlias(older.ID, holder.ID) + } gone[older.ID] = true removed++ freed += f if m.Log != nil { - m.Log("info", fmt.Sprintf("snapshot %s removed: every file of it is in the newer %s", older.ID, newer.ID)) + m.Log("info", fmt.Sprintf("snapshot %s removed: every file of it is in the newer %s", older.ID, holder.ID)) } } } @@ -210,6 +225,57 @@ func (m *Manager) PruneContained(gameID string) (removed int, freed int64, err e return removed, freed, nil } +func containedIn(files []store.CapturedFile, have map[string]string) bool { + for _, f := range files { + if have[archiveName(f)] != f.Hash { + return false + } + } + return true +} + +// PruneContainedBy checks the older snapshots of snap's branch against snap, +// just taken, and removes those it holds entirely (PruneContained). Only when +// PruneOnCreate is set; in the background, counted for shutdown. +func (m *Manager) PruneContainedBy(snap store.Snapshot, files []store.CapturedFile) { + if !m.PruneOnCreate || len(files) == 0 { + return + } + m.inFlight.Add() + go func() { + defer m.inFlight.Done() + have := make(map[string]string, len(files)) + for _, f := range files { + have[archiveName(f)] = f.Hash + } + snaps, err := m.Store.ListSnapshots(snap.GameID, snap.BranchName) + if err != nil { + return + } + for _, older := range snaps { + if older.ID == snap.ID || older.Timestamp >= snap.Timestamp || older.Pinned || !older.IsSystemAuto || + !ArchiveExists(older.ZipPath) { + continue + } + olderFiles, ferr := m.filesOf(older) + if ferr != nil || len(olderFiles) == 0 || len(olderFiles) > len(files) || !containedIn(olderFiles, have) { + continue + } + identical := len(olderFiles) == len(files) + if identical { + _ = m.Store.RepointSnapshotAliases(older.ID, snap.ID) + } + _, derr := m.DeleteSnapshot(snap.GameID, older.ID) + if derr == nil && identical { + _ = m.Store.AddSnapshotAlias(older.ID, snap.ID) + } + if derr == nil && m.Log != nil { + m.Log("info", fmt.Sprintf("snapshot %s removed: every file of it is in the newer %s", older.ID, snap.ID)) + } + } + }() +} + // isNewest reports whether id is the newest snapshot on a game's branch. func (m *Manager) isNewest(gameID, branch, id string) bool { snaps, err := m.Store.ListSnapshots(gameID, branch) diff --git a/internal/snapshot/content_test.go b/internal/snapshot/content_test.go index ca41ce2d..f8b2fc1f 100644 --- a/internal/snapshot/content_test.go +++ b/internal/snapshot/content_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) // The content a snapshot records and the content read back from its archive @@ -92,6 +93,49 @@ func TestPruneContained(t *testing.T) { } } +// With PruneOnCreate, a new snapshot takes the place of older automatic ones it +// holds entirely, as it is taken; one that lacks a file of them does not. +func TestPruneContainedBy_ANewSnapshotReplacesItsSteps(t *testing.T) { + env := setup(t) + env.mgr.PruneOnCreate = true + game, _ := env.store.GetGame("game1") + game.MaxSnapshots = 50 + _ = env.store.UpdateGame(game) + + writeSave(t, env.saveDir, "a.bin", "a") + first, err := env.mgr.Create("game1", "", true) + if err != nil { + t.Fatal(err) + } + writeSave(t, env.saveDir, "b.bin", "b") + if _, err := env.mgr.Create("game1", "", true); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := env.store.GetSnapshot(first.ID); err != nil { + break + } + time.Sleep(50 * time.Millisecond) + } + if _, err := env.store.GetSnapshot(first.ID); err == nil { + t.Fatal("the step the new snapshot holds entirely was kept") + } + + // Now a file goes: the newer snapshot does not hold the older one. + before, _ := env.store.ListSnapshots("game1", "main") + if err := os.Remove(filepath.Join(env.saveDir, "b.bin")); err != nil { + t.Fatal(err) + } + if _, err := env.mgr.Create("game1", "", true); err != nil { + t.Fatal(err) + } + time.Sleep(500 * time.Millisecond) + if _, err := env.store.GetSnapshot(before[0].ID); err != nil { + t.Error("removed the snapshot holding a file deleted afterwards") + } +} + // A copy taken before a sync replaces a save already archived is that archive, // not a second one. func TestBeforeReplacing_AnArchivedSaveIsNotArchivedAgain(t *testing.T) { diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index e3185235..5f736434 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -92,6 +92,10 @@ type Manager struct { // running past shutdown it wrote into a directory that was being deleted // and recorded against a database that was already closed. inFlight drain.Group + // PruneOnCreate checks each new snapshot against the older ones of its + // branch and removes those it holds entirely (content.go, + // PruneContainedBy). Off unless set: the daemon sets it. + PruneOnCreate bool // sharedMu is held by a compaction and by the clean-up of shared files // (shared.go): a compaction names shared files before any list does, and // a clean-up running then would take them for unused. pendingRoots are @@ -273,14 +277,14 @@ func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSys // A copy taken before a sync replaces the save, of files a snapshot on this // branch already holds: that one is the copy, and the save is not archived // again (content.go). Syncs took one of these each time, mostly of a save - // already archived. Only when nothing automatic will remove it ahead of - // newer ones — pinned, taken by hand, or the branch's newest — or the save - // would be left with no copy once retention reached it. Snapshots the + // already archived. Only when that snapshot is the branch's newest: a + // branch switch puts back the newest, and retention removes older ones + // first — an older copy standing in would leave the save behind either way. Snapshots the // game's changes or a person ask for are always taken: the watcher takes // them only when something changed, and a person asked. contentKey := ContentKey(captured) if existing, ok, _ := m.Store.SnapshotByContent(gameID, branch, contentKey); !current && ok && ArchiveExists(existing.ZipPath) && - (existing.Pinned || !existing.IsSystemAuto || m.isNewest(gameID, branch, existing.ID)) { + m.isNewest(gameID, branch, existing.ID) { if m.Log != nil { m.Log("info", fmt.Sprintf("%q holds the same files as snapshot %s; not archived again", game.Name, existing.ID)) } @@ -302,6 +306,9 @@ func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSys snapshotID := snap.ID _ = m.Store.SetSnapshotContentHash(snapshotID, contentKey) snap.ContentHash = contentKey + // Older automatic snapshots this one holds entirely are steps on the way + // to it (content.go). + defer m.PruneContainedBy(snap, captured) // What this snapshot holds, file by file, recorded once and never // revisited. It is what lets a later caller ask whether some exact content @@ -772,6 +779,11 @@ func (m *Manager) Restore(gameID, snapshotID string) (store.Snapshot, error) { if err != nil { return store.Snapshot{}, err } + // An id kept as an alias of the snapshot holding the same files + // (content.go) restores that snapshot. + if resolved, ok := m.Store.ResolveSnapshotID(snapshotID); ok { + snapshotID = resolved + } snap, err := m.Store.GetSnapshot(snapshotID) if err != nil || snap.GameID != gameID { return store.Snapshot{}, fmt.Errorf("snapshot %q not found for game %q", snapshotID, gameID) diff --git a/internal/watcher/owntouch_test.go b/internal/watcher/owntouch_test.go index 2e4e241f..d984bc29 100644 --- a/internal/watcher/owntouch_test.go +++ b/internal/watcher/owntouch_test.go @@ -113,6 +113,7 @@ func TestWhileASyncWrites_TheWatcherWaits(t *testing.T) { var writing atomic.Bool cb := col.callbacks() cb.SyncWriting = func(string) bool { return writing.Load() } + cb.SyncWritingNow = cb.SyncWriting eng := New(cb) defer eng.Stop() if err := eng.Watch("game1", saveDir); err != nil { diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index da7da332..33f44f4c 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -120,17 +120,26 @@ type Callbacks struct { // Log receives human-readable watcher activity. May be nil. Log func(level, msg string) // SyncWriting reports whether a sync on this device is writing the game - // now, or was a moment ago. While it is, a burst the watcher cannot - // attribute — an overflow of file events — is taken for the sync's, and - // the burst is looked at once the sync has finished. May be nil. + // now, or was a moment ago: a burst the watcher cannot attribute — an + // overflow of file events — is then taken for the sync's. May be nil. SyncWriting func(gameID string) bool + // SyncWritingNow reports whether a sync is writing the game at this + // moment: the watcher looks at the burst once it has finished. May be nil. + SyncWritingNow func(gameID string) bool } -// syncWriting reports whether a sync is writing a game (Callbacks.SyncWriting). +// syncWriting reports whether a sync is or was just writing a game +// (Callbacks.SyncWriting). func (e *Engine) syncWriting(gameID string) bool { return e.cb.SyncWriting != nil && e.cb.SyncWriting(gameID) } +// syncWritingNow reports whether a sync is writing a game at this moment +// (Callbacks.SyncWritingNow). +func (e *Engine) syncWritingNow(gameID string) bool { + return e.cb.SyncWritingNow != nil && e.cb.SyncWritingNow(gameID) +} + // Engine owns one watch goroutine per tracked game. type Engine struct { cb Callbacks @@ -782,7 +791,7 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { debounceC = nil // A sync still writing this game: look at the burst once it has // finished, not at a save half-way between two states. - if e.syncWriting(gw.gameID) { + if e.syncWritingNow(gw.gameID) { resetDebounce() continue } From 67d2383e30d4c554df0a20ca1e019d2bb593a4aa Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:01:22 +0200 Subject: [PATCH 36/75] p2p: drop the reconnect lock 2.4.1 replaced (onlineMu) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/engine.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/p2p/engine.go b/internal/p2p/engine.go index 4b053026..115541c5 100644 --- a/internal/p2p/engine.go +++ b/internal/p2p/engine.go @@ -138,10 +138,6 @@ type Engine struct { answered map[string]bool startedMs int64 - // Serializes the offline->online transition in requirePairedPeer, so a - // burst of concurrent requests from a returning peer triggers one full - // auto-sync instead of one per request. - peerOnlineMu sync.Mutex // Lineage refreshes already running after a peer-applied deletion, keyed // by game+peer. Deletions arrive one file at a time, and each one leaves From 0672b539e395c1cf1370908c3ab9fd28cf81689d Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:20:52 +0200 Subject: [PATCH 37/75] Launcher files, mirror snapshot speed, unwritable folders Found in a four-device retest: - Steam rewrites remotecache.vdf in a game's userdata folder on every start. It counted as a new save: an auto-snapshot of a game nobody played, a version bump, and every other device trying to take it - failing every 30 s where the folder is under Program Files. It is the launcher's file, not the save: delta.NeverSynced leaves it out of manifests and snapshots, and the watcher does not wake for it. - The snapshot recorded after taking a peer's version zipped the whole save again. It goes through the snapshot manager now, which reuses the unchanged files of the previous snapshot (ArchiveSaveTo); a large save no longer sits at "100%" for minutes after the download. - A save folder that cannot be written (access denied) is said once, plainly, with what to do, and pulls into it wait 30 minutes instead of failing on every pass from every device. - An identical older snapshot is aliased before it is deleted, and Restore looks an id up again if its row vanished in between, so a restore racing the prune never finds nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/delta/manifest.go | 19 +++++++++ internal/delta/neversynced_test.go | 54 +++++++++++++++++++++++++ internal/p2p/engine.go | 1 - internal/p2p/routes.go | 2 +- internal/p2p/syncengine/engine.go | 19 +++++++-- internal/p2p/syncengine/version.go | 39 ++++++++++++++++++ internal/p2p/syncengine/version_test.go | 2 +- internal/snapshot/content.go | 37 +++++++++++++---- internal/snapshot/snapshot.go | 6 +++ internal/snapshot/zip.go | 2 +- internal/snapshot/zip_roots.go | 2 +- internal/watcher/watcher.go | 6 +++ 12 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 internal/delta/neversynced_test.go diff --git a/internal/delta/manifest.go b/internal/delta/manifest.go index d008ad76..bf1b9704 100644 --- a/internal/delta/manifest.go +++ b/internal/delta/manifest.go @@ -12,6 +12,7 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" "sort" "strconv" @@ -29,6 +30,20 @@ import ( // peer as a real file (and cascade into name.opensave.tmp.opensave.tmp). const TmpSuffix = ".opensave.tmp" +// NeverSynced reports whether a file in a save folder is never part of the +// save, on any device. +// +// remotecache.vdf is Steam's record of what Steam Cloud has uploaded for a +// game, kept beside the game's files in Steam's userdata folder — which is +// where a game whose saves live in Steam Cloud is tracked. It describes this +// device's Steam, not the save: syncing it hands Steam another machine's +// bookkeeping. And that folder is under Program Files, where writing needs +// rights OpenSave does not have, so the copy failed with "access denied" on +// every pass, from every device. +func NeverSynced(rel string) bool { + return strings.EqualFold(path.Base(rel), "remotecache.vdf") +} + // staleTmpAge is how old a leftover TmpSuffix file must be before the // manifest walk garbage-collects it. Generous enough that a patch actively // writing its temp file is never deleted out from under it. @@ -363,6 +378,10 @@ func buildManifest(root string) (Manifest, error) { } return nil } + // A file no device's save is made of (NeverSynced). + if NeverSynced(rel) { + return nil + } entry, err := cachedHashFile(path, walkInfo) if err != nil { diff --git a/internal/delta/neversynced_test.go b/internal/delta/neversynced_test.go new file mode 100644 index 00000000..615e18e4 --- /dev/null +++ b/internal/delta/neversynced_test.go @@ -0,0 +1,54 @@ +package delta + +import ( + "os" + "path/filepath" + "testing" +) + +// Steam rewrites remotecache.vdf in a game's userdata folder on every start, +// and that folder can sit under Program Files where it cannot be written. It +// is the launcher's file, not the save: it must never show up as a change. +func TestBuildManifestLeavesOutLauncherFiles(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "remote"), 0o755); err != nil { + t.Fatal(err) + } + for name, data := range map[string]string{ + "remote/save.sav": "save", + "remotecache.vdf": "steam", + "sub/RemoteCache.VDF": "steam", + } { + p := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + } + m, err := BuildManifest(dir) + if err != nil { + t.Fatal(err) + } + if _, ok := m.Files["remote/save.sav"]; !ok { + t.Fatalf("the save is missing: %v", m.Files) + } + for p := range m.Files { + if NeverSynced(p) { + t.Errorf("%s is in the manifest", p) + } + } + before := m.ManifestHash() + InvalidateRoot(dir) + if err := os.WriteFile(filepath.Join(dir, "remotecache.vdf"), []byte("steam started again"), 0o644); err != nil { + t.Fatal(err) + } + m2, err := BuildManifest(dir) + if err != nil { + t.Fatal(err) + } + if m2.ManifestHash() != before { + t.Error("Steam rewriting remotecache.vdf changed the save's hash") + } +} diff --git a/internal/p2p/engine.go b/internal/p2p/engine.go index 115541c5..83e68ea7 100644 --- a/internal/p2p/engine.go +++ b/internal/p2p/engine.go @@ -138,7 +138,6 @@ type Engine struct { answered map[string]bool startedMs int64 - // Lineage refreshes already running after a peer-applied deletion, keyed // by game+peer. Deletions arrive one file at a time, and each one leaves // the merge-base needing to be re-derived — so clearing a save folder of diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index 471377b1..290728fa 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -2,8 +2,8 @@ package p2p import ( "compress/gzip" - "crypto/rand" "context" + "crypto/rand" "encoding/json" "errors" "fmt" diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index ba160ace..20c86d01 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -85,7 +85,7 @@ type Result struct { // back until told whether that was meant (hold.go). peer_busy: the peer was // still writing the game for a sync of its own; this one runs again when it // has finished (settle.go). - Status string `json:"status"` // in_sync | updated | updated_bidirectional | deletions_synced | triggered_peer_pull | conflict | peer_missing | peer_awaiting_folder | peer_holding | peer_busy + Status string `json:"status"` // in_sync | updated | updated_bidirectional | deletions_synced | triggered_peer_pull | conflict | peer_missing | peer_awaiting_folder | peer_holding | peer_busy | unwritable Direction string `json:"direction"` PeerID string `json:"peerId,omitempty"` PeerName string `json:"peerName,omitempty"` @@ -160,6 +160,9 @@ type Engine struct { // peerVersions: per peer, whether its OpenSave keeps save versions // (PeerNeedsUpdate). peerVersions map[string]bool + // unwritable: games whose save folder could not be written, and until + // when pulls into it wait (version.go). + unwritable map[string]time.Time // linkMu guards links, the measured connection to each peer, and // pullWaits, syncs waiting for a close peer to finish taking a save @@ -384,7 +387,7 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer // divergence from the NEXT sync, causing the peer to silently overwrite // its own changes instead of detecting the conflict and asking. switch res.Status { - case "conflict", "error", versionStatusWaiting, StatusPeerOutdatedApp: + case "conflict", "error", versionStatusWaiting, StatusPeerOutdatedApp, "unwritable": case "peer_missing", "peer_awaiting_folder", "peer_holding": // The two devices talked and finished, which is what the // per-device stamp has always recorded. But nothing of THIS game @@ -1403,7 +1406,7 @@ func (e *Engine) registerConflict(gameID string, peer Peer, localManifest delta. DiffFiles: diffs, DiffTotal: total, OnlyLocalTotal: counts["only-local"], OnlyRemoteTotal: counts["only-remote"], ChangedTotal: counts["changed"], - remoteVersion: remoteData.Version, + remoteVersion: remoteData.Version, } e.mu.Unlock() @@ -2051,7 +2054,15 @@ func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer, if rootsErr != nil { mirrorRoots = nil } - skipped, captured, err := snapshot.ZipRootsCapturing(game.SavePath, mirrorRoots, zipPath) + var skipped []string + var captured []store.CapturedFile + if e.Snapshots != nil { + // Unchanged files copied from the newest snapshot, not compressed + // again: seconds rather than minutes for a large save. + skipped, captured, err = e.Snapshots.ArchiveSaveTo(gameID, game.ActiveBranch, zipPath) + } else { + skipped, captured, err = snapshot.ZipRootsCapturing(game.SavePath, mirrorRoots, zipPath) + } if err != nil { os.Remove(zipPath) e.Log("warn", fmt.Sprintf("mirror snapshot zip failed: %v", err)) diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index 70e38484..203e72aa 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "sort" "strings" "time" @@ -672,6 +673,12 @@ func (e *Engine) pullVersion(ctx context.Context, game store.Game, peer Peer, local delta.Manifest, remote ManifestResponse, adopt VersionVector) (Result, bool, error) { gameID := game.ID + if until, blocked := e.unwritableUntil(gameID); blocked { + // Said once when it happened (below); waiting is the answer until then. + e.logOnce(gameID+"|unwritable", fmt.Sprintf("%q: waiting until %s before writing to its save folder again", + game.Name, until.Format("15:04"))) + return Result{Status: "unwritable", PeerID: peer.ID, PeerName: peer.Name}, true, nil + } if len(remote.Manifest.Files) == 0 && len(local.Files) > 0 && !remote.DeletionConfirmed { // The newer version is an empty folder nobody has confirmed emptying. e.Log("info", fmt.Sprintf("%q holds none of %q's save files now, and has not confirmed deleting them — keeping this device's copies", @@ -738,6 +745,14 @@ func (e *Engine) pullVersion(ctx context.Context, game store.Game, peer Peer, if len(d.FilesToPull) > 0 { if err := e.pullFiles(ctx, peer, gameID, game, primaryRootOf(game), local, remote, d.FilesToPull); err != nil { applied() + if errors.Is(err, fs.ErrPermission) { + // Not something the next pass fixes: the folder needs other + // rights (one under Program Files does). Said once, plainly, + // and tried again later rather than on every pass. + e.markUnwritable(gameID) + e.Log("error", fmt.Sprintf("%q: the save folder %s cannot be written (%v). Give OpenSave write access to it, or track the game at another folder. Trying again in %s.", + game.Name, game.SavePath, err, unwritableRetry)) + } return Result{}, true, fmt.Errorf("taking %s's version of %q did not finish (it is resumed on the next sync): %w", peer.Name, game.Name, err) } } @@ -785,6 +800,30 @@ func (e *Engine) pullVersion(ctx context.Context, game store.Game, peer Peer, return Result{Status: "updated", Direction: "pull", PeerID: peer.ID, PeerName: peer.Name}, true, nil } +// unwritableRetry is how long a game whose save folder could not be written +// waits before a pull into it is tried again. +const unwritableRetry = 30 * time.Minute + +func (e *Engine) markUnwritable(gameID string) { + e.versionMu.Lock() + defer e.versionMu.Unlock() + if e.unwritable == nil { + e.unwritable = map[string]time.Time{} + } + e.unwritable[gameID] = time.Now().Add(unwritableRetry) +} + +func (e *Engine) unwritableUntil(gameID string) (time.Time, bool) { + e.versionMu.Lock() + defer e.versionMu.Unlock() + until, ok := e.unwritable[gameID] + if !ok || time.Now().After(until) { + delete(e.unwritable, gameID) + return time.Time{}, false + } + return until, true +} + // setPulling marks a pull towards a newer version as running, so the files it // writes are never taken for a change made here. In memory for this run; in // the record from the start until the pull has completed, which only adopting diff --git a/internal/p2p/syncengine/version_test.go b/internal/p2p/syncengine/version_test.go index 7e3215b1..67e2d189 100644 --- a/internal/p2p/syncengine/version_test.go +++ b/internal/p2p/syncengine/version_test.go @@ -348,7 +348,7 @@ func (t *meshTransport) DeleteRemote(ctx context.Context, peer Peer, ref FileRef return os.RemoveAll(filepath.Join(n.dir, filepath.FromSlash(ref.RelPath))) } -func (t *meshTransport) TriggerPeerPull(peer Peer, gameID string) {} +func (t *meshTransport) TriggerPeerPull(peer Peer, gameID string) {} func (t *meshTransport) ReportSyncEvent(peer Peer, gameID, eventType string, data map[string]any) {} func newMesh(t *testing.T, names ...string) (*mesh, map[string]*meshNode) { diff --git a/internal/snapshot/content.go b/internal/snapshot/content.go index b29a5371..f3e7be31 100644 --- a/internal/snapshot/content.go +++ b/internal/snapshot/content.go @@ -203,16 +203,16 @@ func (m *Manager) pruneContainedPass(gameID string) (removed int, freed int64, e } identical := len(olderFiles) == len(holderFiles) if identical { + // The same files: its id still leads to them. Before the + // delete, so no moment exists where the id leads nowhere; + // while the row is there it still resolves to itself. _ = m.Store.RepointSnapshotAliases(older.ID, holder.ID) + _ = m.Store.AddSnapshotAlias(older.ID, holder.ID) } f, derr := m.DeleteSnapshot(gameID, older.ID) if derr != nil { continue } - if identical { - // The same files: its id still leads to them. - _ = m.Store.AddSnapshotAlias(older.ID, holder.ID) - } gone[older.ID] = true removed++ freed += f @@ -263,12 +263,11 @@ func (m *Manager) PruneContainedBy(snap store.Snapshot, files []store.CapturedFi } identical := len(olderFiles) == len(files) if identical { + // Aliased before the delete (see PruneContained). _ = m.Store.RepointSnapshotAliases(older.ID, snap.ID) - } - _, derr := m.DeleteSnapshot(snap.GameID, older.ID) - if derr == nil && identical { _ = m.Store.AddSnapshotAlias(older.ID, snap.ID) } + _, derr := m.DeleteSnapshot(snap.GameID, older.ID) if derr == nil && m.Log != nil { m.Log("info", fmt.Sprintf("snapshot %s removed: every file of it is in the newer %s", older.ID, snap.ID)) } @@ -276,6 +275,28 @@ func (m *Manager) PruneContainedBy(snap store.Snapshot, files []store.CapturedFi }() } +// ArchiveSaveTo archives a game's save — every location — into zipPath the way +// a snapshot here is archived: files unchanged since the branch's newest +// snapshot are copied from it rather than compressed again (reuse.go). For a +// snapshot recorded under another device's id after a sync; compressing a save +// of a quarter-million files from scratch kept a finished pull at 100% for +// minutes. +func (m *Manager) ArchiveSaveTo(gameID, branch, zipPath string) (skipped []string, captured []store.CapturedFile, err error) { + m.inFlight.Add() + defer m.inFlight.Done() + game, err := m.Store.GetGame(gameID) + if err != nil { + return nil, nil, err + } + extra, rootsErr := m.Store.GameRootPaths(gameID) + if rootsErr != nil { + extra = nil + } + reuse := m.openReuseSource(gameID, branch) + defer reuse.Close() + return zipRootsCapturing(game.SavePath, extra, zipPath, reuse) +} + // isNewest reports whether id is the newest snapshot on a game's branch. func (m *Manager) isNewest(gameID, branch, id string) bool { snaps, err := m.Store.ListSnapshots(gameID, branch) @@ -335,11 +356,11 @@ func (m *Manager) MergeDuplicates(gameID string) (merged int, freed int64, err e continue } _ = m.Store.RepointSnapshotAliases(s.ID, keep.ID) + _ = m.Store.AddSnapshotAlias(s.ID, keep.ID) f, err := m.DeleteSnapshot(gameID, s.ID) if err != nil { continue } - _ = m.Store.AddSnapshotAlias(s.ID, keep.ID) merged++ freed += f if m.Log != nil { diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 5f736434..6f1ed40d 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -785,6 +785,12 @@ func (m *Manager) Restore(gameID, snapshotID string) (store.Snapshot, error) { snapshotID = resolved } snap, err := m.Store.GetSnapshot(snapshotID) + if err != nil { + // Removed in favour of the same files just now: its alias is there. + if resolved, ok := m.Store.ResolveSnapshotID(snapshotID); ok { + snap, err = m.Store.GetSnapshot(resolved) + } + } if err != nil || snap.GameID != gameID { return store.Snapshot{}, fmt.Errorf("snapshot %q not found for game %q", snapshotID, gameID) } diff --git a/internal/snapshot/zip.go b/internal/snapshot/zip.go index 804f7cf8..b60ea395 100644 --- a/internal/snapshot/zip.go +++ b/internal/snapshot/zip.go @@ -97,7 +97,7 @@ func zipPathCapturing(sourcePath, outPath string, reuse *reuseSource) (skipped [ } // OpenSave's own temporary file for a file being pulled: never part // of the save, and gone by the time it is read. - if strings.HasSuffix(rel, delta.TmpSuffix) { + if strings.HasSuffix(rel, delta.TmpSuffix) || delta.NeverSynced(rel) { return nil } info, infoErr := d.Info() diff --git a/internal/snapshot/zip_roots.go b/internal/snapshot/zip_roots.go index fac78559..5100f6ea 100644 --- a/internal/snapshot/zip_roots.go +++ b/internal/snapshot/zip_roots.go @@ -145,7 +145,7 @@ func archiveInto(w *zip.Writer, sourcePath, prefix, root string, reuse *reuseSou return err } // OpenSave's own temporary file for a file being pulled (zip.go). - if strings.HasSuffix(rel, delta.TmpSuffix) { + if strings.HasSuffix(rel, delta.TmpSuffix) || delta.NeverSynced(rel) { return nil } info, infoErr := d.Info() diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 33f44f4c..b5bcd055 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -845,6 +845,12 @@ func (gw *gameWatch) eventRelevant(event fsnotify.Event) bool { if strings.HasSuffix(name, ".opensave.tmp") { return false } + // A file the launcher rewrites on its own (Steam's remotecache.vdf, on + // every start) is not a save: it is never synced or snapshotted, so it + // does not wake the watcher either. + if delta.NeverSynced(name) { + return false + } if gw.isFile { return strings.EqualFold( filepath.Clean(event.Name), From 147a381eb18d64c4d44329490096a5efcc57d569 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:51:02 +0200 Subject: [PATCH 38/75] remotecache.vdf: excluded like a rule, not left out of manifests 0672b53 left Steam's remotecache.vdf out of the manifest itself. On a device whose tracked folder held nothing else - every game tracked at Steam's userdata/<appid> folder - the first start of that build saw the folder emptied: "every save file was deleted", an empty auto-snapshot, and a new version of its own for every such game, which every other device would have done at the same moment. - The served manifest lists the file again: a device on an older build must not read the gap as a deletion and remove its own copy. It is left out where decisions are made instead, exactly like an exclusion rule: filterManifest, the lineage, recorded deletions and the version hash all leave it out, with or without rules of the game's own. - The version hash tag includes the list (delta.NeverSyncedList), so a new list re-takes the hash instead of naming a version. - Once per change of the list, at start, each game's recorded hashes are re-taken in the new terms when the save is the one they were recorded for (watcher.ContentHashBeforeNeverSynced, AdoptNeverSyncedView), so the watcher does not take the upgrade for a change. Recorded in a new marks table. - A hold recorded over such a file lets go: it is not something that has to come back. - The snapshot check skips a snapshot removed while it ran (a newer one with the same files took its place) instead of calling it damaged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/daemon/daemon.go | 38 ++++++ internal/daemon/verify.go | 5 + internal/delta/manifest.go | 17 ++- internal/delta/neversynced_test.go | 57 ++------ internal/p2p/syncengine/conflict.go | 7 +- internal/p2p/syncengine/engine.go | 34 ++--- internal/p2p/syncengine/hold.go | 5 + internal/p2p/syncengine/ignore.go | 61 +++++++-- internal/p2p/syncengine/multiroot.go | 22 ++-- internal/p2p/syncengine/neversynced_test.go | 137 ++++++++++++++++++++ internal/p2p/syncengine/version.go | 48 ++++++- internal/store/marks.go | 22 ++++ internal/store/migrations/0040_marks.sql | 8 ++ internal/watcher/neversynced_test.go | 26 ++++ internal/watcher/watcher.go | 47 +++++-- 15 files changed, 421 insertions(+), 113 deletions(-) create mode 100644 internal/p2p/syncengine/neversynced_test.go create mode 100644 internal/store/marks.go create mode 100644 internal/store/migrations/0040_marks.sql create mode 100644 internal/watcher/neversynced_test.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 3722b8d8..86ffa570 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -271,6 +271,41 @@ func New(opts Options) (*Daemon, error) { return d, nil } +// adoptNeverSyncedView re-takes, once per change of delta.NeverSyncedList, +// the hashes recorded for every game under the previous list — each only when +// the save is the one it was recorded for, so a real change still counts. +// +// Without it the first build with a new list sees every save holding one of +// those files as changed: an auto-snapshot of a game nobody played, and a new +// version of its own on every device at once, which the devices then disagree +// over. +func (d *Daemon) adoptNeverSyncedView(games []store.Game) { + const mark = "never_synced" + if d.Store.Mark(mark) == delta.NeverSyncedList { + return + } + for _, game := range games { + extra, err := d.Store.GameRootPaths(game.ID) + if err != nil { + extra = nil + } + m, failures, err := delta.BuildMultiManifest(game.SavePath, extra) + if err != nil || len(failures) > 0 { + continue // unreadable now; compared as it is when it is back + } + if game.LastManifestHash != "" && + game.LastManifestHash == watcher.ContentHashBeforeNeverSynced(m, game.SyncIgnore) { + _ = d.Store.SetLastManifestHash(game.ID, watcher.ContentHash(m, game.SyncIgnore)) + } + if d.P2P != nil && d.P2P.Sync != nil { + primary := m + primary.Extra = nil + d.P2P.Sync.AdoptNeverSyncedView(game.ID, primary) + } + } + _ = d.Store.SetMark(mark, delta.NeverSyncedList) +} + // Start begins watching every tracked game with auto-sync enabled. func (d *Daemon) Start() error { // Deletion records expire. Swept once per launch rather than on a timer: @@ -298,6 +333,9 @@ func (d *Daemon) Start() error { // then evicts entries it is about to want and starts re-reading saves. delta.SetHashCacheBudgetForGames(len(games)) + // Before anything is watched or compared. + d.adoptNeverSyncedView(games) + for _, game := range games { // Backfill cover art for games tracked before covers existed (or // migrated from the JS app without one). diff --git a/internal/daemon/verify.go b/internal/daemon/verify.go index ebf57cbd..47552baf 100644 --- a/internal/daemon/verify.go +++ b/internal/daemon/verify.go @@ -64,6 +64,11 @@ func (d *Daemon) VerifySnapshots(ctx context.Context, gameID string, pace time.D } was := snap.Problem if err := d.Snapshots.Verify(snap); err != nil { + if _, gerr := d.Store.GetSnapshot(snap.ID); gerr != nil { + // Removed while this ran (a newer snapshot holding + // the same files took its place): not damaged, gone. + continue + } report.Damaged = append(report.Damaged, DamagedSnapshot{ GameID: g.ID, GameName: g.Name, SnapshotID: snap.ID, Timestamp: snap.Timestamp, Problem: err.Error(), }) diff --git a/internal/delta/manifest.go b/internal/delta/manifest.go index bf1b9704..c4f195bc 100644 --- a/internal/delta/manifest.go +++ b/internal/delta/manifest.go @@ -40,10 +40,22 @@ const TmpSuffix = ".opensave.tmp" // bookkeeping. And that folder is under Program Files, where writing needs // rights OpenSave does not have, so the copy failed with "access denied" on // every pass, from every device. +// +// Left out where decisions are made — what to sync, what a version is, what +// a snapshot holds — and NOT from the manifest a device builds and serves: a +// device on an older build would read the gap as the file deleted, and +// delete its own copy (the same reasoning as exclusion rules, +// syncengine/ignore.go). func NeverSynced(rel string) bool { return strings.EqualFold(path.Base(rel), "remotecache.vdf") } +// NeverSyncedList names what NeverSynced leaves out. It goes into the tag of +// a version's hash, so changing the list re-takes the hash quietly on every +// device, like changing a game's exclusion rules — instead of every device +// calling the same save a new version of its own at once. +const NeverSyncedList = "remotecache.vdf" + // staleTmpAge is how old a leftover TmpSuffix file must be before the // manifest walk garbage-collects it. Generous enough that a patch actively // writing its temp file is never deleted out from under it. @@ -378,11 +390,6 @@ func buildManifest(root string) (Manifest, error) { } return nil } - // A file no device's save is made of (NeverSynced). - if NeverSynced(rel) { - return nil - } - entry, err := cachedHashFile(path, walkInfo) if err != nil { // Deleted after the walk listed it and before it could be read. diff --git a/internal/delta/neversynced_test.go b/internal/delta/neversynced_test.go index 615e18e4..1bcf8c21 100644 --- a/internal/delta/neversynced_test.go +++ b/internal/delta/neversynced_test.go @@ -1,54 +1,17 @@ package delta -import ( - "os" - "path/filepath" - "testing" -) +import "testing" -// Steam rewrites remotecache.vdf in a game's userdata folder on every start, -// and that folder can sit under Program Files where it cannot be written. It -// is the launcher's file, not the save: it must never show up as a change. -func TestBuildManifestLeavesOutLauncherFiles(t *testing.T) { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "remote"), 0o755); err != nil { - t.Fatal(err) - } - for name, data := range map[string]string{ - "remote/save.sav": "save", - "remotecache.vdf": "steam", - "sub/RemoteCache.VDF": "steam", +func TestNeverSynced(t *testing.T) { + for p, want := range map[string]bool{ + "remotecache.vdf": true, + "remote/RemoteCache.VDF": true, + "remote/save.sav": false, + "remotecache.vdf.bak": false, + "myremotecache.vdf": false, } { - p := filepath.Join(dir, filepath.FromSlash(name)) - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(p, []byte(data), 0o644); err != nil { - t.Fatal(err) + if got := NeverSynced(p); got != want { + t.Errorf("NeverSynced(%q) = %v, want %v", p, got, want) } } - m, err := BuildManifest(dir) - if err != nil { - t.Fatal(err) - } - if _, ok := m.Files["remote/save.sav"]; !ok { - t.Fatalf("the save is missing: %v", m.Files) - } - for p := range m.Files { - if NeverSynced(p) { - t.Errorf("%s is in the manifest", p) - } - } - before := m.ManifestHash() - InvalidateRoot(dir) - if err := os.WriteFile(filepath.Join(dir, "remotecache.vdf"), []byte("steam started again"), 0o644); err != nil { - t.Fatal(err) - } - m2, err := BuildManifest(dir) - if err != nil { - t.Fatal(err) - } - if m2.ManifestHash() != before { - t.Error("Steam rewriting remotecache.vdf changed the save's hash") - } } diff --git a/internal/p2p/syncengine/conflict.go b/internal/p2p/syncengine/conflict.go index dfd81581..67a98e7f 100644 --- a/internal/p2p/syncengine/conflict.go +++ b/internal/p2p/syncengine/conflict.go @@ -204,10 +204,9 @@ func (e *Engine) markResolvedLocal(ctx context.Context, gameID string, peer Peer } else { e.Log("info", fmt.Sprintf("could not read %s's save while resolving %q (%v) — nothing is recorded as shared, so nothing it lacks is taken as deleted", peer.Name, gameID, err)) } - if rules := e.rulesFor(gameID); !rules.Empty() { - files = filterPathList(files, rules) - dirs = filterPathList(dirs, rules) - } + rules := e.rulesFor(gameID) + files = filterPathList(files, rules) + dirs = filterPathList(dirs, rules) if err := e.Store.SetSyncState(gameID, peer.ID, files, dirs); err != nil { e.Log("warn", fmt.Sprintf("persist sync lineage failed: %v", err)) } diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 20c86d01..0964ed14 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -580,10 +580,8 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // would propagate a deletion of the very file being protected. ignoreRules := e.rulesFor(gameID) unfilteredLocal, unfilteredRemote := localManifest, remoteData.Manifest - if !ignoreRules.Empty() { - localManifest = filterManifest(localManifest, ignoreRules) - remoteData.Manifest = filterManifest(remoteData.Manifest, ignoreRules) - } + localManifest = filterManifest(localManifest, ignoreRules) + remoteData.Manifest = filterManifest(remoteData.Manifest, ignoreRules) // 3. Existing unresolved conflict blocks further syncing — unless a // version newer than both sides of it has turned up since (version.go): @@ -696,7 +694,7 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // side's UNFILTERED state still hashes to the base then that side has not // changed since, whichever view the base was written in, and its filtered // hash is the same fact expressed in today's terms. - if !ignoreRules.Empty() && agreedHash != "" { + if agreedHash != "" { switch agreedHash { case unfilteredLocal.ManifestHash(): agreedHash = localManifest.ManifestHash() @@ -721,10 +719,9 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // path is still recorded as shared. Leave it there and the decision reads // "we both had this and now I do not" — and propagates a deletion of the // file the rule exists to protect. - if rules := e.rulesFor(gameID); !rules.Empty() { - lineageFiles = filterLineage(lineageFiles, rules) - lineageDirs = filterLineage(lineageDirs, rules) - } + rules := ignoreRules + lineageFiles = filterLineage(lineageFiles, rules) + lineageDirs = filterLineage(lineageDirs, rules) // A side that is merely behind the other has not diverged from it, whatever // the clocks and the base say (OnlyBehind). @@ -748,11 +745,9 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // Excluded paths are filtered out for the same reason the lineage is: a // rule change must never be able to reach across and remove a peer's file. deleted := e.recordedDeletions(gameID, delta.PrimaryRoot) - if rules := e.rulesFor(gameID); !rules.Empty() { - for path := range deleted { - if rules.Match(path) { - delete(deleted, path) - } + for path := range deleted { + if excluded(rules, path) { + delete(deleted, path) } } decision := ComputeWithDeletions(localManifest, remoteData.Manifest, lineageFiles, lineageDirs, agreedHash, deleted) @@ -1025,10 +1020,9 @@ func (e *Engine) persistLineage(gameID, peerID string, local, remote delta.Manif // RefreshLineage rebuilds this from unfiltered manifests, so without a // filter here an excluded file would be written back in — and the next // sync would read it as shared, then as deleted, and propagate that. - if rules := e.rulesFor(gameID); !rules.Empty() { - files = filterPathList(files, rules) - dirs = filterPathList(dirs, rules) - } + rules := e.rulesFor(gameID) + files = filterPathList(files, rules) + dirs = filterPathList(dirs, rules) if err := e.Store.SetSyncState(gameID, peerID, files, dirs); err != nil { e.Log("warn", fmt.Sprintf("persist sync lineage failed: %v", err)) } @@ -1160,9 +1154,7 @@ func (e *Engine) AddConfirmedLineageForRoot(gameID, peerID, root string, files [ // Excluded paths must not enter the record of what both sides hold, for // the same reason persistLineage filters them: the next sync would read an // excluded file as shared, then as deleted, and propagate that. - if rules := e.rulesFor(gameID); !rules.Empty() { - merged = filterPathList(merged, rules) - } + merged = filterPathList(merged, e.rulesFor(gameID)) sort.Strings(merged) if err := e.Store.SetSyncStateForRoot(gameID, peerID, root, merged, existingDirs); err != nil { e.Log("warn", fmt.Sprintf("recording confirmed lineage failed: %v", err)) diff --git a/internal/p2p/syncengine/hold.go b/internal/p2p/syncengine/hold.go index 7060c241..b444156d 100644 --- a/internal/p2p/syncengine/hold.go +++ b/internal/p2p/syncengine/hold.go @@ -85,6 +85,11 @@ type emptiness struct { func (x emptiness) back(held map[string][]string) bool { for name, files := range held { for _, f := range files { + if delta.NeverSynced(f) { + // Recorded by a build that still counted it; never part of + // the save, so not something that has to come back. + continue + } if _, ok := x.present[name][f]; !ok { return false } diff --git a/internal/p2p/syncengine/ignore.go b/internal/p2p/syncengine/ignore.go index 96901a0e..ee3cc8d6 100644 --- a/internal/p2p/syncengine/ignore.go +++ b/internal/p2p/syncengine/ignore.go @@ -42,10 +42,53 @@ func (e *Engine) rulesFor(gameID string) ignore.Rules { return ignore.Parse(game.SyncIgnore) } +// excluded says whether a path takes no part in syncing: a file no save is +// made of on any device (delta.NeverSynced), or one the game's rules exclude. +// The first is handled exactly like the second — the manifest served still +// lists it, the decision never sees it — so a device on an older build, which +// still counts it, is never told it was deleted. +func excluded(rules ignore.Rules, p string) bool { + return delta.NeverSynced(p) || (!rules.Empty() && rules.Match(p)) +} + +func holdsNeverSynced(m delta.Manifest) bool { + for p := range m.Files { + if delta.NeverSynced(p) { + return true + } + } + for _, root := range m.Extra { + for p := range root.Files { + if delta.NeverSynced(p) { + return true + } + } + } + return false +} + +func anyNeverSynced(paths []string) bool { + for _, p := range paths { + if delta.NeverSynced(p) { + return true + } + } + return false +} + +func anyNeverSyncedIn(paths map[string]struct{}) bool { + for p := range paths { + if delta.NeverSynced(p) { + return true + } + } + return false +} + // filterManifest returns a copy with every excluded path removed. The // original is untouched: it is still what gets served to peers. func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { - if rules.Empty() { + if rules.Empty() && !holdsNeverSynced(m) { return m } out := delta.Manifest{ @@ -61,7 +104,7 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { // would be told their saves diverged, over a file neither of them is // syncing. for p, entry := range m.Files { - if rules.Match(p) { + if excluded(rules, p) { continue } out.Files[p] = entry @@ -70,7 +113,7 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { } } for _, d := range m.Dirs { - if rules.Match(d) { + if excluded(rules, d) { continue } out.Dirs = append(out.Dirs, d) @@ -83,13 +126,13 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { for name, root := range m.Extra { sub := delta.RootManifest{Files: make(map[string]delta.FileEntry, len(root.Files))} for p, entry := range root.Files { - if rules.Match(p) { + if excluded(rules, p) { continue } sub.Files[p] = entry } for _, d := range root.Dirs { - if rules.Match(d) { + if excluded(rules, d) { continue } sub.Dirs = append(sub.Dirs, d) @@ -107,12 +150,12 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { // decision reads "we both had this and now I don't" and propagates a deletion // to the peer. func filterLineage(paths map[string]struct{}, rules ignore.Rules) map[string]struct{} { - if rules.Empty() { + if rules.Empty() && !anyNeverSyncedIn(paths) { return paths } out := make(map[string]struct{}, len(paths)) for p := range paths { - if rules.Match(p) { + if excluded(rules, p) { continue } out[p] = struct{}{} @@ -123,12 +166,12 @@ func filterLineage(paths map[string]struct{}, rules ignore.Rules) map[string]str // filterPathList drops excluded paths from a plain list, for the lineage that // is persisted back after a sync. func filterPathList(paths []string, rules ignore.Rules) []string { - if rules.Empty() { + if rules.Empty() && !anyNeverSynced(paths) { return paths } out := make([]string, 0, len(paths)) for _, p := range paths { - if rules.Match(p) { + if excluded(rules, p) { continue } out = append(out, p) diff --git a/internal/p2p/syncengine/multiroot.go b/internal/p2p/syncengine/multiroot.go index c763a459..544e7e53 100644 --- a/internal/p2p/syncengine/multiroot.go +++ b/internal/p2p/syncengine/multiroot.go @@ -114,24 +114,20 @@ func (e *Engine) syncOneRoot(ctx context.Context, gameID string, game store.Game rules := e.rulesFor(gameID) remote := sr.remote unfilteredLocal, unfilteredRemote := local, remote - if !rules.Empty() { - local = filterManifest(local, rules) - remote = filterManifest(remote, rules) - } + local = filterManifest(local, rules) + remote = filterManifest(remote, rules) fileList, dirList, err := e.Store.GetSyncStateForRoot(gameID, peer.ID, sr.root.Name) if err != nil { return err } lineageFiles, lineageDirs := toSet(fileList), toSet(dirList) - if !rules.Empty() { - // Without this an exclusion added to a location that had already - // synced reads as "we both had this file and now I do not", and the - // deletion travels to the peer — destroying the copy the rule was - // written to protect. - lineageFiles = filterLineage(lineageFiles, rules) - lineageDirs = filterLineage(lineageDirs, rules) - } + // Without this an exclusion added to a location that had already + // synced reads as "we both had this file and now I do not", and the + // deletion travels to the peer — destroying the copy the rule was + // written to protect. + lineageFiles = filterLineage(lineageFiles, rules) + lineageDirs = filterLineage(lineageDirs, rules) decision := Compute(local, remote, lineageFiles, lineageDirs) if !decision.HasChanges() { @@ -172,7 +168,7 @@ func (e *Engine) syncOneRoot(ctx context.Context, gameID string, game store.Game // adds a rule. If a side's UNFILTERED state still hashes to the base then // that side has not changed, and its filtered hash says the same thing in // today's terms. Same translation the main folder does. - if !rules.Empty() && base != "" { + if base != "" { switch base { case unfilteredLocal.RootHash(delta.PrimaryRoot): base = local.RootHash(delta.PrimaryRoot) diff --git a/internal/p2p/syncengine/neversynced_test.go b/internal/p2p/syncengine/neversynced_test.go new file mode 100644 index 00000000..33a6def7 --- /dev/null +++ b/internal/p2p/syncengine/neversynced_test.go @@ -0,0 +1,137 @@ +package syncengine + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/opensave/opensave/internal/delta" +) + +// Steam's remotecache.vdf is each device's own bookkeeping: never taken from +// a peer, never pushed, never deleted because the peer lacks it. +func TestNeverSynced_LeftAloneOnBothSides(t *testing.T) { + env := setupEngine(t) + write(t, env.localDir, "save.dat", "progress") + write(t, env.localDir, "remotecache.vdf", "this device's steam") + write(t, env.remoteDir, "save.dat", "progress") + write(t, env.remoteDir, "remotecache.vdf", "the other device's steam") + + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil { + t.Fatalf("SyncWithPeer error = %v", err) + } + if res.Status != "in_sync" { + t.Fatalf("status = %q, want in_sync: the saves are the same", res.Status) + } + got, _ := os.ReadFile(filepath.Join(env.localDir, "remotecache.vdf")) + if string(got) != "this device's steam" { + t.Errorf("this device's remotecache.vdf became %q", got) + } + if got, _ := os.ReadFile(filepath.Join(env.remoteDir, "remotecache.vdf")); string(got) != "the other device's steam" { + t.Errorf("the peer's remotecache.vdf became %q", got) + } +} + +// The served manifest keeps listing it: a device on an older build would read +// the gap as the file deleted, and delete its own. +func TestNeverSynced_StillServed(t *testing.T) { + dir := t.TempDir() + write(t, dir, "remotecache.vdf", "steam") + m, err := delta.BuildManifest(dir) + if err != nil { + t.Fatal(err) + } + if _, ok := m.Files["remotecache.vdf"]; !ok { + t.Fatal("the manifest a device serves left remotecache.vdf out") + } +} + +// A build with a new list of such files re-takes the recorded hash quietly +// when the save is the one it was recorded for, and the next change seen is +// not called a new version over it. A save that did change is left to count. +func TestNeverSynced_AdoptingTheViewIsNotAVersion(t *testing.T) { + env := setupEngine(t) + game, err := env.store.GetGame("game1") + if err != nil { + t.Fatal(err) + } + game.AutoSync = true + if err := env.store.UpdateGame(game); err != nil { + t.Fatal(err) + } + write(t, env.localDir, "save.dat", "progress") + write(t, env.localDir, "remotecache.vdf", "steam") + env.engine.NoteLocalChange("game1") // the first version: named by content + + m, err := delta.BuildManifest(env.localDir) + if err != nil { + t.Fatal(err) + } + // As a build before the list recorded it. + rec, err := env.store.GetGameVersion("game1") + if err != nil || rec.Vector == "" { + t.Fatalf("no version recorded: %v", err) + } + vector := rec.Vector + rec.Hash = versionHashBeforeNeverSynced("", m) + if err := env.store.SaveGameVersion(rec); err != nil { + t.Fatal(err) + } + + env.engine.AdoptNeverSyncedView("game1", m) + env.engine.NoteLocalChange("game1") + rec, _ = env.store.GetGameVersion("game1") + if rec.Vector != vector { + t.Fatalf("version moved from %s to %s over a save nobody changed", vector, rec.Vector) + } + if rec.Hash != env.engine.versionHashOf("game1", m) { + t.Error("the recorded hash was not re-taken") + } + + // Recorded for a save that has since changed: not adopted, so the change + // is a version. + write(t, env.localDir, "save.dat", "more progress") + delta.InvalidateRoot(env.localDir) + changed, _ := delta.BuildManifest(env.localDir) + rec.Hash = versionHashBeforeNeverSynced("", m) + _ = env.store.SaveGameVersion(rec) + env.engine.AdoptNeverSyncedView("game1", changed) + env.engine.NoteLocalChange("game1") + rec, _ = env.store.GetGameVersion("game1") + if rec.Vector == vector { + t.Error("a real change after the upgrade did not become a version") + } +} + +// Held only by the peer: not taken. Held only here: not pushed, and not +// deleted because the peer lacks it. +func TestNeverSynced_OneSidedIsNotADifference(t *testing.T) { + env := setupEngine(t) + write(t, env.localDir, "save.dat", "progress") + write(t, env.remoteDir, "save.dat", "progress") + write(t, env.remoteDir, "RemoteCache.VDF", "only there") + + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil || res.Status != "in_sync" { + t.Fatalf("status = %q, %v; want in_sync", res.Status, err) + } + if _, err := os.Stat(filepath.Join(env.localDir, "RemoteCache.VDF")); err == nil { + t.Error("the peer's remotecache.vdf was pulled") + } + + os.Remove(filepath.Join(env.remoteDir, "RemoteCache.VDF")) + write(t, env.localDir, "remotecache.vdf", "only here") + delta.InvalidateRoot(env.localDir) + res, err = env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil || res.Status != "in_sync" { + t.Fatalf("status = %q, %v; want in_sync", res.Status, err) + } + if _, err := os.Stat(filepath.Join(env.remoteDir, "remotecache.vdf")); err == nil { + t.Error("this device's remotecache.vdf was pushed") + } + if _, err := os.Stat(filepath.Join(env.localDir, "remotecache.vdf")); err != nil { + t.Error("this device's remotecache.vdf was deleted") + } +} diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index 203e72aa..c965f6e6 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -166,9 +166,13 @@ func genesisKey(m delta.Manifest) string { // content hashes, nothing else — not folders, not times. Excluded paths are // left out. func filesKey(m delta.Manifest, rules ignore.Rules) string { + return filesKeyOf(m, func(p string) bool { return excluded(rules, p) }) +} + +func filesKeyOf(m delta.Manifest, leaveOut func(string) bool) string { paths := make([]string, 0, len(m.Files)) for p := range m.Files { - if rules.Empty() || !rules.Match(p) { + if !leaveOut(p) { paths = append(paths, p) } } @@ -193,16 +197,50 @@ func filesKey(m delta.Manifest, rules ignore.Rules) string { // either: it changes the filtered hash on every device at once, and each took // that as a change of its own, so the next sync asked about a divergence // nobody made. A hash taken under other rules is re-taken, not compared -// (sameSaveAs). +// (sameSaveAs). The files no save is made of (delta.NeverSyncedList) are in +// the tag for the same reason. func (e *Engine) versionHashOf(gameID string, primary delta.Manifest) string { text := "" if game, err := e.Store.GetGame(gameID); err == nil { text = game.SyncIgnore } - tag := sha256.Sum256([]byte(text)) + tag := sha256.Sum256([]byte(text + "\x00" + delta.NeverSyncedList)) return hex.EncodeToString(tag[:4]) + ":" + filesKey(primary, ignore.Parse(text)) } +// versionHashBeforeNeverSynced is versionHashOf as builds before +// delta.NeverSynced took it: the rules alone in the tag, those files counted. +func versionHashBeforeNeverSynced(rulesText string, primary delta.Manifest) string { + tag := sha256.Sum256([]byte(rulesText)) + rules := ignore.Parse(rulesText) + return hex.EncodeToString(tag[:4]) + ":" + filesKeyOf(primary, func(p string) bool { + return !rules.Empty() && rules.Match(p) + }) +} + +// AdoptNeverSyncedView re-takes the hash recorded with this device's version +// of a game in the terms of delta.NeverSynced, when the save is the one it was +// recorded for — the files no save is made of aside. Run once, when a build +// with a new list starts, before anything compares: otherwise the only +// difference, which files are counted, reads as the save changed here, and +// every device names the same save a new version of its own at once. +func (e *Engine) AdoptNeverSyncedView(gameID string, primary delta.Manifest) { + game, err := e.Store.GetGame(gameID) + if err != nil { + return + } + e.versionMu.Lock() + defer e.versionMu.Unlock() + gv, err := e.loadVersionLocked(gameID) + if err != nil || gv.rec.GameID == "" || gv.rec.Hash == "" { + return + } + if gv.rec.Hash == versionHashBeforeNeverSynced(game.SyncIgnore, primary) { + gv.rec.Hash = e.versionHashOf(gameID, primary) + _ = e.saveVersionLocked(gv) + } +} + // sameSaveAs reports whether a hash recorded with a version still describes // the save that now hashes to current: the same, or taken under different // exclusion rules, which says nothing about the save. @@ -768,9 +806,7 @@ func (e *Engine) pullVersion(ctx context.Context, game store.Game, peer Peer, return Result{}, true, err } freshHash := e.versionHashOf(gameID, fresh) - if rules := e.rulesFor(gameID); !rules.Empty() { - fresh = filterManifest(fresh, rules) - } + fresh = filterManifest(fresh, e.rulesFor(gameID)) if !sameFiles(fresh, remote.Manifest) { return Result{}, true, fmt.Errorf("taking %s's version of %q did not finish: the save still differs; it is resumed on the next sync", peer.Name, game.Name) } diff --git a/internal/store/marks.go b/internal/store/marks.go new file mode 100644 index 00000000..5928757a --- /dev/null +++ b/internal/store/marks.go @@ -0,0 +1,22 @@ +package store + +import "fmt" + +// Mark returns the value a one-time step was taken for, empty if never. +// See migrations/0040_marks.sql. +func (s *Store) Mark(name string) string { + var v string + if err := s.db.Get(&v, `SELECT value FROM marks WHERE name = ?`, name); err != nil { + return "" + } + return v +} + +// SetMark records that a one-time step was taken for value. +func (s *Store) SetMark(name, value string) error { + if _, err := s.db.Exec(`INSERT INTO marks (name, value) VALUES (?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value`, name, value); err != nil { + return fmt.Errorf("set mark %s: %w", name, err) + } + return nil +} diff --git a/internal/store/migrations/0040_marks.sql b/internal/store/migrations/0040_marks.sql new file mode 100644 index 00000000..e9992819 --- /dev/null +++ b/internal/store/migrations/0040_marks.sql @@ -0,0 +1,8 @@ +-- One-time steps this device has already taken, by name, with the value they +-- were taken for. A step whose value changes in a later build runs again. +-- Used for delta.NeverSyncedList: when the files no save is made of change, +-- the hashes recorded under the old list are re-taken once (daemon.go). +CREATE TABLE marks ( + name TEXT PRIMARY KEY, + value TEXT NOT NULL +); diff --git a/internal/watcher/neversynced_test.go b/internal/watcher/neversynced_test.go new file mode 100644 index 00000000..f3d19a75 --- /dev/null +++ b/internal/watcher/neversynced_test.go @@ -0,0 +1,26 @@ +package watcher + +import ( + "testing" + + "github.com/opensave/opensave/internal/delta" +) + +// Steam rewriting remotecache.vdf is not a change of the save; a value +// recorded by an older build, which counted it, is still recognisable. +func TestContentHashLeavesOutNeverSynced(t *testing.T) { + save := delta.Manifest{Files: map[string]delta.FileEntry{"save.sav": {Hash: "a"}}} + withCache := delta.Manifest{Files: map[string]delta.FileEntry{ + "save.sav": {Hash: "a"}, + "remotecache.vdf": {Hash: "steam"}, + }} + if ContentHash(withCache, "") != ContentHash(save, "") { + t.Error("remotecache.vdf counts toward the content hash") + } + if ContentHash(save, "") != save.ContentHash() { + t.Error("a save without such files hashes differently than before") + } + if ContentHashBeforeNeverSynced(withCache, "") != withCache.ContentHash() { + t.Error("the hash an older build recorded cannot be reproduced") + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index b5bcd055..38d6d439 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -1130,23 +1130,54 @@ func (e *Engine) log(level, msg string) { // against that recorded value, so it has to compute it this way — one // definition, or the question gets two answers. func ContentHash(m delta.Manifest, ignoreRules string) string { - if rules := ignore.Parse(ignoreRules); !rules.Empty() { - m = filterForHash(m, rules) + rules := ignore.Parse(ignoreRules) + leaveOut := func(p string) bool { + return delta.NeverSynced(p) || (!rules.Empty() && rules.Match(p)) } - return m.ContentHash() + if rules.Empty() && !leavesAnyOut(m, leaveOut) { + return m.ContentHash() // the same value, without the copy + } + return filterForHash(m, leaveOut).ContentHash() +} + +func leavesAnyOut(m delta.Manifest, leaveOut func(string) bool) bool { + for p := range m.Files { + if leaveOut(p) { + return true + } + } + for _, root := range m.Extra { + for p := range root.Files { + if leaveOut(p) { + return true + } + } + } + return false +} + +// ContentHashBeforeNeverSynced is ContentHash as builds before +// delta.NeverSynced took it, with those files counted: what a value recorded +// by one of them is compared with, to tell whether the save changed since. +func ContentHashBeforeNeverSynced(m delta.Manifest, ignoreRules string) string { + rules := ignore.Parse(ignoreRules) + if rules.Empty() { + return m.ContentHash() + } + return filterForHash(m, rules.Match).ContentHash() } // filterForHash drops excluded paths before the content hash is taken, so the // value recorded here means the same thing the sync engine means by it. -func filterForHash(m delta.Manifest, rules ignore.Rules) delta.Manifest { +func filterForHash(m delta.Manifest, leaveOut func(string) bool) delta.Manifest { out := delta.Manifest{Files: make(map[string]delta.FileEntry, len(m.Files))} for p, entry := range m.Files { - if !rules.Match(p) { + if !leaveOut(p) { out.Files[p] = entry } } for _, d := range m.Dirs { - if !rules.Match(d) { + if !leaveOut(d) { out.Dirs = append(out.Dirs, d) } } @@ -1155,12 +1186,12 @@ func filterForHash(m delta.Manifest, rules ignore.Rules) delta.Manifest { for name, root := range m.Extra { sub := delta.RootManifest{Files: make(map[string]delta.FileEntry, len(root.Files))} for p, entry := range root.Files { - if !rules.Match(p) { + if !leaveOut(p) { sub.Files[p] = entry } } for _, d := range root.Dirs { - if !rules.Match(d) { + if !leaveOut(d) { sub.Dirs = append(sub.Dirs, d) } } From 9683df7ed8e202f3bc8e69ba7015459f4443d20a Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:32:57 +0200 Subject: [PATCH 39/75] Keep each device's game settings out of sync A game that keeps its settings beside its saves had one PC's graphics settings handed to every other: Crimson Desert's save/user_engine_option_save.xml, among others. The Ludusavi manifest already says which files are a game's settings: it tags them "config", as opposed to "save" - 16,700 entries. The index now keeps those (indexedGame.Config, index format v3), and for each tracked game the ones that lie inside its save folder become exclusion patterns relative to it (presets.DeviceSettings), matched component by component so a template's <storeUserId> wildcard meets the folder actually tracked. An entry tagged both is a save. A pattern that would cover the whole folder is never applied: a wrong exclusion loses saves, a missed one only syncs a setting. They are applied as the game's exclusion rules are, through one function everything that applies rules now takes them from (syncengine.Engine.IgnoreText): never synced in either direction, never deleted on another device, still in every snapshot, and kept on this device by a restore. The game's own rules come after them, so a "!" line brings one back; a new per-game switch (games.sync_device_settings, "Sync these settings between devices too" in the configuration tab, with the files listed) syncs them all. The patterns change without anyone writing a rule - a manifest update, a first start with this build - so at start each game's recorded hashes (last snapshot, version, base agreed with each device) are carried over to the new view where the save is the one they were recorded for (daemon.adoptExclusionView, generalising the never-synced step). Toggling the switch goes through the same path as editing the rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../frontend/src/views/game/ConfigTab.svelte | 42 +++++- internal/api/routes.go | 7 +- internal/api/routes_backup.go | 2 +- internal/api/routes_cloud.go | 2 +- internal/api/routes_savefiles.go | 5 +- internal/api/server.go | 14 +- internal/daemon/cloudsync.go | 4 +- internal/daemon/daemon.go | 46 ++----- internal/daemon/devicesettings.go | 126 ++++++++++++++++++ internal/daemon/devicesettings_test.go | 74 ++++++++++ .../p2p/syncengine/devicesettings_test.go | 94 +++++++++++++ internal/p2p/syncengine/engine.go | 4 + internal/p2p/syncengine/ignore.go | 40 +++++- internal/p2p/syncengine/neversynced_test.go | 4 +- internal/p2p/syncengine/version.go | 59 ++++++-- internal/presets/devicesettings.go | 124 +++++++++++++++++ internal/presets/devicesettings_test.go | 82 ++++++++++++ internal/presets/ludusavi.go | 56 +++++--- internal/store/games.go | 9 +- .../migrations/0041_sync_device_settings.sql | 5 + 20 files changed, 709 insertions(+), 90 deletions(-) create mode 100644 internal/daemon/devicesettings.go create mode 100644 internal/daemon/devicesettings_test.go create mode 100644 internal/p2p/syncengine/devicesettings_test.go create mode 100644 internal/presets/devicesettings.go create mode 100644 internal/presets/devicesettings_test.go create mode 100644 internal/store/migrations/0041_sync_device_settings.sql diff --git a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte index 459703f1..180e2012 100644 --- a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte +++ b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte @@ -25,8 +25,12 @@ autoSync: game.autoSync ?? true, maxSnapshots: game.maxSnapshots ?? 5, maxManualSnapshots: game.maxManualSnapshots ?? 0, - syncIgnore: game.syncIgnore ?? '' + syncIgnore: game.syncIgnore ?? '', + syncDeviceSettings: game.syncDeviceSettings ?? false }; + // The files the game database names as this game's settings rather than its + // save (graphics, resolution): left out of syncing unless asked for. + $: deviceSettings = game.deviceSettings ?? []; let locations = []; // Cover preview: a custom URL wins, else the proxied Steam art for the App @@ -76,7 +80,8 @@ autoSync: cfg.autoSync, maxSnapshots: Number(cfg.maxSnapshots), maxManualSnapshots: Number(cfg.maxManualSnapshots), - syncIgnore: cfg.syncIgnore ?? '' + syncIgnore: cfg.syncIgnore ?? '', + syncDeviceSettings: !!cfg.syncDeviceSettings }) ); } @@ -160,6 +165,24 @@ can't push them out. <strong>0 = keep forever</strong> (the default). </span> </div> + {#if deviceSettings.length > 0} + <div class="field device-block"> + <label for="c-devsettings">Device settings</label> + <ul class="device-settings"> + {#each deviceSettings as p} + <li><code>{p.replace(/^\//, '')}</code></li> + {/each} + </ul> + </div> + <label class="check"> + <input id="c-devsettings" type="checkbox" bind:checked={cfg.syncDeviceSettings} /> + Sync these settings between devices too + </label> + <span class="hint device-hint"> + The game database lists these as this game's settings, not its save, so each device + keeps its own (graphics, resolution). They are still in every snapshot. + </span> + {/if} <SyncIgnoreEditor {game} bind:value={cfg.syncIgnore} severalFolders={locations.length > 0} /> <SaveLocations {game} {runner} bind:locations /> <div class="save"> @@ -206,6 +229,21 @@ .fields .field { margin-bottom: 14px; } + .device-settings { + margin: 4px 0 6px; + padding-left: 18px; + color: var(--text-dim, inherit); + } + .device-settings code { + font-size: 0.9em; + } + .device-block { + margin-bottom: 4px !important; + } + .device-hint { + display: block; + margin-bottom: 14px; + } .hint-warn { color: var(--warn); } diff --git a/internal/api/routes.go b/internal/api/routes.go index c13e5b1c..f7bd3c59 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -380,7 +380,7 @@ func (s *Server) handleUpdateGame(w http.ResponseWriter, r *http.Request) { oldSavePath := game.SavePath oldAutoSync := game.AutoSync - oldIgnore := game.SyncIgnore + oldIgnore := s.Daemon.IgnoreText(game) oldAppID := game.AppID if err := readJSON(r, &game); err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -421,7 +421,7 @@ func (s *Server) handleUpdateGame(w http.ResponseWriter, r *http.Request) { forgetCoverMiss(game.AppID) } - if game.SyncIgnore != oldIgnore { + if s.Daemon.IgnoreText(game) != oldIgnore { // The merge bases were computed over a save that included files the // new rules exclude, so neither device could ever match them again — // which reads as permanent divergence and prompts a conflict on every @@ -457,6 +457,9 @@ func (s *Server) handleUpdateGame(w http.ResponseWriter, r *http.Request) { writeError(w, notFoundToStatus(err), err.Error()) return } + if s.Daemon.IgnoreText(game) != oldIgnore { + s.Daemon.NoteExclusions(game) + } // Re-watch if the save location or autoSync flag changed. if game.SavePath != oldSavePath || game.AutoSync != oldAutoSync { diff --git a/internal/api/routes_backup.go b/internal/api/routes_backup.go index 3d4125d0..0b7df6cd 100644 --- a/internal/api/routes_backup.go +++ b/internal/api/routes_backup.go @@ -723,7 +723,7 @@ func (s *Server) importBackupV2(zr *zip.Reader, manifest *backupManifest, mode s if err == nil && mode == "overwrite" { // A backup file may come from another device: this one's // excluded files stay its own (Manager.RestoreKeeping). - _, err = s.Daemon.Snapshots.RestoreKeeping(gameID, snapID, ignore.Parse(local.SyncIgnore)) + _, err = s.Daemon.Snapshots.RestoreKeeping(gameID, snapID, ignore.Parse(s.Daemon.IgnoreText(local))) res.Path = local.SavePath res.Action = "restored" } else if err == nil { diff --git a/internal/api/routes_cloud.go b/internal/api/routes_cloud.go index d8253cee..05e868bd 100644 --- a/internal/api/routes_cloud.go +++ b/internal/api/routes_cloud.go @@ -346,7 +346,7 @@ func (s *Server) handleCloudRestore(w http.ResponseWriter, r *http.Request) { // A cloud copy may be another device's: this device's excluded files // stay its own (Manager.RestoreKeeping). - if _, err := s.Daemon.Snapshots.RestoreKeeping(gameID, snapID, ignore.Parse(game.SyncIgnore)); err != nil { + if _, err := s.Daemon.Snapshots.RestoreKeeping(gameID, snapID, ignore.Parse(s.Daemon.IgnoreText(game))); err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("downloaded but restore failed: %v", err)) return } diff --git a/internal/api/routes_savefiles.go b/internal/api/routes_savefiles.go index 663b63fc..297df1d8 100644 --- a/internal/api/routes_savefiles.go +++ b/internal/api/routes_savefiles.go @@ -60,10 +60,11 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) { return } - text := game.SyncIgnore if raw, ok := r.URL.Query()["rules"]; ok { - text = raw[0] // present-but-empty means "no rules", not "use the saved ones" + game.SyncIgnore = raw[0] // present-but-empty means "no rules", not "use the saved ones" } + // With the game's device settings, which are left out the same way. + text := s.Daemon.IgnoreText(game) rules := ignore.Parse(text) roots := []struct{ name, path string }{{"", game.SavePath}} diff --git a/internal/api/server.go b/internal/api/server.go index c9dc694a..dc7ad5e6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -567,11 +567,15 @@ func (s *Server) gamePayload(g store.Game) map[string]any { // Whether the art cached for this game is explicit, so the client can // blur it until someone asks to see it. An <img src> cannot read a // response header, so it travels with the game rather than the image. - "coverExplicit": s.CoverIsExplicit(coverKeyFor(g)), - "syncIgnore": g.SyncIgnore, - "branches": branches, - "createdAt": g.CreatedAt, - "lastSyncedWith": lastSyncedWith, + "coverExplicit": s.CoverIsExplicit(coverKeyFor(g)), + "syncIgnore": g.SyncIgnore, + // The files the game database names as this game's settings, left + // out of syncing unless syncDeviceSettings (daemon/devicesettings.go). + "deviceSettings": s.Daemon.DeviceSettings(g), + "syncDeviceSettings": g.SyncDeviceSettings, + "branches": branches, + "createdAt": g.CreatedAt, + "lastSyncedWith": lastSyncedWith, // The save folder is not there — gone, moved, or on a drive not // plugged in. Nothing is watched or synced for it until it is back. "savePathMissing": daemon.SaveFolderMissing(g.SavePath), diff --git a/internal/daemon/cloudsync.go b/internal/daemon/cloudsync.go index 6b4dbbf5..70a55b01 100644 --- a/internal/daemon/cloudsync.go +++ b/internal/daemon/cloudsync.go @@ -401,7 +401,7 @@ func (d *Daemon) currentContentHash(game store.Game) (string, error) { if err != nil { return "", err } - return watcher.ContentHash(m, game.SyncIgnore), nil + return watcher.ContentHash(m, d.IgnoreText(game)), nil } // CheckCloud reads the other devices' heads and acts on the ones ahead of @@ -633,7 +633,7 @@ func (d *Daemon) pullFromCloud(game store.Game, fileName, deviceName string) err } // Another device's save: the files this device keeps for itself stay // its own. See Manager.RestoreKeeping. - if _, err := d.Snapshots.RestoreKeeping(game.ID, snapID, ignore.Parse(game.SyncIgnore)); err != nil { + if _, err := d.Snapshots.RestoreKeeping(game.ID, snapID, ignore.Parse(d.IgnoreText(game))); err != nil { return err } // What is on disk now is that snapshot, so record it as the baseline. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 86ffa570..80680649 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -69,6 +69,10 @@ type Daemon struct { // Play sessions; see sessions.go. sessions sessionState + // Device settings; see devicesettings.go. + devMu sync.Mutex + dev devSettingsCache + // OnCloudOffers receives the saves from other devices' cloud backups // that are waiting for an answer, whenever that list changes, and // OnCloudPulled each one put in place without asking. See cloudsync.go. @@ -168,6 +172,9 @@ func New(opts Options) (*Daemon, error) { d.initSessions() + // A game's device settings are left out of syncing (devicesettings.go). + d.P2P.Sync.DeviceSettingsFor = d.deviceSettingsFor + // A restore or a branch switch rewrites a save folder; nothing syncing it // may read it half-way (syncengine/settle.go). snaps.WriteGate = d.P2P.Sync.Writing @@ -224,7 +231,7 @@ func New(opts Options) (*Daemon, error) { if err != nil { return "" } - return game.SyncIgnore + return d.IgnoreText(game) }, GetLastManifestHash: func(gameID string) (string, error) { game, err := s.GetGame(gameID) @@ -271,41 +278,6 @@ func New(opts Options) (*Daemon, error) { return d, nil } -// adoptNeverSyncedView re-takes, once per change of delta.NeverSyncedList, -// the hashes recorded for every game under the previous list — each only when -// the save is the one it was recorded for, so a real change still counts. -// -// Without it the first build with a new list sees every save holding one of -// those files as changed: an auto-snapshot of a game nobody played, and a new -// version of its own on every device at once, which the devices then disagree -// over. -func (d *Daemon) adoptNeverSyncedView(games []store.Game) { - const mark = "never_synced" - if d.Store.Mark(mark) == delta.NeverSyncedList { - return - } - for _, game := range games { - extra, err := d.Store.GameRootPaths(game.ID) - if err != nil { - extra = nil - } - m, failures, err := delta.BuildMultiManifest(game.SavePath, extra) - if err != nil || len(failures) > 0 { - continue // unreadable now; compared as it is when it is back - } - if game.LastManifestHash != "" && - game.LastManifestHash == watcher.ContentHashBeforeNeverSynced(m, game.SyncIgnore) { - _ = d.Store.SetLastManifestHash(game.ID, watcher.ContentHash(m, game.SyncIgnore)) - } - if d.P2P != nil && d.P2P.Sync != nil { - primary := m - primary.Extra = nil - d.P2P.Sync.AdoptNeverSyncedView(game.ID, primary) - } - } - _ = d.Store.SetMark(mark, delta.NeverSyncedList) -} - // Start begins watching every tracked game with auto-sync enabled. func (d *Daemon) Start() error { // Deletion records expire. Swept once per launch rather than on a timer: @@ -334,7 +306,7 @@ func (d *Daemon) Start() error { delta.SetHashCacheBudgetForGames(len(games)) // Before anything is watched or compared. - d.adoptNeverSyncedView(games) + d.adoptExclusionView(games) for _, game := range games { // Backfill cover art for games tracked before covers existed (or diff --git a/internal/daemon/devicesettings.go b/internal/daemon/devicesettings.go new file mode 100644 index 00000000..ece10d08 --- /dev/null +++ b/internal/daemon/devicesettings.go @@ -0,0 +1,126 @@ +package daemon + +import ( + "strings" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/presets" + "github.com/opensave/opensave/internal/store" + "github.com/opensave/opensave/internal/watcher" +) + +// Device settings: the files the game database names as a game's settings +// rather than its save are left out of syncing (presets/devicesettings.go), +// through the game's exclusion list (syncengine.IgnoreText). +// +// Worked out once per run and kept: the database is read when first needed, +// and a game's answer only changes when its name, App ID or folder does — +// which makes it a different key here. A database update takes effect at the +// next start, where adoptExclusionView carries every recorded hash over. + +type devSettingsCache struct { + loaded bool + db *presets.DeviceSettings + patterns map[string][]string +} + +func (d *Daemon) deviceSettingsFor(game store.Game) []string { + if d.Scanner == nil { + return nil + } + key := game.Name + "\x00" + game.AppID + "\x00" + game.SavePath + d.devMu.Lock() + defer d.devMu.Unlock() + if !d.dev.loaded { + d.dev.db = d.Scanner.DeviceSettings() + d.dev.patterns = map[string][]string{} + d.dev.loaded = true + } + if p, ok := d.dev.patterns[key]; ok { + return p + } + p := d.dev.db.Patterns(game.Name, game.AppID, []string{game.SavePath}) + d.dev.patterns[key] = p + return p +} + +// IgnoreText is the exclusion list a game is synced under (see +// syncengine.Engine.IgnoreText). +func (d *Daemon) IgnoreText(game store.Game) string { + if d.P2P == nil || d.P2P.Sync == nil { + return game.SyncIgnore + } + return d.P2P.Sync.IgnoreText(game) +} + +// DeviceSettings returns the patterns for a game's device settings, whether +// or not it is set to sync them — what the window lists. +func (d *Daemon) DeviceSettings(game store.Game) []string { + return d.deviceSettingsFor(game) +} + +// exclusionsMark records, per game, the exclusion list its recorded hashes +// were last taken under, and the never-synced list of the build that took +// them. +func exclusionsMark(gameID string) string { return "exclusions:" + gameID } + +func exclusionsValue(text string) string { + return delta.NeverSyncedList + "\n--\n" + text +} + +// NoteExclusions records that a game's hashes are now taken under its +// current exclusion list — after the rules were changed and the hashes +// rebased by whoever changed them. +func (d *Daemon) NoteExclusions(game store.Game) { + _ = d.Store.SetMark(exclusionsMark(game.ID), exclusionsValue(d.IgnoreText(game))) +} + +// adoptExclusionView carries every game's recorded hashes over to what the +// game is excluded by now, when that changed without anyone writing a rule: +// a build with a new delta.NeverSyncedList, or the game database naming +// settings files. Each is re-taken only when the save is the one it was +// recorded for, so a real change still counts. +// +// Without it, a build that starts excluding a file sees every save holding +// one as changed: an auto-snapshot of a game nobody played, and a new version +// of its own on every device at once, which the devices then disagree over. +func (d *Daemon) adoptExclusionView(games []store.Game) { + neverSyncedDone := d.Store.Mark("never_synced") == delta.NeverSyncedList + for _, game := range games { + now := d.IgnoreText(game) + oldText, beforeNeverSynced := game.SyncIgnore, !neverSyncedDone + if prev := d.Store.Mark(exclusionsMark(game.ID)); prev != "" { + list, text, _ := strings.Cut(prev, "\n--\n") + if list == delta.NeverSyncedList && text == now { + continue + } + oldText, beforeNeverSynced = text, list != delta.NeverSyncedList + } else if !beforeNeverSynced && oldText == now { + _ = d.Store.SetMark(exclusionsMark(game.ID), exclusionsValue(now)) + continue + } + + extra, err := d.Store.GameRootPaths(game.ID) + if err != nil { + extra = nil + } + m, failures, err := delta.BuildMultiManifest(game.SavePath, extra) + if err != nil || len(failures) > 0 { + continue // unreadable now: tried again at the next start + } + was := watcher.ContentHash(m, oldText) + if beforeNeverSynced { + was = watcher.ContentHashBeforeNeverSynced(m, oldText) + } + if game.LastManifestHash != "" && game.LastManifestHash == was { + _ = d.Store.SetLastManifestHash(game.ID, watcher.ContentHash(m, now)) + } + if d.P2P != nil && d.P2P.Sync != nil { + primary := m + primary.Extra = nil + d.P2P.Sync.AdoptExclusionView(game.ID, primary, oldText, beforeNeverSynced) + } + _ = d.Store.SetMark(exclusionsMark(game.ID), exclusionsValue(now)) + } + _ = d.Store.SetMark("never_synced", delta.NeverSyncedList) +} diff --git a/internal/daemon/devicesettings_test.go b/internal/daemon/devicesettings_test.go new file mode 100644 index 00000000..576bb248 --- /dev/null +++ b/internal/daemon/devicesettings_test.go @@ -0,0 +1,74 @@ +package daemon + +import ( + "os" + "path/filepath" + "testing" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/store" + "github.com/opensave/opensave/internal/watcher" +) + +// When the game database starts naming a game's settings, the hash its last +// snapshot recorded is carried over — only where the save is the one it was +// recorded for — so the watcher does not take the new view for a change and +// snapshot a game nobody played. +func TestAdoptExclusionView_CarriesTheLastSnapshotHashOver(t *testing.T) { + d := newTestDaemon(t) + mk := func(id string, files map[string]string) store.Game { + dir := filepath.Join(t.TempDir(), id) + for name, data := range files { + if err := os.MkdirAll(dir, 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(data), 0o666); err != nil { + t.Fatal(err) + } + } + g := store.Game{ID: id, Name: id, SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5} + if err := d.Store.CreateGame(g); err != nil { + t.Fatal(err) + } + m, err := delta.BuildManifest(dir) + if err != nil { + t.Fatal(err) + } + if err := d.Store.SetLastManifestHash(id, watcher.ContentHash(m, "")); err != nil { + t.Fatal(err) + } + g, _ = d.Store.GetGame(id) + return g + } + same := mk("same", map[string]string{"slot1.sav": "a", "graphics.xml": "4k"}) + changed := mk("changed", map[string]string{"slot1.sav": "a", "graphics.xml": "4k"}) + // A real change since the last snapshot, not yet seen by the watcher. + if err := os.WriteFile(filepath.Join(changed.SavePath, "slot1.sav"), []byte("b"), 0o666); err != nil { + t.Fatal(err) + } + delta.InvalidateRoot(changed.SavePath) + _ = d.Store.SetMark("never_synced", delta.NeverSyncedList) + + d.devMu.Lock() + d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}} + for _, g := range []store.Game{same, changed} { + d.dev.patterns[g.Name+"\x00"+g.AppID+"\x00"+g.SavePath] = []string{"/graphics.xml"} + } + d.devMu.Unlock() + + d.adoptExclusionView([]store.Game{same, changed}) + + hashNow := func(g store.Game) string { + m, _ := delta.BuildManifest(g.SavePath) + return watcher.ContentHash(m, d.IgnoreText(g)) + } + if g, _ := d.Store.GetGame("same"); g.LastManifestHash != hashNow(g) { + t.Error("an unchanged save's last-snapshot hash was not carried over: the watcher will snapshot it") + } + if g, _ := d.Store.GetGame("changed"); g.LastManifestHash == hashNow(g) { + t.Error("a changed save's hash was carried over: its change would never be snapshotted") + } + if d.Store.Mark(exclusionsMark("same")) == "" { + t.Error("nothing recorded: this would run again at every start") + } +} diff --git a/internal/p2p/syncengine/devicesettings_test.go b/internal/p2p/syncengine/devicesettings_test.go new file mode 100644 index 00000000..c9691e07 --- /dev/null +++ b/internal/p2p/syncengine/devicesettings_test.go @@ -0,0 +1,94 @@ +package syncengine + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/ignore" + "github.com/opensave/opensave/internal/store" +) + +func withDeviceSettings(env *engineEnv, patterns ...string) { + env.engine.DeviceSettingsFor = func(store.Game) []string { return patterns } +} + +// Each device keeps its own graphics settings: a differing settings file is +// not a difference, in either direction — until the game is set to sync them. +func TestDeviceSettings_KeptPerDevice(t *testing.T) { + env := setupEngine(t) + withDeviceSettings(env, "/graphics.xml") + write(t, env.localDir, "slot1.sav", "progress") + write(t, env.localDir, "graphics.xml", "4k ultra") + write(t, env.remoteDir, "slot1.sav", "progress") + write(t, env.remoteDir, "graphics.xml", "720p low") + + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil || res.Status != "in_sync" { + t.Fatalf("status = %q, %v; want in_sync", res.Status, err) + } + if got, _ := os.ReadFile(filepath.Join(env.localDir, "graphics.xml")); string(got) != "4k ultra" { + t.Errorf("this device's settings became %q", got) + } + if got, _ := os.ReadFile(filepath.Join(env.remoteDir, "graphics.xml")); string(got) != "720p low" { + t.Errorf("the peer's settings became %q", got) + } + + // A save change still travels; the settings beside it still do not. + write(t, env.remoteDir, "slot1.sav", "more progress") + res, err = env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil || res.Status != "updated" { + t.Fatalf("status = %q, %v; want updated", res.Status, err) + } + if got, _ := os.ReadFile(filepath.Join(env.localDir, "slot1.sav")); string(got) != "more progress" { + t.Errorf("the save did not arrive: %q", got) + } + if got, _ := os.ReadFile(filepath.Join(env.localDir, "graphics.xml")); string(got) != "4k ultra" { + t.Errorf("this device's settings became %q", got) + } + + game, _ := env.store.GetGame("game1") + game.SyncDeviceSettings = true + if env.engine.IgnoreText(game) != game.SyncIgnore { + t.Error("set to sync its settings, the game still leaves them out") + } +} + +// The game's own rules come after the settings, so "!" brings one back. +func TestDeviceSettings_OwnRuleCanBringOneBack(t *testing.T) { + env := setupEngine(t) + withDeviceSettings(env, "/graphics.xml", "/input.cfg") + game, _ := env.store.GetGame("game1") + game.SyncIgnore = "!input.cfg" + rules := env.engine.IgnoreText(game) + if r := ignore.Parse(rules); !r.Match("graphics.xml") || r.Match("input.cfg") { + t.Errorf("rules %q: graphics.xml excluded %v, input.cfg excluded %v", rules, r.Match("graphics.xml"), r.Match("input.cfg")) + } +} + +// Leaving the settings out where they were synced before is not a change of +// the save: the recorded hash is re-taken, no version is named. +func TestDeviceSettings_StartingToLeaveThemOutIsNotAVersion(t *testing.T) { + env := setupEngine(t) + game, _ := env.store.GetGame("game1") + game.AutoSync = true + _ = env.store.UpdateGame(game) + write(t, env.localDir, "slot1.sav", "progress") + write(t, env.localDir, "graphics.xml", "4k ultra") + env.engine.NoteLocalChange("game1") + before, _ := env.store.GetGameVersion("game1") + + withDeviceSettings(env, "/graphics.xml") + m, _ := delta.BuildManifest(env.localDir) + env.engine.AdoptExclusionView("game1", m, "", false) + env.engine.NoteLocalChange("game1") + after, _ := env.store.GetGameVersion("game1") + if after.Vector != before.Vector { + t.Fatalf("version moved from %s to %s", before.Vector, after.Vector) + } + if after.Hash != env.engine.versionHashOf("game1", m) { + t.Error("the recorded hash was not re-taken") + } +} diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 0964ed14..2cce5d6e 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -160,6 +160,10 @@ type Engine struct { // peerVersions: per peer, whether its OpenSave keeps save versions // (PeerNeedsUpdate). peerVersions map[string]bool + // DeviceSettingsFor returns the exclusion patterns for a game's device + // settings (presets/devicesettings.go), set by the daemon; nil leaves + // them syncing. See IgnoreText. + DeviceSettingsFor func(store.Game) []string // unwritable: games whose save folder could not be written, and until // when pulls into it wait (version.go). unwritable map[string]time.Time diff --git a/internal/p2p/syncengine/ignore.go b/internal/p2p/syncengine/ignore.go index ee3cc8d6..dad3e7f0 100644 --- a/internal/p2p/syncengine/ignore.go +++ b/internal/p2p/syncengine/ignore.go @@ -2,6 +2,7 @@ package syncengine import ( "context" + "strings" "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/ignore" @@ -39,7 +40,29 @@ func (e *Engine) rulesFor(gameID string) ignore.Rules { if err != nil { return ignore.Rules{} } - return ignore.Parse(game.SyncIgnore) + return ignore.Parse(e.IgnoreText(game)) +} + +// IgnoreText is the exclusion list a game is synced under: the rules written +// for it, and the files the game database names as its settings +// (DeviceSettingsFor) unless it is set to sync those too. Everything that +// applies a game's rules takes them from here, so the two can never be +// applied by one part of the app and not another. +func (e *Engine) IgnoreText(game store.Game) string { + if game.SyncDeviceSettings || e.DeviceSettingsFor == nil { + return game.SyncIgnore + } + settings := e.DeviceSettingsFor(game) + if len(settings) == 0 { + return game.SyncIgnore + } + text := game.SyncIgnore + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n" + } + // The game's own rules come last, so a "!" line there can still bring a + // settings file back into syncing. + return "# device settings (game database)\n" + strings.Join(settings, "\n") + "\n" + text } // excluded says whether a path takes no part in syncing: a file no save is @@ -91,6 +114,11 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { if rules.Empty() && !holdsNeverSynced(m) { return m } + return filterManifestBy(m, func(p string) bool { return excluded(rules, p) }) +} + +// filterManifestBy is filterManifest with what to leave out given directly. +func filterManifestBy(m delta.Manifest, leaveOut func(string) bool) delta.Manifest { out := delta.Manifest{ Timestamp: m.Timestamp, Files: make(map[string]delta.FileEntry, len(m.Files)), @@ -104,7 +132,7 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { // would be told their saves diverged, over a file neither of them is // syncing. for p, entry := range m.Files { - if excluded(rules, p) { + if leaveOut(p) { continue } out.Files[p] = entry @@ -113,7 +141,7 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { } } for _, d := range m.Dirs { - if excluded(rules, d) { + if leaveOut(d) { continue } out.Dirs = append(out.Dirs, d) @@ -126,13 +154,13 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { for name, root := range m.Extra { sub := delta.RootManifest{Files: make(map[string]delta.FileEntry, len(root.Files))} for p, entry := range root.Files { - if excluded(rules, p) { + if leaveOut(p) { continue } sub.Files[p] = entry } for _, d := range root.Dirs { - if excluded(rules, d) { + if leaveOut(d) { continue } sub.Dirs = append(sub.Dirs, d) @@ -195,5 +223,5 @@ func (e *Engine) FilteredContentHash(gameID string, game store.Game) string { if err != nil { return "" } - return filterManifest(m, ignore.Parse(game.SyncIgnore)).ManifestHash() + return filterManifest(m, ignore.Parse(e.IgnoreText(game))).ManifestHash() } diff --git a/internal/p2p/syncengine/neversynced_test.go b/internal/p2p/syncengine/neversynced_test.go index 33a6def7..f104089b 100644 --- a/internal/p2p/syncengine/neversynced_test.go +++ b/internal/p2p/syncengine/neversynced_test.go @@ -80,7 +80,7 @@ func TestNeverSynced_AdoptingTheViewIsNotAVersion(t *testing.T) { t.Fatal(err) } - env.engine.AdoptNeverSyncedView("game1", m) + env.engine.AdoptExclusionView("game1", m, "", true) env.engine.NoteLocalChange("game1") rec, _ = env.store.GetGameVersion("game1") if rec.Vector != vector { @@ -97,7 +97,7 @@ func TestNeverSynced_AdoptingTheViewIsNotAVersion(t *testing.T) { changed, _ := delta.BuildManifest(env.localDir) rec.Hash = versionHashBeforeNeverSynced("", m) _ = env.store.SaveGameVersion(rec) - env.engine.AdoptNeverSyncedView("game1", changed) + env.engine.AdoptExclusionView("game1", changed, "", true) env.engine.NoteLocalChange("game1") rec, _ = env.store.GetGameVersion("game1") if rec.Vector == vector { diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index c965f6e6..e7f8e96d 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -202,8 +202,13 @@ func filesKeyOf(m delta.Manifest, leaveOut func(string) bool) string { func (e *Engine) versionHashOf(gameID string, primary delta.Manifest) string { text := "" if game, err := e.Store.GetGame(gameID); err == nil { - text = game.SyncIgnore + text = e.IgnoreText(game) } + return versionHashUnder(text, primary) +} + +// versionHashUnder is versionHashOf under a given exclusion list. +func versionHashUnder(text string, primary delta.Manifest) string { tag := sha256.Sum256([]byte(text + "\x00" + delta.NeverSyncedList)) return hex.EncodeToString(tag[:4]) + ":" + filesKey(primary, ignore.Parse(text)) } @@ -218,25 +223,55 @@ func versionHashBeforeNeverSynced(rulesText string, primary delta.Manifest) stri }) } -// AdoptNeverSyncedView re-takes the hash recorded with this device's version -// of a game in the terms of delta.NeverSynced, when the save is the one it was -// recorded for — the files no save is made of aside. Run once, when a build -// with a new list starts, before anything compares: otherwise the only -// difference, which files are counted, reads as the save changed here, and -// every device names the same save a new version of its own at once. -func (e *Engine) AdoptNeverSyncedView(gameID string, primary delta.Manifest) { - game, err := e.Store.GetGame(gameID) - if err != nil { +// AdoptExclusionView re-takes the hash recorded with this device's version of +// a game under what it is excluded by now (IgnoreText), when the save is the +// one it was recorded for under oldText — the files excluded then or now +// aside. Run when the exclusions change without anyone writing a rule (a new +// build's delta.NeverSyncedList, the game database naming settings files), +// before anything compares: otherwise the only difference, which files are +// counted, reads as the save changed here, and every device names the same +// save a new version of its own at once. +// +// beforeNeverSynced says the old hash was taken by a build that predates +// delta.NeverSynced, and counted those files. +func (e *Engine) AdoptExclusionView(gameID string, primary delta.Manifest, oldText string, beforeNeverSynced bool) { + if _, err := e.Store.GetGame(gameID); err != nil { return } + // The base agreed with each device, where it is still this save: the + // two agreed on it, and the files now left out leave them agreeing on + // the rest. One that no longer matches is a real change, left to count. + oldRules := ignore.Parse(oldText) + var oldView delta.Manifest + if beforeNeverSynced && oldRules.Empty() { + oldView = primary + } else if beforeNeverSynced { + oldView = filterManifestBy(primary, func(p string) bool { return !oldRules.Empty() && oldRules.Match(p) }) + } else { + oldView = filterManifest(primary, oldRules) + } + if was, now := oldView.ManifestHash(), filterManifest(primary, e.rulesFor(gameID)).ManifestHash(); was != now { + if peers, err := e.Store.ListPeers(); err == nil { + for _, p := range peers { + if e.Store.GetAgreedHash(gameID, p.ID) == was { + _ = e.Store.SetAgreedHash(gameID, p.ID, now) + } + } + } + } + e.versionMu.Lock() defer e.versionMu.Unlock() gv, err := e.loadVersionLocked(gameID) if err != nil || gv.rec.GameID == "" || gv.rec.Hash == "" { return } - if gv.rec.Hash == versionHashBeforeNeverSynced(game.SyncIgnore, primary) { - gv.rec.Hash = e.versionHashOf(gameID, primary) + was := versionHashUnder(oldText, primary) + if beforeNeverSynced { + was = versionHashBeforeNeverSynced(oldText, primary) + } + if now := e.versionHashOf(gameID, primary); gv.rec.Hash == was && was != now { + gv.rec.Hash = now _ = e.saveVersionLocked(gv) } } diff --git a/internal/presets/devicesettings.go b/internal/presets/devicesettings.go new file mode 100644 index 00000000..a3beec97 --- /dev/null +++ b/internal/presets/devicesettings.go @@ -0,0 +1,124 @@ +package presets + +import ( + "path" + "path/filepath" + "sort" + "strings" +) + +// DeviceSettings answers, for a tracked game, which of the files in its save +// folders are its settings rather than its save: what the Ludusavi manifest +// tags "config" and not "save" (indexedGame.Config). A device's graphics +// settings are the usual case — a game that keeps them beside its saves had +// one PC's resolution and quality handed to every other. +// +// The answer is a list of exclusion patterns, in the syntax of a game's own +// exclusion rules and relative to the save folder they fall in, so it is +// applied by exactly the machinery that applies those: never synced in either +// direction, never deleted on another device, still in every snapshot. +type DeviceSettings struct { + sc *Scanner + byID map[string]indexedGame + byName map[string]indexedGame + protonIx map[string][]string + bases func([]string) []string +} + +// DeviceSettings loads the manifest index once for any number of lookups. +// Nil when there is no index. +func (sc *Scanner) DeviceSettings() *DeviceSettings { + if sc == nil || sc.CacheFile == "" { + return nil + } + idx := sc.loadManifestIndex() + if len(idx) == 0 { + return nil + } + ds := &DeviceSettings{sc: sc, byID: map[string]indexedGame{}, byName: map[string]indexedGame{}} + for _, g := range idx { + if len(g.Config) == 0 { + continue + } + if g.SteamID != "" { + ds.byID[g.SteamID] = g + } + if key := normalizeGameName(g.Name); key != "" { + ds.byName[key] = g + } + } + if sc.goos() == "linux" { + ds.protonIx = sc.protonPrefixIndex() + } + ds.bases = sc.installBaseCandidates() + return ds +} + +// Patterns returns the exclusion patterns for a game's settings that lie +// inside one of roots (its save folders; the first is the main one), each +// relative to the root it is in. Patterns for any other root than the first +// are not returned: a game's rules are written against its main folder. +func (ds *DeviceSettings) Patterns(name, appID string, roots []string) []string { + if ds == nil || len(roots) == 0 { + return nil + } + g, ok := ds.byID[appID] + if !ok || appID == "" { + g, ok = ds.byName[normalizeGameName(stripNameSuffixes(name))] + } + if !ok { + return nil + } + installBases := ds.bases(g.Installs) + seen := map[string]bool{} + var out []string + for _, vars := range ds.sc.ludusaviVarSets(g, ds.protonIx) { + for _, tpl := range g.Config { + for _, expanded := range expandTemplate(tpl, vars, installBases, g.SteamID) { + rel, ok := relativeUnder(roots[0], expanded) + if !ok || coversEverything(rel) || seen[rel] { + continue + } + seen[rel] = true + out = append(out, "/"+rel) + } + } + } + sort.Strings(out) + return out +} + +// relativeUnder returns pattern relative to root when it names something +// strictly inside it, comparing component by component so that a wildcard the +// template had (a store user id) matches the folder actually tracked. +// Case-insensitive, as exclusion rules are. +func relativeUnder(root, pattern string) (string, bool) { + split := func(p string) []string { + p = strings.ToLower(filepath.ToSlash(filepath.Clean(p))) + var parts []string + for _, c := range strings.Split(p, "/") { + if c != "" { + parts = append(parts, c) + } + } + return parts + } + r, p := split(root), split(pattern) + if len(r) == 0 || len(p) <= len(r) { + return "", false + } + for i, rc := range r { + if ok, err := path.Match(p[i], rc); err != nil || !ok { + return "", false + } + } + return strings.Join(p[len(r):], "/"), true +} + +// coversEverything reports a relative pattern that would exclude the whole +// folder — a settings entry naming the folder the save is tracked at. Never +// applied: a wrong exclusion loses saves, a missed one only syncs a setting. +func coversEverything(rel string) bool { + first := strings.SplitN(rel, "/", 2)[0] + return strings.Trim(first, "*") == "" || first == "*.*" +} diff --git a/internal/presets/devicesettings_test.go b/internal/presets/devicesettings_test.go new file mode 100644 index 00000000..019641c4 --- /dev/null +++ b/internal/presets/devicesettings_test.go @@ -0,0 +1,82 @@ +package presets + +import ( + "path/filepath" + "reflect" + "runtime" + "testing" +) + +// Crimson Desert's shape: the save is a per-account folder inside the tracked +// one, and the graphics settings sit beside it. The settings are named; the +// save is not; an entry tagged both counts as save. +func TestDeviceSettings_SettingsInsideTheSaveFolder(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("templates below use Windows placeholders") + } + home := t.TempDir() + t.Setenv("USERPROFILE", home) + t.Setenv("HOME", home) + local := filepath.Join(home, "AppData", "Local") + t.Setenv("LOCALAPPDATA", local) + + sc := manifestScanner(t, ` +Pearl Game: + files: + "<winLocalAppData>/Pearl/CD/save/<storeUserId>": + tags: [save] + "<winLocalAppData>/Pearl/CD/save/user_engine_option_save.xml": + tags: [config] + "<winLocalAppData>/Pearl/CD/save/both.cfg": + tags: [config, save] + "<winLocalAppData>/Pearl/CD/save/*": + tags: [config] + "<winLocalAppData>/Pearl/Elsewhere/settings.ini": + tags: [config] + steam: + id: 123 +`) + ds := sc.DeviceSettings() + if ds == nil { + t.Fatal("no index") + } + root := filepath.Join(local, "Pearl", "CD", "save") + want := []string{"/user_engine_option_save.xml"} + if got := ds.Patterns("Pearl Game", "123", []string{root}); !reflect.DeepEqual(got, want) { + t.Errorf("by App ID: %v, want %v", got, want) + } + if got := ds.Patterns("Pearl Game", "", []string{root}); !reflect.DeepEqual(got, want) { + t.Errorf("by name: %v, want %v", got, want) + } + // Tracked at the per-account folder: the settings are not inside it. + if got := ds.Patterns("Pearl Game", "123", []string{filepath.Join(root, "22202")}); len(got) != 0 { + t.Errorf("at the account folder: %v, want none", got) + } + if got := ds.Patterns("Another Game", "999", []string{root}); len(got) != 0 { + t.Errorf("unknown game: %v", got) + } +} + +func TestRelativeUnder(t *testing.T) { + for _, c := range []struct { + root, pattern, want string + ok bool + }{ + {`C:\Users\A\Saved Games\Game`, `C:\Users\A\Saved Games\Game\game.cfg`, "game.cfg", true}, + {`C:\Users\A/Saved Games/Game`, `c:\users\a\saved games\game\Config\x.ini`, "config/x.ini", true}, + {`C:\Users\A\Game\save\22202`, `C:\Users\A\Game\save\*\opt.xml`, "opt.xml", true}, + {`C:\Users\A\Game\save`, `C:\Users\A\*\save\opt.xml`, "opt.xml", true}, + {`C:\Users\A\Game`, `C:\Users\A\Game`, "", false}, + {`C:\Users\A\Game`, `C:\Users\A\Other\x.ini`, "", false}, + } { + got, ok := relativeUnder(c.root, c.pattern) + if got != c.want || ok != c.ok { + t.Errorf("relativeUnder(%q, %q) = %q, %v; want %q, %v", c.root, c.pattern, got, ok, c.want, c.ok) + } + } + for rel, want := range map[string]bool{"*": true, "*.*": true, "**/x": true, "*.cfg": false, "config": false} { + if coversEverything(rel) != want { + t.Errorf("coversEverything(%q) = %v", rel, !want) + } + } +} diff --git a/internal/presets/ludusavi.go b/internal/presets/ludusavi.go index 7a6ea160..31027066 100644 --- a/internal/presets/ludusavi.go +++ b/internal/presets/ludusavi.go @@ -68,6 +68,11 @@ type indexedGame struct { // index built by an older release, which reads as "none" — the field is // additive, so an index on disk from a previous version still loads. Registry []string `json:"r,omitempty"` + // Config is where the game keeps its settings and nothing else: entries + // the manifest tags "config" and not "save". A device's own graphics + // settings among them; inside a tracked save folder they are left out of + // syncing (DeviceSettingsPatterns). + Config []string `json:"c,omitempty"` } // manifestPaths derives the manifest + index locations from the scanner's @@ -89,7 +94,7 @@ func (sc *Scanner) manifestPaths() (yamlPath, indexPath string) { // reading the file it knows and a newer one builds its own, so a user moving // between the two is never handed an index whose shape their code predates. // Bump this whenever indexedGame gains or loses a field that a scan depends on. -const indexFileName = "ludusavi-index-v2.json" +const indexFileName = "ludusavi-index-v3.json" // scanLudusavi expands the manifest's save-path templates and returns the // locations that actually exist on this machine. @@ -728,13 +733,17 @@ func buildManifestIndex(yamlPath string) []indexedGame { games := make([]indexedGame, 0, len(manifest)) for name, mg := range manifest { - var paths []string + var paths, config []string for tpl, entry := range mg.Files { + if entryIsConfigEntry(tpl, entry) { + config = append(config, tpl) + } if !entryIsSaveEntry(tpl, entry) { continue } paths = append(paths, tpl) } + sort.Strings(config) var regKeys []string for key, entry := range mg.Registry { if !registryEntryIsSave(key, entry) { @@ -763,7 +772,7 @@ func buildManifestIndex(yamlPath string) []indexedGame { continue } sort.Strings(regKeys) // stable index across rebuilds - g := indexedGame{Name: name, Paths: paths, Registry: regKeys, SteamID: steamID} + g := indexedGame{Name: name, Paths: paths, Registry: regKeys, SteamID: steamID, Config: config} for dir := range mg.InstallDir { g.Installs = append(g.Installs, dir) } @@ -799,6 +808,34 @@ func registryEntryIsSave(key string, entry manifestFileEntry) bool { } func entryIsSaveEntry(tpl string, entry manifestFileEntry) bool { + if len(entry.Tags) > 0 && !hasTag(entry, "save") { + return false + } + return entryResolvable(tpl, entry) +} + +// entryIsConfigEntry keeps manifest file entries that are a game's settings +// and nothing else: tagged "config" and not "save". An entry carrying both +// is part of the save, whatever else it holds. +func entryIsConfigEntry(tpl string, entry manifestFileEntry) bool { + if !hasTag(entry, "config") || hasTag(entry, "save") { + return false + } + return entryResolvable(tpl, entry) +} + +func hasTag(entry manifestFileEntry, tag string) bool { + for _, t := range entry.Tags { + if t == tag { + return true + } + } + return false +} + +// entryResolvable reports whether a file template can plausibly resolve on +// a platform this app runs on. +func entryResolvable(tpl string, entry manifestFileEntry) bool { // Placeholders no supported platform can resolve. if strings.Contains(tpl, "<winDir>") || strings.Contains(tpl, "<dataDrive>") { return false @@ -812,19 +849,6 @@ func entryIsSaveEntry(tpl string, entry manifestFileEntry) bool { return false } - if len(entry.Tags) > 0 { - hasSave := false - for _, t := range entry.Tags { - if t == "save" { - hasSave = true - break - } - } - if !hasSave { - return false - } - } - if len(entry.When) == 0 { return true } diff --git a/internal/store/games.go b/internal/store/games.go index 52d9c928..18b23ee6 100644 --- a/internal/store/games.go +++ b/internal/store/games.go @@ -72,7 +72,11 @@ type Game struct { // else — snapshots keep capturing every file, so a restore can never be // the thing that deletes an excluded config. SyncIgnore string `db:"sync_ignore" json:"syncIgnore"` - CreatedAt string `db:"created_at" json:"createdAt"` + // SyncDeviceSettings syncs the files the game database names as the + // game's settings, which are otherwise left out of syncing + // (presets/devicesettings.go). Off unless the user turns it on. + SyncDeviceSettings bool `db:"sync_device_settings" json:"syncDeviceSettings"` + CreatedAt string `db:"created_at" json:"createdAt"` } // CreateGame inserts a new game and its default "main" branch in one @@ -159,7 +163,8 @@ func (s *Store) UpdateGame(g Game) error { exe_path = :exe_path, cover_url = :cover_url, last_manifest_hash = :last_manifest_hash, - sync_ignore = :sync_ignore + sync_ignore = :sync_ignore, + sync_device_settings = :sync_device_settings WHERE id = :id`, g) if err != nil { return fmt.Errorf("update game %s: %w", g.ID, err) diff --git a/internal/store/migrations/0041_sync_device_settings.sql b/internal/store/migrations/0041_sync_device_settings.sql new file mode 100644 index 00000000..a5f9d82e --- /dev/null +++ b/internal/store/migrations/0041_sync_device_settings.sql @@ -0,0 +1,5 @@ +-- Whether a game's device settings (the files the Ludusavi manifest tags as +-- its settings and not its save, presets/devicesettings.go) sync like the rest +-- of its save. Off by default: one PC's resolution and graphics quality are +-- not another's. +ALTER TABLE games ADD COLUMN sync_device_settings INTEGER NOT NULL DEFAULT 0; From 3425ba64217d119c4e87e7196725f7de20a5ce57 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:47:01 +0200 Subject: [PATCH 40/75] Find a game's settings by what they hold, and give back what is a save The game database names the settings files of the games it knows. For the rest, a file is now taken for a game's settings by its contents (presets/detectsettings.go): the vocabulary of one machine's display - resolution, vsync, shadow quality, upscaler - matched inside identifiers split at case changes and separators ("_enableFrameGeneration", "r_materialAniso"), in UTF-8 or UTF-16. Unreal Engine's Config/<platform> folder is taken by convention. Names only add weight, by how reliably the Ludusavi manifest's own labels show them to mean settings: measured over its 16,700 settings and 9,000 save entries, "settings" names 1,521 settings files and 70 saves, ".ini" 1,831 and 137, so a name never decides on its own. A name with a save word in it rules a file out unless the contents are overwhelming, the game database's save locations are never taken, and nor are logs, binaries, long files that are only partly settings, or Syncthing's conflict copies. A file taken for settings that changes like a save is given back for good (verdict "save", which also overrules the game database): - before it is left out, and at every later run, the game's snapshots are asked whether it changed in most of the snapshots in which the rest of the save changed (snapshot/history.go); - while it is left out, every automatic snapshot here and every manifest another device sends count whether it changed when the rest of the save did; three such changes, outnumbering the times it stayed put, give it back (daemon/detectsettings.go). Counted continuously, since retention and duplicate merging leave most games with too few snapshots to judge. Detected files and verdicts are kept per game (settings_files, settings_observations) and applied through the device settings, so the hashes recorded under the old list are carried over as before. The game's file list says why each excluded file is excluded (its own rule, the game database, or detected), and unticking one there syncs it. On this machine: 74 files in 25 games detected, among them Crimson Desert's, DOOM Eternal's and Elden Ring's settings, and 12 games the game database does not cover; no file the snapshot history shows changing with the save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../frontend/src/views/game/ConfigTab.svelte | 24 +- .../src/views/game/SyncIgnoreEditor.svelte | 7 +- internal/api/routes_savefiles.go | 30 ++ internal/daemon/daemon.go | 27 ++ internal/daemon/detectsettings.go | 249 ++++++++++++ internal/daemon/devicesettings.go | 95 ++++- internal/daemon/devicesettings_test.go | 56 ++- internal/p2p/syncengine/engine.go | 7 + internal/presets/detectsettings.go | 361 ++++++++++++++++++ internal/presets/detectsettings_test.go | 65 ++++ internal/presets/devicesettings.go | 2 +- internal/snapshot/history.go | 103 +++++ internal/snapshot/history_test.go | 52 +++ .../store/migrations/0042_settings_files.sql | 31 ++ internal/store/settings_files.go | 83 ++++ 15 files changed, 1169 insertions(+), 23 deletions(-) create mode 100644 internal/daemon/detectsettings.go create mode 100644 internal/presets/detectsettings.go create mode 100644 internal/presets/detectsettings_test.go create mode 100644 internal/snapshot/history.go create mode 100644 internal/snapshot/history_test.go create mode 100644 internal/store/migrations/0042_settings_files.sql create mode 100644 internal/store/settings_files.go diff --git a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte index 180e2012..8d6f8bfb 100644 --- a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte +++ b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte @@ -169,8 +169,14 @@ <div class="field device-block"> <label for="c-devsettings">Device settings</label> <ul class="device-settings"> - {#each deviceSettings as p} - <li><code>{p.replace(/^\//, '')}</code></li> + {#each deviceSettings as s} + <li class:given-back={s.source === 'save'}> + <code>{s.pattern.replace(/^!?\//, '')}</code> + <span class="source"> + {#if s.source === 'database'}game database{:else if s.source === 'detected'}detected + — {s.reason}{:else}syncs again — {s.reason}{/if} + </span> + </li> {/each} </ul> </div> @@ -179,8 +185,10 @@ Sync these settings between devices too </label> <span class="hint device-hint"> - The game database lists these as this game's settings, not its save, so each device - keeps its own (graphics, resolution). They are still in every snapshot. + These are this game's settings, not its save — named by the game database or found by + what they hold — so each device keeps its own (graphics, resolution). They are still in + every snapshot. A file that turns out to change along with the save syncs again by + itself; to sync one yourself, untick it under “Pick from your save folder” below. </span> {/if} <SyncIgnoreEditor {game} bind:value={cfg.syncIgnore} severalFolders={locations.length > 0} /> @@ -237,6 +245,14 @@ .device-settings code { font-size: 0.9em; } + .device-settings .source { + font-size: 0.82em; + color: var(--text-faint); + margin-left: 6px; + } + .device-settings .given-back code { + text-decoration: line-through; + } .device-block { margin-bottom: 4px !important; } diff --git a/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte b/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte index 13d14bd8..742d059f 100644 --- a/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte +++ b/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte @@ -133,7 +133,10 @@ <span class="file-name"> {#if f.location}<span class="file-loc">{f.location} ›</span>{/if}{f.path} </span> - <span class="verdict">{f.excluded ? "won't sync" : 'syncs'}</span> + <span class="verdict" title={f.reason ?? ''}> + {#if !f.excluded}syncs{:else if f.source === 'database'}won't sync · settings (game database){:else if f.source === 'detected'}won't + sync · settings (detected){:else}won't sync{/if} + </span> </label> {/each} </div> @@ -141,6 +144,8 @@ Ticking a file writes the pattern for you, anchored so it can only ever mean that one file. Unticking a file caught by a wildcard adds a <code>!</code> exception rather than deleting the wildcard. + Files marked <em>settings</em> are left out as this game's device settings; unticking + one syncs it after all. {#if severalFolders} A pattern applies to <strong>every one of this game's folders</strong>, so a name that appears in two of them is excluded in both. diff --git a/internal/api/routes_savefiles.go b/internal/api/routes_savefiles.go index 297df1d8..7f1ae236 100644 --- a/internal/api/routes_savefiles.go +++ b/internal/api/routes_savefiles.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/go-chi/chi/v5" + "github.com/opensave/opensave/internal/daemon" "github.com/opensave/opensave/internal/ignore" ) @@ -37,6 +38,11 @@ type saveFile struct { MtimeMs int64 `json:"mtimeMs"` // Excluded reports whether the current rules stop this file syncing. Excluded bool `json:"excluded"` + // Why: "rule" (the game's own exclusion list), "database" (the game + // database names it as the game's settings) or "detected" (found to be + // its settings by what it holds). Empty when it syncs. + Source string `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` } // saveFileListCap bounds the response. A save folder with more files than @@ -66,6 +72,25 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) { // With the game's device settings, which are left out the same way. text := s.Daemon.IgnoreText(game) rules := ignore.Parse(text) + ownRules := ignore.Parse(game.SyncIgnore) + var settings []daemon.DeviceSetting + if !game.SyncDeviceSettings { + settings = s.Daemon.DeviceSettings(game) + } + // which says what excludes a file the rules exclude: the game's own + // rules first, as they apply last; then the last device-settings entry + // that names it. + which := func(path string) (string, string) { + if !ownRules.Empty() && ownRules.Match(path) { + return "rule", "" + } + for i := len(settings) - 1; i >= 0; i-- { + if settings[i].Source != "save" && ignore.Parse(settings[i].Pattern).Match(path) { + return settings[i].Source, settings[i].Reason + } + } + return "rule", "" + } roots := []struct{ name, path string }{{"", game.SavePath}} if extra, rootsErr := s.Daemon.Store.ListGameRoots(gameID); rootsErr == nil { @@ -95,6 +120,11 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) { for _, f := range files { f.Location = root.name f.Excluded = rules.Match(f.Path) + if f.Excluded && root.name == "" { + f.Source, f.Reason = which(f.Path) + } else if f.Excluded { + f.Source = "rule" + } out = append(out, f) } } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 80680649..1cabd547 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -174,6 +174,7 @@ func New(opts Options) (*Daemon, error) { // A game's device settings are left out of syncing (devicesettings.go). d.P2P.Sync.DeviceSettingsFor = d.deviceSettingsFor + d.P2P.Sync.ObserveSettings = d.observeSettings // A restore or a branch switch rewrites a save folder; nothing syncing it // may read it half-way (syncengine/settle.go). @@ -255,6 +256,9 @@ func New(opts Options) (*Daemon, error) { // A change made here, by the game or the user: a new version of // the save, recorded before anyone is offered it. d.P2P.Sync.NoteLocalChange(gameID) + // How the files left out as its settings changed with it + // (detectsettings.go). + d.observeLocalSettings(gameID) // An emptied save is noticed as it happens, and said on screen, // even with no other device online to hold it back from. _, _ = d.P2P.Sync.CheckHold(gameID, false) @@ -398,6 +402,29 @@ func (d *Daemon) Start() error { } }) + // Find each game's settings by what they hold (detectsettings.go): a few + // minutes after start, then hourly for whatever is due. + d.P2P.GoSync(func(ctx context.Context) { + first := time.NewTimer(newGameFirstScan) + defer first.Stop() + select { + case <-ctx.Done(): + return + case <-first.C: + d.detectSettingsDue(ctx) + } + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + d.detectSettingsDue(ctx) + } + } + }) + // Look for newly installed games: a few minutes after start, then every // hour. See newgames.go. d.P2P.GoSync(func(ctx context.Context) { diff --git a/internal/daemon/detectsettings.go b/internal/daemon/detectsettings.go new file mode 100644 index 00000000..4c656959 --- /dev/null +++ b/internal/daemon/detectsettings.go @@ -0,0 +1,249 @@ +package daemon + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/ignore" + "github.com/opensave/opensave/internal/store" +) + +// Finding a game's settings by what they hold, and giving back what turns out +// to be a save. +// +// Detection (presets/detectsettings.go) takes a file for settings by its +// contents. Before anything is left out, and again at every later run, the +// game's snapshots are asked how the file behaved (snapshot/history.go). And +// as long as a file is left out, how it behaves is watched: in this device's +// automatic snapshots, and in what every other device sends (observeSettings). +// A settings file is changed now and then; a save changes with the sessions +// played. One that changes along with the rest of the save in most of them is +// a save after all: it syncs again, for good, whatever named it — this +// detection or the game database — and the log says which and why. +// +// Errors cost little either way. A settings file taken for a save syncs, as +// every file did before. A save taken for settings is still in every +// snapshot, and comes back to syncing at the latest when it has been seen to +// change with the save a few times. + +// detectorVersion moves when detection changes, so every game is judged again. +const detectorVersion = "1" + +const ( + observeMinMoved = 3 // changes along with the save before a file is given back + detectEvery = 24 * time.Hour +) + +func settingsDetectMark(gameID string) string { return "settings_detect:" + gameID } + +// detectSettingsDue runs detection for every game not judged under this +// detector and folder yet, or not for a day. In the background, from Start. +func (d *Daemon) detectSettingsDue(ctx context.Context) { + games, err := d.Store.ListGames() + if err != nil { + return + } + for _, game := range games { + if ctx.Err() != nil { + return + } + mark := d.Store.Mark(settingsDetectMark(game.ID)) + want := detectorVersion + "|" + game.SavePath + if prev, at, ok := strings.Cut(mark, "@"); ok && prev == want { + if t, err := time.Parse(time.RFC3339, at); err == nil && time.Since(t) < detectEvery { + continue + } + } + d.DetectSettings(game) + _ = d.Store.SetMark(settingsDetectMark(game.ID), want+"@"+time.Now().UTC().Format(time.RFC3339)) + } +} + +// DetectSettings judges a game's files, records what is new, and re-asks the +// snapshots about everything taken for settings so far. +func (d *Daemon) DetectSettings(game store.Game) { + if d.Scanner == nil || game.SavePath == "" || SaveFolderMissing(game.SavePath) { + return + } + d.devMu.Lock() + db := d.devDBLocked() + d.devMu.Unlock() + + known := map[string]store.SettingsFile{} + if verdicts, err := d.Store.SettingsFiles(game.ID); err == nil { + for _, v := range verdicts { + known[v.Path] = v + } + } + found := db.DetectSettings(game.Name, game.AppID, game.SavePath) + var ask []string + for _, f := range found { + if _, ok := known[f.Path]; !ok { + ask = append(ask, f.Path) + } + } + for p, v := range known { + if v.Verdict == store.VerdictSettings { + ask = append(ask, p) + } + } + saves := d.Snapshots.ChangesLikeASave(game.ID, ask) + + changed := false + var taken, given []string + for _, f := range found { + if _, ok := known[f.Path]; ok { + continue + } + v := store.SettingsFile{GameID: game.ID, Path: f.Path, Verdict: store.VerdictSettings, Reason: f.Reason} + if why, isSave := saves[f.Path]; isSave { + v.Verdict, v.Reason = store.VerdictSave, why + } else { + taken = append(taken, f.Path) + } + if d.Store.SetSettingsFile(v) == nil { + changed = true + } + } + for p, v := range known { + if why, isSave := saves[p]; isSave && v.Verdict == store.VerdictSettings { + v.Verdict, v.Reason, v.AtMs = store.VerdictSave, why, 0 + if d.Store.SetSettingsFile(v) == nil { + changed = true + given = append(given, p+" ("+why+")") + } + } + } + if !changed { + return + } + d.exclusionsChanged(game) + if len(taken) > 0 && !game.SyncDeviceSettings { + d.Log.Log("info", fmt.Sprintf("%q: %s %s this device's settings, not the save — each device keeps its own. "+ + "Untick it under the game's \"Files that shouldn't sync\" to sync it after all.", + game.Name, strings.Join(taken, ", "), isAre(len(taken)))) + } + if len(given) > 0 { + d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.", + game.Name, strings.Join(given, ", "), itThey(len(given)))) + } +} + +// exclusionsChanged carries a game's recorded hashes over to its exclusion +// list as it is now (adoptExclusionView), after detection changed it. +func (d *Daemon) exclusionsChanged(game store.Game) { + d.forgetVerdicts(game.ID) + if fresh, err := d.Store.GetGame(game.ID); err == nil { + game = fresh + } + d.adoptExclusionView([]store.Game{game}) +} + +// observeSettings is told what a device holds of a game — another device's +// manifest as it arrives, or this device's own at an automatic snapshot — and +// keeps count, for every file left out as this game's settings, of how often +// it changed when the rest of the save did. peerID is "" for this device. +func (d *Daemon) observeSettings(game store.Game, peerID string, m delta.Manifest) { + if game.SyncDeviceSettings { + return + } + settings := d.deviceSettingsFor(game) + if len(settings) == 0 { + return + } + asSettings := ignore.Parse(strings.Join(settings, "\n")) + var watched []string + rest := delta.Manifest{Files: map[string]delta.FileEntry{}} + for p, f := range m.Files { + if asSettings.Match(p) { + watched = append(watched, p) + } else { + rest.Files[p] = f + } + } + if len(watched) == 0 { + return + } + user := ignore.Parse(game.SyncIgnore) + for p := range rest.Files { + if delta.NeverSynced(p) || (!user.Empty() && user.Match(p)) { + delete(rest.Files, p) + } + } + restHash := rest.ManifestHash() + + // The rest of the save, as last seen from this device. + r := d.Store.GetSettingsObservation(game.ID, peerID, "") + saveMoved := r.LastHash != "" && r.LastHash != restHash + first := r.LastHash == "" + r.LastHash = restHash + _ = d.Store.SaveSettingsObservation(r) + + var given []string + for _, p := range watched { + o := d.Store.GetSettingsObservation(game.ID, peerID, p) + h := m.Files[p].Hash + if saveMoved && o.LastHash != "" { + if o.LastHash != h { + o.Moved++ + } else { + o.Still++ + } + } + o.LastHash = h + _ = d.Store.SaveSettingsObservation(o) + if first || !saveMoved { + continue + } + moved, still := 0, 0 + if all, err := d.Store.SettingsObservations(game.ID, p); err == nil { + for _, x := range all { + moved += x.Moved + still += x.Still + } + } + if moved >= observeMinMoved && moved > still { + why := fmt.Sprintf("changed in %d of the %d times the save changed", moved, moved+still) + if d.Store.SetSettingsFile(store.SettingsFile{GameID: game.ID, Path: p, Verdict: store.VerdictSave, Reason: why}) == nil { + given = append(given, p+" ("+why+")") + } + } + } + if len(given) > 0 { + d.exclusionsChanged(game) + d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.", + game.Name, strings.Join(given, ", "), itThey(len(given)))) + } +} + +// observeLocalSettings is observeSettings for this device's own save, at an +// automatic snapshot. +func (d *Daemon) observeLocalSettings(gameID string) { + game, err := d.Store.GetGame(gameID) + if err != nil { + return + } + m, err := delta.BuildManifest(filepath.Clean(game.SavePath)) + if err != nil { + return + } + d.observeSettings(game, "", m) +} + +func isAre(n int) string { + if n == 1 { + return "is" + } + return "are" +} + +func itThey(n int) string { + if n == 1 { + return "it is" + } + return "they are" +} diff --git a/internal/daemon/devicesettings.go b/internal/daemon/devicesettings.go index ece10d08..6ef76c1c 100644 --- a/internal/daemon/devicesettings.go +++ b/internal/daemon/devicesettings.go @@ -19,29 +19,92 @@ import ( // next start, where adoptExclusionView carries every recorded hash over. type devSettingsCache struct { - loaded bool - db *presets.DeviceSettings + loaded bool + db *presets.DeviceSettings + // patterns: the game database's, by game name, App ID and folder. patterns map[string][]string + // verdicts: what was found here (detectsettings.go), by game ID. + verdicts map[string][]store.SettingsFile } -func (d *Daemon) deviceSettingsFor(game store.Game) []string { +// devDB returns the game database for device settings, read when first +// needed. Called with devMu held. +func (d *Daemon) devDBLocked() *presets.DeviceSettings { + if !d.dev.loaded { + d.dev.db = d.Scanner.DeviceSettings() + d.dev.loaded = true + } + if d.dev.patterns == nil { + d.dev.patterns = map[string][]string{} + } + if d.dev.verdicts == nil { + d.dev.verdicts = map[string][]store.SettingsFile{} + } + return d.dev.db +} + +// DeviceSetting is one entry of a game's device settings, for the window. +type DeviceSetting struct { + Pattern string `json:"pattern"` + // Source: "database" (the game database names it), "detected" (found by + // what it holds), or "save" (shown to change with the save, so it syncs + // whatever named it). + Source string `json:"source"` + Reason string `json:"reason,omitempty"` +} + +// deviceSettingsList is every entry, in the order they apply: the game +// database's, what was detected here, then what was shown to be a save — +// as "!" lines, which bring a file back whatever named it before. +func (d *Daemon) deviceSettingsList(game store.Game) []DeviceSetting { if d.Scanner == nil { return nil } key := game.Name + "\x00" + game.AppID + "\x00" + game.SavePath d.devMu.Lock() defer d.devMu.Unlock() - if !d.dev.loaded { - d.dev.db = d.Scanner.DeviceSettings() - d.dev.patterns = map[string][]string{} - d.dev.loaded = true + db := d.devDBLocked() + pats, ok := d.dev.patterns[key] + if !ok { + pats = db.Patterns(game.Name, game.AppID, []string{game.SavePath}) + d.dev.patterns[key] = pats + } + verdicts, ok := d.dev.verdicts[game.ID] + if !ok { + verdicts, _ = d.Store.SettingsFiles(game.ID) + d.dev.verdicts[game.ID] = verdicts + } + var out []DeviceSetting + for _, p := range pats { + out = append(out, DeviceSetting{Pattern: p, Source: "database"}) + } + for _, v := range verdicts { + if v.Verdict == store.VerdictSettings { + out = append(out, DeviceSetting{Pattern: "/" + v.Path, Source: "detected", Reason: v.Reason}) + } + } + for _, v := range verdicts { + if v.Verdict == store.VerdictSave { + out = append(out, DeviceSetting{Pattern: "!/" + v.Path, Source: "save", Reason: v.Reason}) + } } - if p, ok := d.dev.patterns[key]; ok { - return p + return out +} + +func (d *Daemon) deviceSettingsFor(game store.Game) []string { + list := d.deviceSettingsList(game) + out := make([]string, 0, len(list)) + for _, s := range list { + out = append(out, s.Pattern) } - p := d.dev.db.Patterns(game.Name, game.AppID, []string{game.SavePath}) - d.dev.patterns[key] = p - return p + return out +} + +// forgetVerdicts drops a game's cached verdicts after they changed. +func (d *Daemon) forgetVerdicts(gameID string) { + d.devMu.Lock() + delete(d.dev.verdicts, gameID) + d.devMu.Unlock() } // IgnoreText is the exclusion list a game is synced under (see @@ -53,10 +116,10 @@ func (d *Daemon) IgnoreText(game store.Game) string { return d.P2P.Sync.IgnoreText(game) } -// DeviceSettings returns the patterns for a game's device settings, whether -// or not it is set to sync them — what the window lists. -func (d *Daemon) DeviceSettings(game store.Game) []string { - return d.deviceSettingsFor(game) +// DeviceSettings returns a game's device settings, whether or not it is set +// to sync them — what the window lists. +func (d *Daemon) DeviceSettings(game store.Game) []DeviceSetting { + return d.deviceSettingsList(game) } // exclusionsMark records, per game, the exclusion list its recorded hashes diff --git a/internal/daemon/devicesettings_test.go b/internal/daemon/devicesettings_test.go index 576bb248..786900f0 100644 --- a/internal/daemon/devicesettings_test.go +++ b/internal/daemon/devicesettings_test.go @@ -1,11 +1,13 @@ package daemon import ( + "fmt" "os" "path/filepath" "testing" "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/ignore" "github.com/opensave/opensave/internal/store" "github.com/opensave/opensave/internal/watcher" ) @@ -50,7 +52,7 @@ func TestAdoptExclusionView_CarriesTheLastSnapshotHashOver(t *testing.T) { _ = d.Store.SetMark("never_synced", delta.NeverSyncedList) d.devMu.Lock() - d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}} + d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}} for _, g := range []store.Game{same, changed} { d.dev.patterns[g.Name+"\x00"+g.AppID+"\x00"+g.SavePath] = []string{"/graphics.xml"} } @@ -72,3 +74,55 @@ func TestAdoptExclusionView_CarriesTheLastSnapshotHashOver(t *testing.T) { t.Error("nothing recorded: this would run again at every start") } } + +// A file taken for settings that another device's copy shows changing with +// every change of the save is given back to syncing; one that stays put while +// the save moves stays left out. +func TestObserveSettings_GivesBackWhatChangesLikeASave(t *testing.T) { + d := newTestDaemon(t) + dir := t.TempDir() + game := store.Game{ID: "g", Name: "g", SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5} + if err := d.Store.CreateGame(game); err != nil { + t.Fatal(err) + } + for _, p := range []string{"progress.cfg", "video.cfg"} { + if err := d.Store.SetSettingsFile(store.SettingsFile{GameID: "g", Path: p, Verdict: store.VerdictSettings, Reason: "test"}); err != nil { + t.Fatal(err) + } + } + d.devMu.Lock() + d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}} + d.devMu.Unlock() + excluded := func(p string) bool { return ignore.Parse(d.IgnoreText(game)).Match(p) } + if !excluded("progress.cfg") || !excluded("video.cfg") { + t.Fatal("setup: the detected files are not left out") + } + + manifest := func(i int, video string) delta.Manifest { + return delta.Manifest{Files: map[string]delta.FileEntry{ + "slot1.sav": {Hash: fmt.Sprint("save", i)}, + "progress.cfg": {Hash: fmt.Sprint("progress", i)}, + "video.cfg": {Hash: video}, + }} + } + d.observeSettings(game, "peerA", manifest(0, "1080p")) + for i := 1; i <= 2; i++ { + d.observeSettings(game, "peerA", manifest(i, "1080p")) + } + if !excluded("progress.cfg") { + t.Fatal("given back after two changes: too eager") + } + d.observeSettings(game, "peerA", manifest(3, "1080p")) + if excluded("progress.cfg") { + t.Error("a file that changed with the save three times out of three is still left out") + } + if !excluded("video.cfg") { + t.Error("a settings file that stayed put was given back") + } + v, _ := d.Store.SettingsFiles("g") + for _, f := range v { + if f.Path == "progress.cfg" && f.Verdict != store.VerdictSave { + t.Errorf("verdict %q, want save", f.Verdict) + } + } +} diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 2cce5d6e..62a2bf8f 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -164,6 +164,10 @@ type Engine struct { // settings (presets/devicesettings.go), set by the daemon; nil leaves // them syncing. See IgnoreText. DeviceSettingsFor func(store.Game) []string + // ObserveSettings is told what another device holds of a game, as its + // manifest arrives, to watch how the files left out as the game's + // settings change (daemon/detectsettings.go). Set by the daemon. + ObserveSettings func(game store.Game, peerID string, remote delta.Manifest) // unwritable: games whose save folder could not be written, and until // when pulls into it wait (version.go). unwritable map[string]time.Time @@ -584,6 +588,9 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // would propagate a deletion of the very file being protected. ignoreRules := e.rulesFor(gameID) unfilteredLocal, unfilteredRemote := localManifest, remoteData.Manifest + if e.ObserveSettings != nil && len(unfilteredRemote.Files) > 0 { + e.ObserveSettings(game, peer.ID, unfilteredRemote) + } localManifest = filterManifest(localManifest, ignoreRules) remoteData.Manifest = filterManifest(remoteData.Manifest, ignoreRules) diff --git a/internal/presets/detectsettings.go b/internal/presets/detectsettings.go new file mode 100644 index 00000000..5fe85f2b --- /dev/null +++ b/internal/presets/detectsettings.go @@ -0,0 +1,361 @@ +package presets + +import ( + "bytes" + "io/fs" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode/utf16" +) + +// Detecting a game's settings files by what they hold, for games the game +// database does not cover (DeviceSettings covers the ones it does). +// +// What tells a device's settings from a save is its contents: resolution, +// vsync, shadow quality, upscaler — the vocabulary of one machine's display, +// which no save has a use for. Names were measured against the Ludusavi +// manifest's own labels (16,700 settings entries, 9,000 save entries) and do +// not tell them apart on their own: "settings" names 1,521 settings files and +// 70 saves, ".ini" 1,831 and 137, ".json" 359 and 216. So a name only adds +// weight to what the contents say, by how reliably the manifest shows it to +// mean settings; a word that names saves rules a file out. +// +// What this misses stays syncing, which is how every file synced before; what +// it wrongly takes is caught by how a save behaves — it changes with every +// session — and given back (daemon/detectsettings.go). + +// DetectedSettings is one file found to be a game's settings. +type DetectedSettings struct { + Path string // relative to the save folder, slash-separated + Reason string +} + +const ( + detectMaxSize = 256 << 10 + detectMaxFiles = 4000 // text-like files read per folder, at most +) + +var detectExts = map[string]bool{ + "ini": true, "cfg": true, "conf": true, "config": true, "xml": true, "json": true, "lua": true, + "txt": true, "yaml": true, "yml": true, "toml": true, "vdf": true, "set": true, "settings": true, + "opt": true, "prefs": true, "local": true, "properties": true, +} + +// Terms of a machine's display and graphics, as sequences of words. Matched +// in identifiers split at case changes and separators, so +// "_enableFrameGeneration", "r_materialAniso" and "Shadow Quality" all count. +var strongTerms = [][]string{ + {"resolution"}, {"fullscreen"}, {"windowed"}, {"borderless"}, {"vsync"}, {"v", "sync"}, + {"antialiasing"}, {"anti", "aliasing"}, {"aniso"}, {"anisotropic"}, {"anisotropy"}, + {"msaa"}, {"fxaa"}, {"taa"}, {"smaa"}, {"ssao"}, {"hbao"}, {"ssr"}, {"bloom"}, {"gamma"}, + {"brightness"}, {"hdr"}, {"dlss"}, {"fsr"}, {"xess"}, {"upscaling"}, {"upscaler"}, + {"monitor"}, {"adapter"}, {"gpu"}, {"vram"}, {"tessellation"}, {"raytracing"}, + {"ray", "tracing"}, {"fov"}, {"field", "view"}, {"lod"}, {"framerate"}, {"frame", "rate"}, + {"refreshrate"}, {"refresh", "rate"}, {"frame", "generation"}, {"frame", "limit"}, + {"fps", "limit"}, {"max", "fps"}, {"texture", "quality"}, {"texture", "filtering"}, + {"shadow", "quality"}, {"shadow", "resolution"}, {"motion", "blur"}, {"depth", "field"}, + {"view", "distance"}, {"draw", "distance"}, {"render", "scale"}, {"resolution", "scale"}, + {"display", "mode"}, {"window", "mode"}, {"screen", "mode"}, {"ambient", "occlusion"}, + {"screen", "width"}, {"screen", "height"}, {"quality", "preset"}, {"graphics", "quality"}, + {"film", "grain"}, {"lens", "flare"}, {"volumetric"}, {"scalability"}, {"supersampling"}, + {"sharpening"}, {"chromatic", "aberration"}, {"vignette"}, {"directx"}, {"vulkan"}, + {"dx11"}, {"dx12"}, {"d3d11"}, {"d3d12"}, +} + +var weakTerms = []string{ + "width", "height", "quality", "preset", "driver", "spec", "shadows", "shadow", "texture", + "textures", "video", "display", "graphics", "render", "screen", "fps", "sync", "filtering", +} + +// Words in a file name, weighted by how reliably the Ludusavi manifest shows +// them to mean settings rather than a save. +var nameWeights = map[string]float64{ + "options": 1.5, "option": 1.5, "graphics": 1.5, "graphic": 1.5, "video": 1.5, "display": 1.5, + "render": 1.5, "engine": 1.5, "scalability": 1.5, "gameusersettings": 1.5, "screen": 1, + "settings": 0.5, "setting": 0.5, "config": 0.5, "configuration": 0.5, "cfg": 0.5, + "prefs": 0.5, "preferences": 0.5, +} + +// Words that name saves somewhere in the manifest often enough that a file +// carrying one is never taken, whatever it holds. Logs are not settings +// either, though leaving them out would harm nothing. +var saveWords = map[string]bool{ + "save": true, "saves": true, "savegame": true, "savedata": true, "slot": true, "progress": true, + "autosave": true, "quicksave": true, "world": true, "player": true, "character": true, + "campaign": true, "level": true, "levels": true, "achievement": true, "achievements": true, + "achievments": true, "stats": true, "unlock": true, "unlocks": true, "inventory": true, + "profile": true, "career": true, "highscore": true, "highscores": true, "score": true, + "scores": true, "records": true, "besttimes": true, "gamedata": true, "log": true, + "logs": true, "crash": true, "output": true, "history": true, +} + +var identRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9]*`) +var camelRe = regexp.MustCompile(`[A-Z]?[a-z]+|[A-Z]+(?:[^a-z]|$)|[0-9]+`) + +// words splits text into lowercase words: identifiers, then at case changes +// ("FrameGeneration" → frame generation), keeping a whole short identifier +// too ("dx11", "vsync"). +func words(text string) []string { + var out []string + for _, id := range identRe.FindAllString(text, -1) { + parts := camelRe.FindAllString(id, -1) + low := strings.ToLower(id) + if len(parts) > 1 && len(low) <= 6 { + out = append(out, low) + } + for _, p := range parts { + p = strings.ToLower(p) + if p == "of" || p == "the" || p == "b" || p == "r" || p == "e" { + continue // filler, and engine prefixes like r_ and bEnable + } + out = append(out, p) + } + } + return out +} + +// textOf returns a file's text, decoding UTF-16, or false for binary. +func textOf(b []byte) (string, bool) { + switch { + case bytes.HasPrefix(b, []byte{0xFF, 0xFE}), bytes.HasPrefix(b, []byte{0xFE, 0xFF}): + big := b[0] == 0xFE + b = b[2:] + u := make([]uint16, len(b)/2) + for i := range u { + if big { + u[i] = uint16(b[2*i])<<8 | uint16(b[2*i+1]) + } else { + u[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i]) + } + } + return string(utf16.Decode(u)), true + case len(b) >= 4 && b[1] == 0 && b[3] == 0 && b[0] != 0 && b[2] != 0: + // UTF-16LE without a mark, as some engines write their XML. + u := make([]uint16, len(b)/2) + for i := range u { + u[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i]) + } + return string(utf16.Decode(u)), true + case bytes.IndexByte(b, 0) >= 0: + return "", false + } + return string(b), true +} + +// contentScore counts the distinct display terms a text holds. +func contentScore(text string) (strong int, score float64) { + ws := words(text) + present := make(map[string]bool, len(ws)) + pairs := make(map[[2]string]bool, len(ws)) + for i, w := range ws { + present[w] = true + if i+1 < len(ws) { + pairs[[2]string{w, ws[i+1]}] = true + } + } + for _, t := range strongTerms { + if (len(t) == 1 && present[t[0]]) || (len(t) == 2 && pairs[[2]string{t[0], t[1]}]) { + strong++ + } + } + weak := 0.0 + for _, w := range weakTerms { + if present[w] { + weak += 0.5 + } + } + if weak > 2 { + weak = 2 + } + return strong, float64(strong) + weak +} + +// nameSignal weighs a file's name: the support it lends, and whether a word +// in it names saves. +func nameSignal(rel string) (weight float64, saveWord, strongName bool) { + base := path.Base(rel) + ext := strings.ToLower(strings.TrimPrefix(path.Ext(base), ".")) + stem := strings.TrimSuffix(base, path.Ext(base)) + ws := words(stem) + low := strings.ToLower(stem) + for w := range nameWeights { + if len(w) >= 6 && strings.Contains(low, w) { + ws = append(ws, w) + } + } + seen := map[string]bool{} + for _, w := range ws { + if seen[w] { + continue + } + seen[w] = true + if saveWords[w] { + saveWord = true + } + weight += nameWeights[w] + if nameWeights[w] >= 1.5 { + strongName = true + } + } + if strings.Contains(low, "save") || strings.Contains(low, "slot") || ext == "log" { + saveWord = true + } + if ext == "ini" || ext == "cfg" || ext == "conf" { + weight += 0.5 + } + if weight > 2 { + weight = 2 + } + return weight, saveWord, strongName +} + +// unrealSettings reports a file in an Unreal Engine game's settings folder, +// Saved/Config/<platform>: the engine writes a machine's settings there and +// nothing else, often as files too short to judge by content. +func unrealSettings(rel string) bool { + segs := strings.Split(strings.ToLower(rel), "/") + if len(segs) < 2 || !strings.HasSuffix(segs[len(segs)-1], ".ini") { + return false + } + for i := 0; i+1 < len(segs)-1; i++ { + if segs[i] == "config" && (strings.HasPrefix(segs[i+1], "windows") || strings.HasPrefix(segs[i+1], "linux") || + strings.HasPrefix(segs[i+1], "mac")) { + return true + } + } + return false +} + +// judgeSettings decides one file from its path and bytes. +func judgeSettings(rel string, b []byte) (bool, string) { + weight, saveWord, strongName := nameSignal(rel) + if saveWord && !strongName { + return false, "" + } + if unrealSettings(rel) && !saveWord { + return true, "Unreal Engine settings folder" + } + text, ok := textOf(b) + if !ok { + return false, "" + } + strong, score := contentScore(text) + switch { + case saveWord: + // "user_engine_option_save.xml": a strong settings word and a save + // word together. Taken only on overwhelming contents. + if strong >= 6 && score+weight >= 8 { + return true, "display settings in its contents" + } + case len(b) > 32<<10: + // A long file holds a lot besides; a save with a settings section is + // long. Only a file that is mostly settings is taken. + if strong >= 6 { + return true, "display settings in its contents" + } + case strong >= 3 && score+weight >= 4: + return true, "display settings in its contents" + case strong >= 2 && weight >= 1.5 && score+weight >= 4: + return true, "display settings in its contents, and its name" + } + return false, "" +} + +// DetectSettings finds the settings files in root by what they hold. The +// game's save locations in the game database (by name or App ID) are never +// taken, whatever they hold. +func (ds *DeviceSettings) DetectSettings(name, appID, root string) []DetectedSettings { + var savePats []string + if ds != nil { + g, ok := ds.byID[appID] + if !ok || appID == "" { + g, ok = ds.byName[normalizeGameName(stripNameSuffixes(name))] + } + if ok { + for _, vars := range ds.sc.ludusaviVarSets(g, ds.protonIx) { + for _, tpl := range g.Paths { + for _, expanded := range expandTemplate(tpl, vars, ds.bases(g.Installs), g.SteamID) { + if rel, ok := relativeUnder(root, expanded); ok { + savePats = append(savePats, rel) + } + } + } + } + } + } + return detectIn(root, savePats) +} + +// isSaveLocation reports whether rel is, or is inside, one of the game +// database's save locations (relative patterns). A location naming a folder +// covers what is in it; one with a file extension names that file. +func isSaveLocation(rel string, savePats []string) bool { + segs := strings.Split(strings.ToLower(rel), "/") + for _, p := range savePats { + ps := strings.Split(p, "/") + if len(ps) > len(segs) { + continue + } + match := true + for i, pc := range ps { + if ok, err := path.Match(pc, segs[i]); err != nil || !ok { + match = false + break + } + } + if !match { + continue + } + if len(ps) < len(segs) || path.Ext(ps[len(ps)-1]) != "" { + return true + } + } + return false +} + +func detectIn(root string, savePats []string) []DetectedSettings { + var out []DetectedSettings + read := 0 + _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if p != root && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + return nil + } + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(p), ".")) + if !detectExts[ext] || read >= detectMaxFiles { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return nil + } + rel = filepath.ToSlash(rel) + if strings.Contains(strings.ToLower(rel), ".sync-conflict-") || isSaveLocation(rel, savePats) { + return nil + } + info, err := d.Info() + if err != nil || info.Size() > detectMaxSize { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return nil + } + read++ + if ok, reason := judgeSettings(rel, b); ok { + out = append(out, DetectedSettings{Path: rel, Reason: reason}) + } + return nil + }) + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} diff --git a/internal/presets/detectsettings_test.go b/internal/presets/detectsettings_test.go new file mode 100644 index 00000000..f499e375 --- /dev/null +++ b/internal/presets/detectsettings_test.go @@ -0,0 +1,65 @@ +package presets + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf16" +) + +func utf16le(s string) []byte { + u := utf16.Encode([]rune(s)) + b := []byte{0xFF, 0xFE} + for _, r := range u { + b = append(b, byte(r), byte(r>>8)) + } + return b +} + +func TestJudgeSettings(t *testing.T) { + crimson := `<EngineOptionSave _selectPreset="Custom"><EngineOptionVideo Name="_videoOption"> +<OptionBool Name="_enableFrameGeneration"/><OptionInt Name="_shadowQuality"/><OptionInt Name="_textureQuality"/> +<OptionBool Name="_vSync"/><OptionInt Name="_displayMode"/><OptionInt Name="_resolution"/><OptionInt Name="_upscaler" Value="DLSS"/> +<OptionFloat Name="_brightness"/><OptionInt Name="_antiAliasing"/></EngineOptionVideo></EngineOptionSave>` + doom := "configVersion 9\nr_materialAniso \"16\"\nr_mode \"18\"\nr_fullscreen \"1\"\nr_swapInterval \"1\"\nr_lodScale \"1\"\nr_gamma \"1.2\"\n" + cases := []struct { + name string + path string + data []byte + want bool + }{ + {"display settings", "videoconfig.txt", []byte("setting.fullscreen 1\nsetting.mat_vsync 0\nsetting.gamma 1\nsetting.defaultres 1920\nsetting.ssao 1\n"), true}, + {"camelCase identifiers, save word outweighed", "user_engine_option_save.xml", []byte(crimson), true}, + {"engine abbreviations", "DOOMEternalConfig.local", []byte(doom), true}, + {"UTF-16", "GraphicsConfig.xml", utf16le(`<config><ScreenMode>1</ScreenMode><Resolution w="1920"/><Vsync>1</Vsync><AntiAliasing>2</AntiAliasing><ShadowQuality>3</ShadowQuality><TextureQuality>3</TextureQuality></config>`), true}, + {"Unreal settings folder", "Saved/Config/WindowsNoEditor/Input.ini", []byte("[/Script/Engine.InputSettings]\n"), true}, + {"save word, weak settings", "savegame.json", []byte(`{"fullscreen":true,"vsync":true,"resolution":"1080p","gold":12}`), false}, + {"progress in a settings-like file", "config.cfg", []byte("level 4\ngold 120\nhealth 80\n"), false}, + {"a log mentioning the GPU", "output_log.txt", []byte("GPU: RTX; resolution 1920; vsync on; fullscreen; monitor 1; hdr off"), false}, + {"binary", "graphics.cfg", []byte{0x01, 0x00, 0x02, 0x00, 0x03}, false}, + {"long save with a settings section", "state.json", []byte(`{"fullscreen":1,"vsync":1,"resolution":1,"gamma":1,"hdr":0,` + strings.Repeat(`"item":1,`, 5000) + `"x":0}`), false}, + } + for _, c := range cases { + if got, _ := judgeSettings(c.path, c.data); got != c.want { + t.Errorf("%s: %s judged settings=%v, want %v", c.name, c.path, got, c.want) + } + } +} + +// The game database's save locations are never taken, whatever they hold. +func TestDetectIn_SaveLocationsAreNeverTaken(t *testing.T) { + root := t.TempDir() + settings := "fullscreen=1\nvsync=1\nresolution=1920x1080\nshadow_quality=3\nanti_aliasing=2\n" + for _, p := range []string{"video.ini", "slots/1/video.ini", "keep/video.ini"} { + full := filepath.Join(root, filepath.FromSlash(p)) + _ = os.MkdirAll(filepath.Dir(full), 0o777) + if err := os.WriteFile(full, []byte(settings), 0o666); err != nil { + t.Fatal(err) + } + } + got := detectIn(root, []string{"slots", "keep/video.ini"}) + if len(got) != 1 || got[0].Path != "video.ini" { + t.Errorf("detected %+v, want only video.ini", got) + } +} diff --git a/internal/presets/devicesettings.go b/internal/presets/devicesettings.go index a3beec97..baaacd95 100644 --- a/internal/presets/devicesettings.go +++ b/internal/presets/devicesettings.go @@ -37,7 +37,7 @@ func (sc *Scanner) DeviceSettings() *DeviceSettings { } ds := &DeviceSettings{sc: sc, byID: map[string]indexedGame{}, byName: map[string]indexedGame{}} for _, g := range idx { - if len(g.Config) == 0 { + if len(g.Config) == 0 && len(g.Paths) == 0 { continue } if g.SteamID != "" { diff --git a/internal/snapshot/history.go b/internal/snapshot/history.go new file mode 100644 index 00000000..b6ebe914 --- /dev/null +++ b/internal/snapshot/history.go @@ -0,0 +1,103 @@ +package snapshot + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" +) + +// How often files changed along with a game's save, read from its +// snapshots: a settings file is changed now and then, when someone changes a +// setting; a save changes with nearly every session. A file taken for a +// game's settings that changes like a save is not its settings +// (daemon/detectsettings.go). + +const ( + historySnapshots = 40 // the most recent ones are read + historyMinSaves = 4 // changes of the save needed before anything is said +) + +// ChangesLikeASave returns, for each of paths (relative to the main save +// folder) that changed in more than half of the snapshots in which the rest +// of the save changed, why it is judged a save. Paths with too little history +// to tell are not in it. +func (m *Manager) ChangesLikeASave(gameID string, paths []string) map[string]string { + out := map[string]string{} + if len(paths) == 0 { + return out + } + watched := make(map[string]bool, len(paths)) + for _, p := range paths { + watched[p] = true + } + snaps, err := m.Store.AllSnapshotsOfGame(gameID) + if err != nil { + return out + } + if len(snaps) > historySnapshots { + snaps = snaps[len(snaps)-historySnapshots:] + } + + // Per snapshot, the hashes of the watched files and one value for the + // rest of the save. + type state struct { + watched map[string]string + rest string + } + var states []state + for _, s := range snaps { + files, err := m.filesOf(s) + if err != nil || len(files) == 0 { + continue + } + st := state{watched: map[string]string{}} + var rest []string + for _, f := range files { + if f.Root != "" { + continue + } + if watched[f.Path] { + st.watched[f.Path] = f.Hash + } else { + rest = append(rest, f.Path+"\x00"+f.Hash) + } + } + st.rest = restKey(rest) + states = append(states, st) + } + + saves := 0 + moved := map[string]int{} + for i := 1; i < len(states); i++ { + a, b := states[i-1], states[i] + if a.rest == b.rest { + continue // the save did not change: nothing to compare with + } + saves++ + for p := range watched { + if a.watched[p] != b.watched[p] { + moved[p]++ + } + } + } + if saves < historyMinSaves { + return out + } + for p, n := range moved { + if 2*n > saves { + out[p] = fmt.Sprintf("changed in %d of the %d snapshots in which the save changed", n, saves) + } + } + return out +} + +func restKey(entries []string) string { + sort.Strings(entries) + h := sha256.New() + for _, e := range entries { + h.Write([]byte(e)) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/snapshot/history_test.go b/internal/snapshot/history_test.go new file mode 100644 index 00000000..1c7ea812 --- /dev/null +++ b/internal/snapshot/history_test.go @@ -0,0 +1,52 @@ +package snapshot + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A file that changes with every change of the save is a save; one changed +// once among many is settings; too little history says nothing. +func TestChangesLikeASave(t *testing.T) { + env := setup(t) + write := func(name, data string) { + if err := os.WriteFile(filepath.Join(env.saveDir, name), []byte(data), 0o666); err != nil { + t.Fatal(err) + } + } + snap := func() { + if _, err := env.mgr.Create("game1", "", false); err != nil { + t.Fatal(err) + } + } + write("graphics.cfg", "resolution=1080") + write("progress.cfg", "level=0") + write("slot1.sav", "0") + snap() + ask := []string{"graphics.cfg", "progress.cfg"} + for i := 1; i <= 3; i++ { + write("slot1.sav", strings.Repeat("x", i)) + write("progress.cfg", "level="+strings.Repeat("1", i)) + snap() + } + if got := env.mgr.ChangesLikeASave("game1", ask); len(got) != 0 { + t.Fatalf("3 changes of the save already judged: %v", got) + } + for i := 4; i <= 6; i++ { + write("slot1.sav", strings.Repeat("x", i)) + write("progress.cfg", "level="+strings.Repeat("1", i)) + if i == 5 { + write("graphics.cfg", "resolution=2160") + } + snap() + } + got := env.mgr.ChangesLikeASave("game1", ask) + if _, ok := got["progress.cfg"]; !ok { + t.Errorf("a file that changed with every save is not judged a save: %v", got) + } + if _, ok := got["graphics.cfg"]; ok { + t.Errorf("a file changed once in six saves is judged a save: %v", got) + } +} diff --git a/internal/store/migrations/0042_settings_files.sql b/internal/store/migrations/0042_settings_files.sql new file mode 100644 index 00000000..46dee16b --- /dev/null +++ b/internal/store/migrations/0042_settings_files.sql @@ -0,0 +1,31 @@ +-- Files in a game's main save folder judged to be its settings rather than its +-- save, by what they hold (presets/detectsettings.go), and files shown to be a +-- save after all — by changing with the save, in this device's snapshots or in +-- what another device sent (daemon/detectsettings.go). +-- +-- verdict 'settings' leaves the file out of syncing, as the game database's +-- settings files are. verdict 'save' keeps it syncing for good: detection never +-- takes it again, and a game database entry that named it is overruled. +-- path is relative to the main save folder, slash-separated. +CREATE TABLE settings_files ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + path TEXT NOT NULL, + verdict TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + at_ms INTEGER NOT NULL, + PRIMARY KEY (game_id, path) +); + +-- What another device's copy of an excluded settings file did, sync by sync: +-- moved counts syncs in which the save changed and so did that copy, still +-- those in which the save changed and it did not. A settings file is changed +-- now and then; a save that was taken for one changes with every session. +CREATE TABLE settings_observations ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + peer_id TEXT NOT NULL, + path TEXT NOT NULL, + last_hash TEXT NOT NULL DEFAULT '', + moved INTEGER NOT NULL DEFAULT 0, + still INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (game_id, peer_id, path) +); diff --git a/internal/store/settings_files.go b/internal/store/settings_files.go new file mode 100644 index 00000000..94794a57 --- /dev/null +++ b/internal/store/settings_files.go @@ -0,0 +1,83 @@ +package store + +import ( + "fmt" + "time" +) + +// Verdicts on a file in a game's save folder (migrations/0042_settings_files.sql). +const ( + VerdictSettings = "settings" + VerdictSave = "save" +) + +// SettingsFile is a verdict on one file of a game's main save folder. +type SettingsFile struct { + GameID string `db:"game_id" json:"-"` + Path string `db:"path" json:"path"` + Verdict string `db:"verdict" json:"verdict"` + Reason string `db:"reason" json:"reason"` + AtMs int64 `db:"at_ms" json:"atMs"` +} + +// SettingsFiles returns every verdict recorded for a game. +func (s *Store) SettingsFiles(gameID string) ([]SettingsFile, error) { + var out []SettingsFile + if err := s.db.Select(&out, `SELECT * FROM settings_files WHERE game_id = ? ORDER BY path`, gameID); err != nil { + return nil, fmt.Errorf("list settings files of %s: %w", gameID, err) + } + return out, nil +} + +// SetSettingsFile records a verdict, replacing any earlier one. +func (s *Store) SetSettingsFile(f SettingsFile) error { + if f.AtMs == 0 { + f.AtMs = time.Now().UnixMilli() + } + if _, err := s.db.NamedExec(`INSERT INTO settings_files (game_id, path, verdict, reason, at_ms) + VALUES (:game_id, :path, :verdict, :reason, :at_ms) + ON CONFLICT(game_id, path) DO UPDATE SET verdict = excluded.verdict, reason = excluded.reason, at_ms = excluded.at_ms`, f); err != nil { + return fmt.Errorf("set settings file %s/%s: %w", f.GameID, f.Path, err) + } + return nil +} + +// SettingsObservation is what one device's copy of an excluded settings file +// has done so far. +type SettingsObservation struct { + GameID string `db:"game_id"` + PeerID string `db:"peer_id"` + Path string `db:"path"` + LastHash string `db:"last_hash"` + Moved int `db:"moved"` + Still int `db:"still"` +} + +// SettingsObservations returns what every device's copy of path has done. +func (s *Store) SettingsObservations(gameID, path string) ([]SettingsObservation, error) { + var out []SettingsObservation + if err := s.db.Select(&out, `SELECT * FROM settings_observations WHERE game_id = ? AND path = ?`, gameID, path); err != nil { + return nil, fmt.Errorf("list settings observations: %w", err) + } + return out, nil +} + +// GetSettingsObservation returns one device's record ({} when none). +func (s *Store) GetSettingsObservation(gameID, peerID, path string) SettingsObservation { + var o SettingsObservation + if err := s.db.Get(&o, `SELECT * FROM settings_observations WHERE game_id = ? AND peer_id = ? AND path = ?`, gameID, peerID, path); err != nil { + return SettingsObservation{GameID: gameID, PeerID: peerID, Path: path} + } + return o +} + +// SaveSettingsObservation writes one device's record. +func (s *Store) SaveSettingsObservation(o SettingsObservation) error { + if _, err := s.db.NamedExec(`INSERT INTO settings_observations (game_id, peer_id, path, last_hash, moved, still) + VALUES (:game_id, :peer_id, :path, :last_hash, :moved, :still) + ON CONFLICT(game_id, peer_id, path) DO UPDATE SET last_hash = excluded.last_hash, + moved = excluded.moved, still = excluded.still`, o); err != nil { + return fmt.Errorf("save settings observation: %w", err) + } + return nil +} From 0441e369754bf5d381174b69b8e4fd759ab1c084 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:31:00 +0200 Subject: [PATCH 41/75] Hold a game being played back from syncing until the session ends A game autosaves every few minutes, and each autosave was synced as it came: on one afternoon a device took eleven versions of Crimson Desert in 26 minutes from the device it was being played on, each with its own "synced from another device" snapshot, and the playing device snapshotted every autosave too. The gameplay guard only waits while save files are locked, and many games do not keep them locked. And a device took other devices' versions of a game running on it, writing a save under the game. While a game is being played on a device (the daemon's play sessions): - it is not synced either way there: SyncGame declines it quietly (ErrPlayingHere, which counts as ErrHeld for callers that only want to know it was not an error), so nothing is taken for a running game; - another device asking for it is answered "playing" (PlayingMessage), both for its manifest and for deletions, and logs that once instead of failing; the answer contains SettlingMessage, so a device on an older build takes it as "busy, ask again"; - the watcher snapshots a change to it at most once per 30 minutes (HoldSnapshot), so a crash still leaves a recent copy without one snapshot per autosave. When the session ends, the existing "After playing" snapshot is taken and the save goes to the other devices in one sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/api/routes_peers.go | 4 +- internal/daemon/daemon.go | 4 ++ internal/daemon/sessions.go | 40 ++++++++++++++++++++ internal/daemon/sessions_test.go | 38 +++++++++++++++++++ internal/p2p/routes.go | 9 +++++ internal/p2p/syncengine/engine.go | 15 +++++++- internal/p2p/syncengine/playing.go | 47 ++++++++++++++++++++++++ internal/p2p/syncengine/playing_test.go | 49 +++++++++++++++++++++++++ internal/p2p/wanclient_handlers.go | 6 +++ internal/watcher/watcher.go | 8 ++++ 10 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 internal/p2p/syncengine/playing.go create mode 100644 internal/p2p/syncengine/playing_test.go diff --git a/internal/api/routes_peers.go b/internal/api/routes_peers.go index 4eb9b594..256cd05b 100644 --- a/internal/api/routes_peers.go +++ b/internal/api/routes_peers.go @@ -243,13 +243,15 @@ func (s *Server) handleProbePeer(w http.ResponseWriter, r *http.Request) { } // syncReason names why a sync did not happen, for a client to act on rather -// than parse the message: "paused" (this device paused syncing), "held" (the +// than parse the message: "paused" (this device paused syncing), "playing" (the game is being played here; it syncs when the session ends), "held" (the // game's save was emptied here and waits for an answer), "offline" (no other // device answered), or "error" for anything else. func syncReason(err error) string { switch { case errors.Is(err, syncengine.ErrPaused): return "paused" + case errors.Is(err, syncengine.ErrPlayingHere): + return "playing" case errors.Is(err, syncengine.ErrHeld): return "held" case errors.Is(err, p2p.ErrNoPeersOnline): diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 1cabd547..eb94dd3a 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -175,6 +175,9 @@ func New(opts Options) (*Daemon, error) { // A game's device settings are left out of syncing (devicesettings.go). d.P2P.Sync.DeviceSettingsFor = d.deviceSettingsFor d.P2P.Sync.ObserveSettings = d.observeSettings + // A game being played here is synced when its session ends + // (syncengine/playing.go). + d.P2P.Sync.Playing = func(gameID string) bool { return !d.PlayingSince(gameID).IsZero() } // A restore or a branch switch rewrites a save folder; nothing syncing it // may read it half-way (syncengine/settle.go). @@ -252,6 +255,7 @@ func New(opts Options) (*Daemon, error) { SyncWritingNow: func(gameID string) bool { return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.WritingNow(gameID) }, + HoldSnapshot: d.holdSnapshotWhilePlaying, OnChanged: func(gameID string) { // A change made here, by the game or the user: a new version of // the save, recorded before anyone is offered it. diff --git a/internal/daemon/sessions.go b/internal/daemon/sessions.go index 67775f99..96ee2f94 100644 --- a/internal/daemon/sessions.go +++ b/internal/daemon/sessions.go @@ -42,6 +42,9 @@ type sessionState struct { list func() ([]sessions.Proc, error) mu sync.Mutex + // checkpoint is when each playing game last had a snapshot taken while + // it was played (holdSnapshotWhilePlaying). + checkpoint map[string]time.Time // startHash is each playing game's save as it was when play began, to // tell afterwards whether the session changed it. startHash map[string]string @@ -365,6 +368,7 @@ func (d *Daemon) sessionEnded(gameID string, started, ended time.Time) { d.sessions.mu.Lock() startHash := d.sessions.startHash[gameID] delete(d.sessions.startHash, gameID) + delete(d.sessions.checkpoint, gameID) seen := d.sessions.seen[gameID] delete(d.sessions.seen, gameID) d.sessions.mu.Unlock() @@ -455,3 +459,39 @@ func (d *Daemon) endOpenSessions() { } } } + +// checkpointEvery is how often a game being played gets a snapshot of its +// save: one per autosave pushed the snapshots worth keeping out, and none +// until the session ends leaves nothing behind a crash. +const checkpointEvery = 30 * time.Minute + +// holdSnapshotWhilePlaying tells the watcher not to snapshot a change to a +// game being played, unless checkpointEvery has passed since play began or +// since the last checkpoint. The session's end keeps the save as it was left +// (sessionEnded). +func (d *Daemon) holdSnapshotWhilePlaying(gameID string) bool { + since := d.PlayingSince(gameID) + if since.IsZero() { + return false + } + d.sessions.mu.Lock() + defer d.sessions.mu.Unlock() + if d.sessions.checkpoint == nil { + d.sessions.checkpoint = map[string]time.Time{} + } + now := time.Now() + if !checkpointDue(since, d.sessions.checkpoint[gameID], now) { + return true + } + d.sessions.checkpoint[gameID] = now + return false +} + +// checkpointDue says whether checkpointEvery has passed since play began, or +// since the last checkpoint when there was one. +func checkpointDue(since, last, now time.Time) bool { + if last.Before(since) { + last = since + } + return now.Sub(last) >= checkpointEvery +} diff --git a/internal/daemon/sessions_test.go b/internal/daemon/sessions_test.go index 362c27c7..02173b1a 100644 --- a/internal/daemon/sessions_test.go +++ b/internal/daemon/sessions_test.go @@ -193,3 +193,41 @@ func TestSpokenLength(t *testing.T) { } } } + +// While a game is played, a change to its save is snapshotted once per +// checkpointEvery, not at every autosave; and it is not synced either way. +func TestSessionHoldsSnapshotsAndSyncWhilePlaying(t *testing.T) { + d := newTestDaemon(t) + g, _ := sessionGame(t, d, "autosaving") + if d.holdSnapshotWhilePlaying(g.ID) { + t.Fatal("held while nothing is being played") + } + d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{{PID: 7, Exe: g.ExePath}}, nil } + d.PollSessions() + if d.PlayingSince(g.ID).IsZero() { + t.Fatal("setup: not playing") + } + if !d.holdSnapshotWhilePlaying(g.ID) { + t.Error("an autosave right after the game started was snapshotted") + } + if !d.P2P.Sync.PlayingHere(g.ID) { + t.Error("the sync engine does not know the game is being played") + } + // Checkpoints: half an hour after play began, then half an hour after + // the last one. + start := time.Now() + for _, c := range []struct { + last time.Time + after time.Duration + want bool + }{ + {time.Time{}, 10 * time.Minute, false}, + {time.Time{}, checkpointEvery, true}, + {start.Add(40 * time.Minute), 50 * time.Minute, false}, + {start.Add(40 * time.Minute), 70 * time.Minute, true}, + } { + if got := checkpointDue(start, c.last, start.Add(c.after)); got != c.want { + t.Errorf("checkpoint due %v after start (last %v) = %v, want %v", c.after, c.last, got, c.want) + } + } +} diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index 290728fa..233eda52 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -712,6 +712,11 @@ func (e *Engine) handleManifest(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusNotFound, syncengine.HeldMessage) return } + // Being played here: synced when the session ends (syncengine/playing.go). + if e.Sync.PlayingHere(game.ID) { + jsonError(w, http.StatusServiceUnavailable, syncengine.PlayingMessage) + return + } // Never describe a save a sync here is writing: part-way through, it is a // mixture no device holds, and the asker would judge it as a save that had // moved (syncengine/settle.go). Held still while it is read; a write in @@ -981,6 +986,10 @@ func (e *Engine) handleDeleteFile(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusNotFound, "Game not found.") return } + if e.Sync.PlayingHere(game.ID) { + jsonError(w, http.StatusServiceUnavailable, syncengine.PlayingMessage) + return + } if game.AutoSync && !body.Versioned { from := clientIP(r) if p, ok := e.peerByAddress(from); ok { diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 62a2bf8f..05bd7458 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -168,6 +168,9 @@ type Engine struct { // manifest arrives, to watch how the files left out as the game's // settings change (daemon/detectsettings.go). Set by the daemon. ObserveSettings func(game store.Game, peerID string, remote delta.Manifest) + // Playing reports whether a game is being played on this device + // (playing.go). Set by the daemon from its play sessions. + Playing func(gameID string) bool // unwritable: games whose save folder could not be written, and until // when pulls into it wait (version.go). unwritable map[string]time.Time @@ -277,6 +280,10 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer if e.Paused != nil && e.Paused() { return nil, ErrPaused } + // A game being played here is synced when the session ends (playing.go). + if e.PlayingHere(gameID) { + return nil, ErrPlayingHere + } // A save emptied here is not synced until someone says whether that was // meant (hold.go). if held, err := e.CheckHold(gameID, false); err == nil && held { @@ -395,7 +402,7 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer // divergence from the NEXT sync, causing the peer to silently overwrite // its own changes instead of detecting the conflict and asking. switch res.Status { - case "conflict", "error", versionStatusWaiting, StatusPeerOutdatedApp, "unwritable": + case "conflict", "error", versionStatusWaiting, StatusPeerOutdatedApp, "unwritable", "peer_playing": case "peer_missing", "peer_awaiting_folder", "peer_holding": // The two devices talked and finished, which is what the // per-device stamp has always recorded. But nothing of THIS game @@ -501,6 +508,12 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // The peer is still writing this game for a sync of its own, and // would otherwise have described a save half-way between two states // (settle.go). SyncGame asks again once it has finished. + // Being played there: its save comes when the session ends, sent by + // that device. Before isSettling, whose message this one contains. + if isPlaying(err) { + e.logOnce(gameID+"|playing|"+peer.ID, fmt.Sprintf("%s is playing %q right now; its save follows when the session ends", peer.Name, game.Name)) + return Result{Status: "peer_playing", PeerID: peer.ID, PeerName: peer.Name}, nil + } if isSettling(err) { e.Log("info", fmt.Sprintf("%s is still applying a sync of %q; asking again when it has finished", peer.Name, game.Name)) return Result{Status: "peer_busy", PeerID: peer.ID, PeerName: peer.Name}, nil diff --git a/internal/p2p/syncengine/playing.go b/internal/p2p/syncengine/playing.go new file mode 100644 index 00000000..c2abda1c --- /dev/null +++ b/internal/p2p/syncengine/playing.go @@ -0,0 +1,47 @@ +package syncengine + +import "strings" + +// A game being played. +// +// A game autosaves every few minutes, and each autosave is a change of its +// save. Synced as they come, every one of them reached every other device as +// a version of its own, and each of those took a snapshot of it: eleven of +// one game in 26 minutes on a device nobody was sitting at. And this device +// took other devices' versions of a game running here, writing a save under +// a game that was still using it. +// +// So while a game is played here (Playing, from the daemon's sessions), it +// is not synced either way. Another device asking for it is told so, and +// asks again later; nothing is taken for it. When the session ends, the save +// as it was left goes to the other devices in one sync (daemon/sessions.go). + +// PlayingMessage is what a device answers when asked for a game being played +// on it. It contains SettlingMessage, so a device on a build that predates +// this takes it as "busy, ask again" too. +const PlayingMessage = "This device is playing this game right now; its save follows when the session ends. " + SettlingMessage + +const playingMark = "is playing this game right now" + +// isPlaying reports whether an error from a peer is its "playing" answer. +func isPlaying(err error) bool { + return err != nil && strings.Contains(err.Error(), playingMark) +} + +// playingErr is SyncGame declining to sync a game being played here. It +// counts as ErrHeld for callers that only want to know it was not an error. +type playingErr struct{} + +func (playingErr) Error() string { + return "it is being played on this device; it syncs when the session ends" +} + +func (playingErr) Is(target error) bool { return target == ErrHeld || target == ErrPlayingHere } + +// ErrPlayingHere is SyncGame declining to sync a game being played here. +var ErrPlayingHere error = playingErr{} + +// PlayingHere reports whether a game is being played on this device. +func (e *Engine) PlayingHere(gameID string) bool { + return e.Playing != nil && e.Playing(gameID) +} diff --git a/internal/p2p/syncengine/playing_test.go b/internal/p2p/syncengine/playing_test.go new file mode 100644 index 00000000..90112fb3 --- /dev/null +++ b/internal/p2p/syncengine/playing_test.go @@ -0,0 +1,49 @@ +package syncengine + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +// A game being played here is not synced either way until the session ends: +// nothing is taken for it, and callers are not told it failed. +func TestPlaying_NotSyncedWhilePlayedHere(t *testing.T) { + env := setupEngine(t) + write(t, env.localDir, "slot1.sav", "being played") + write(t, env.remoteDir, "slot1.sav", "another device's") + playing := true + env.engine.Playing = func(string) bool { return playing } + + _, err := env.engine.SyncGame(context.Background(), "game1", []Peer{env.peer}) + if !errors.Is(err, ErrPlayingHere) || !errors.Is(err, ErrHeld) { + t.Fatalf("err = %v, want ErrPlayingHere, quiet as ErrHeld", err) + } + if got, _ := os.ReadFile(filepath.Join(env.localDir, "slot1.sav")); string(got) != "being played" { + t.Fatalf("the save of a running game was replaced: %q", got) + } + + playing = false + if _, err := env.engine.SyncGame(context.Background(), "game1", []Peer{env.peer}); err != nil { + t.Fatalf("after the session: %v", err) + } +} + +// Another device playing the game answers "playing": nothing is compared or +// stamped, and asking does not count as a failure. +func TestPlaying_PeerPlayingIsWaitedFor(t *testing.T) { + env := setupEngine(t) + write(t, env.localDir, "slot1.sav", "here") + env.transport.manifestErr = errors.New(PlayingMessage) + + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil || res.Status != "peer_playing" { + t.Fatalf("status = %q, %v; want peer_playing", res.Status, err) + } + // A device on an older build reads the same answer as "busy". + if !isSettling(errors.New(PlayingMessage)) { + t.Error("the playing answer is not recognisable as busy by older builds") + } +} diff --git a/internal/p2p/wanclient_handlers.go b/internal/p2p/wanclient_handlers.go index 448f3894..39668b2d 100644 --- a/internal/p2p/wanclient_handlers.go +++ b/internal/p2p/wanclient_handlers.go @@ -604,6 +604,9 @@ func (w *WanClient) serveManifest(route string, body json.RawMessage, peerID str if w.engine.holdingBack(game) { return 404, map[string]string{"error": syncengine.HeldMessage} } + if w.engine.Sync.PlayingHere(game.ID) { + return 503, map[string]string{"error": syncengine.PlayingMessage} + } // As on the LAN: a save a sync here is writing is not described // (syncengine/settle.go). Each request runs on its own goroutine, so the // wait does not hold up the relay traffic that finishes the write. @@ -698,6 +701,9 @@ func (w *WanClient) serveDeleteFile(route string, rawBody json.RawMessage, fromP if err != nil { return 404, map[string]string{"error": "Game not found."} } + if w.engine.Sync.PlayingHere(game.ID) { + return 503, map[string]string{"error": syncengine.PlayingMessage} + } if game.AutoSync && !body.Versioned { from := fromPeerID if p, err := w.engine.Store.GetPeer(fromPeerID); err == nil && p.Name != "" { diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 38d6d439..fd8a0a20 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -126,6 +126,11 @@ type Callbacks struct { // SyncWritingNow reports whether a sync is writing the game at this // moment: the watcher looks at the burst once it has finished. May be nil. SyncWritingNow func(gameID string) bool + // HoldSnapshot reports whether a change should not be snapshotted now: + // the game is being played, and a checkpoint was taken recently. The + // change is not recorded as captured either, so the next one, or the end + // of the session, still sees it. May be nil. + HoldSnapshot func(gameID string) bool } // syncWriting reports whether a sync is or was just writing a game @@ -919,6 +924,9 @@ func (e *Engine) handleChangeFrom(ctx context.Context, gw *gameWatch, ownOnly bo } return } + if e.cb.HoldSnapshot != nil && e.cb.HoldSnapshot(gw.gameID) { + return // being played: kept by the session (a checkpoint, or its end) + } for attempt := 1; attempt <= snapshotMaxRetries; attempt++ { err = e.cb.CreateSnapshot(gw.gameID) From db8ace3b7ab1830348f147a31e5d686a2cc6e483 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:57:50 +0200 Subject: [PATCH 42/75] See protected games running, and handle a save that keeps changing as play A 20-minute session of Crimson Desert was never seen as one: its process keeps its path from everyone (protected against tampering), so the session tracker, which matches running programs by path, saw nothing, and every autosave was snapshotted and synced as it came. - A process whose path cannot be read is listed by its file name, and matched against the programs found in the game's install folders (Target.Programs, three folders deep, installers, crash reporters, redistributables and anti-cheat services left out). - A save that keeps changing while no session is seen is handled as play (daemon/sessions.go, noteActivity): one change on its own is kept and synced at once, as before - a game that saves as it closes; a second within 10 minutes begins a stretch that is not synced either way, with a checkpoint at most every 30 minutes, and once the save has stayed unchanged for 10 minutes it is kept ("After playing") and synced, as a session's end does. Said once in the log, per game, when it happens. Options.SyncEveryChange turns the stretch off for the e2e harness, whose tests of syncing change a save several times a minute and expect every change to travel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/daemon/daemon.go | 7 +- internal/daemon/sessions.go | 216 ++++++++++++++++++++++++++++- internal/daemon/sessions_test.go | 33 +++++ internal/sessions/procs_windows.go | 10 +- internal/sessions/sessions.go | 18 +++ internal/sessions/sessions_test.go | 13 ++ testutil/harness.go | 2 +- 7 files changed, 291 insertions(+), 8 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index eb94dd3a..71614174 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -38,6 +38,11 @@ type Options struct { // DisableDiscovery skips UDP LAN discovery (tests pair peers manually // and don't want broadcast traffic). DisableDiscovery bool + // SyncEveryChange keeps and syncs every change made here as it comes, + // never holding a stretch of changes back as play when no session is seen + // (sessions.go, noteActivity). For tests of syncing itself, which change a + // save several times a minute and expect each change to travel. + SyncEveryChange bool } // Daemon is the assembled core. Access subsystems directly for operations @@ -177,7 +182,7 @@ func New(opts Options) (*Daemon, error) { d.P2P.Sync.ObserveSettings = d.observeSettings // A game being played here is synced when its session ends // (syncengine/playing.go). - d.P2P.Sync.Playing = func(gameID string) bool { return !d.PlayingSince(gameID).IsZero() } + d.P2P.Sync.Playing = func(gameID string) bool { return !d.PlayingSince(gameID).IsZero() || d.changingNow(gameID) } // A restore or a branch switch rewrites a save folder; nothing syncing it // may read it half-way (syncengine/settle.go). diff --git a/internal/daemon/sessions.go b/internal/daemon/sessions.go index 96ee2f94..bf7f4db9 100644 --- a/internal/daemon/sessions.go +++ b/internal/daemon/sessions.go @@ -45,6 +45,14 @@ type sessionState struct { // checkpoint is when each playing game last had a snapshot taken while // it was played (holdSnapshotWhilePlaying). checkpoint map[string]time.Time + // programs: what programsIn found in each install folder. + programs map[string]programList + // active: stretches of changes no session accounts for (noteActivity); + // warned: games already said to change with no program seen. + active map[string]*activity + warned map[string]bool + // lastChange: when each game's save last changed here. + lastChange map[string]time.Time // startHash is each playing game's save as it was when play began, to // tell afterwards whether the session changed it. startHash map[string]string @@ -151,6 +159,9 @@ func (d *Daemon) sessionTargets() ([]sessions.Target, map[string]bool, error) { if dir != "" { t.Dirs = append(t.Dirs, dir) } + for _, dir := range t.Dirs { + t.Programs = append(t.Programs, d.programsIn(dir)...) + } // Steam launches the games it has installed; the rest need a // program to start them, and none was given. if dir != "" && !viaSteam && exe == "" { @@ -365,6 +376,9 @@ func (d *Daemon) sessionStarted(gameID string, at time.Time) { } func (d *Daemon) sessionEnded(gameID string, started, ended time.Time) { + // The session's end keeps the save and syncs it; a stretch of changes + // it overlapped would otherwise hold the sync back for quietAfter more. + d.endActivity(gameID) d.sessions.mu.Lock() startHash := d.sessions.startHash[gameID] delete(d.sessions.startHash, gameID) @@ -466,13 +480,19 @@ func (d *Daemon) endOpenSessions() { const checkpointEvery = 30 * time.Minute // holdSnapshotWhilePlaying tells the watcher not to snapshot a change to a -// game being played, unless checkpointEvery has passed since play began or -// since the last checkpoint. The session's end keeps the save as it was left -// (sessionEnded). +// game being played — in a session, or changing with no session seen +// (noteActivity) — unless checkpointEvery has passed since play began or +// since the last checkpoint. The end of the session, or of the stretch of +// changes, keeps the save as it was left. func (d *Daemon) holdSnapshotWhilePlaying(gameID string) bool { + // Every change made here counts, session seen or not (noteActivity). + start, stretch := d.noteActivity(gameID) since := d.PlayingSince(gameID) if since.IsZero() { - return false + if !stretch { + return false + } + since = start } d.sessions.mu.Lock() defer d.sessions.mu.Unlock() @@ -495,3 +515,191 @@ func checkpointDue(since, last, now time.Time) bool { } return now.Sub(last) >= checkpointEvery } + +// Programs that come with a game without being it: installers, crash +// reporters, redistributables, anti-cheat services. A process known only by +// name is never matched by one of these — every game ships the same few. +var notTheGame = []string{ + "unins", "setup", "install", "crash", "report", "redist", "vcredist", "vc_redist", "dxsetup", + "directx", "prereq", "launcher", "easyanticheat", "eac", "battleye", "beservice", "update", + "patch", "config", "settings", "helper", "service", "cef", "webhelper", "dotnet", "physx", +} + +// programsIn lists the programs in a game's install folder by file name, +// lower-case, for a process known only by name (sessions.Proc.Name). Read at +// most every installDirsFresh, three folders deep. +func (d *Daemon) programsIn(dir string) []string { + d.sessions.mu.Lock() + if c, ok := d.sessions.programs[dir]; ok && time.Since(c.at) < installDirsFresh { + d.sessions.mu.Unlock() + return c.names + } + d.sessions.mu.Unlock() + + var names []string + root := filepath.Clean(dir) + depth := strings.Count(root, string(filepath.Separator)) + _ = filepath.WalkDir(root, func(p string, e os.DirEntry, err error) error { + if err != nil { + return nil + } + if e.IsDir() { + if strings.Count(filepath.Clean(p), string(filepath.Separator))-depth >= 3 { + return filepath.SkipDir + } + return nil + } + name := strings.ToLower(e.Name()) + if !strings.HasSuffix(name, ".exe") || len(names) >= 64 { + return nil + } + for _, w := range notTheGame { + if strings.Contains(name, w) { + return nil + } + } + names = append(names, name) + return nil + }) + + d.sessions.mu.Lock() + if d.sessions.programs == nil { + d.sessions.programs = map[string]programList{} + } + d.sessions.programs[dir] = programList{at: time.Now(), names: names} + d.sessions.mu.Unlock() + return names +} + +type programList struct { + at time.Time + names []string +} + +// quietAfter is how long a game's save must stay unchanged before a stretch +// of changes no session accounts for is over: longer than the gap between +// two autosaves of the games measured (Crimson Desert: up to six minutes). +const quietAfter = 10 * time.Minute + +// activity is a stretch of changes to a game's save while no session of it +// was seen: a game whose program cannot be told apart, or is not installed +// where this device looks. It is handled as a session — not synced either +// way, a checkpoint at most every checkpointEvery — and when the save has +// stayed unchanged for quietAfter, it is kept and synced as a session's end +// would be. +type activity struct { + start, last time.Time + timer *time.Timer +} + +// noteActivity records a change to a game's save made here (not by a sync). +// A change within quietAfter of the one before begins a stretch, or goes on +// with one: it returns when the stretch began, and whether there is one. A +// change on its own — a game that saves once, as it is closed — is not one, +// and is kept and synced straight away, as every change was before. +func (d *Daemon) noteActivity(gameID string) (time.Time, bool) { + now := time.Now() + d.sessions.mu.Lock() + defer d.sessions.mu.Unlock() + if d.sessions.active == nil { + d.sessions.active = map[string]*activity{} + } + if d.sessions.lastChange == nil { + d.sessions.lastChange = map[string]time.Time{} + } + prev, seen := d.sessions.lastChange[gameID] + d.sessions.lastChange[gameID] = now + a := d.sessions.active[gameID] + if a == nil { + if !seen || now.Sub(prev) >= quietAfter || d.opts.SyncEveryChange { + return now, false + } + a = &activity{start: prev} + d.sessions.active[gameID] = a + a.timer = time.AfterFunc(quietAfter, func() { d.activityQuiet(gameID) }) + if d.PlayingSince(gameID).IsZero() && !d.sessions.warned[gameID] { + if d.sessions.warned == nil { + d.sessions.warned = map[string]bool{} + } + d.sessions.warned[gameID] = true + name := gameID + if g, err := d.Store.GetGame(gameID); err == nil { + name = g.Name + } + go d.Log.Log("info", fmt.Sprintf("%q is changing its save, but its program is not seen running here — "+ + "handled as being played: kept and synced once it has not changed for %s", name, spokenLength(quietAfter))) + } + } else { + a.timer.Reset(quietAfter) + } + a.last = now + return a.start, true +} + +// changingNow reports a game in a stretch of changes (noteActivity). +func (d *Daemon) changingNow(gameID string) bool { + d.sessions.mu.Lock() + defer d.sessions.mu.Unlock() + _, ok := d.sessions.active[gameID] + return ok +} + +// endActivity forgets a stretch of changes; a session's end takes over. +func (d *Daemon) endActivity(gameID string) { + d.sessions.mu.Lock() + if a := d.sessions.active[gameID]; a != nil { + a.timer.Stop() + delete(d.sessions.active, gameID) + } + d.sessions.mu.Unlock() +} + +// activityQuiet ends a stretch of changes once the save has stayed unchanged +// for quietAfter: the save as it was left is kept and goes to the other +// devices, as at the end of a session. +func (d *Daemon) activityQuiet(gameID string) { + d.sessions.mu.Lock() + a := d.sessions.active[gameID] + if a == nil { + d.sessions.mu.Unlock() + return + } + if wait := quietAfter - time.Since(a.last); wait > 0 { + a.timer.Reset(wait) + d.sessions.mu.Unlock() + return + } + delete(d.sessions.active, gameID) + delete(d.sessions.checkpoint, gameID) + start, last := a.start, a.last + d.sessions.mu.Unlock() + + if !d.PlayingSince(gameID).IsZero() { + return // a session is open after all; its end keeps the save + } + game, err := d.Store.GetGame(gameID) + if err != nil { + return + } + if hash, err := d.currentContentHash(game); err == nil && hash != game.LastManifestHash { + comment := "Auto backup" + if last.Sub(start) >= shortestSession { + comment = fmt.Sprintf("After playing (%s)", spokenLength(last.Sub(start))) + } + if err := d.markSessionEnd(game, hash, start, comment); err != nil { + d.Log.Log("warn", fmt.Sprintf("could not keep %q as it was left: %v", game.Name, err)) + return + } + } + d.P2P.Sync.NoteLocalChange(gameID) + d.Log.Log("info", fmt.Sprintf("%q has not changed its save for %s; sending it to your other devices", + game.Name, spokenLength(quietAfter))) + if d.OnGameChanged != nil { + d.OnGameChanged(gameID) + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + _, _ = d.P2P.SyncGame(ctx, gameID) + }() +} diff --git a/internal/daemon/sessions_test.go b/internal/daemon/sessions_test.go index 02173b1a..ab4785ed 100644 --- a/internal/daemon/sessions_test.go +++ b/internal/daemon/sessions_test.go @@ -231,3 +231,36 @@ func TestSessionHoldsSnapshotsAndSyncWhilePlaying(t *testing.T) { } } } + +// A game whose program is not seen: one change is kept and synced at once; a +// second soon after begins a stretch handled as play — held, not synced — +// until the save has stayed unchanged for quietAfter, when it is kept once. +func TestActivityWithoutASessionIsHandledAsPlay(t *testing.T) { + d := newTestDaemon(t) + g, dir := sessionGame(t, d, "unseen") + if d.holdSnapshotWhilePlaying(g.ID) { + t.Fatal("a single change was held") + } + if !d.holdSnapshotWhilePlaying(g.ID) { + t.Fatal("a second change soon after was not held") + } + if !d.changingNow(g.ID) || !d.P2P.Sync.PlayingHere(g.ID) { + t.Fatal("a game changing its save is not handled as being played") + } + before := len(snapComments(t, d, g.ID)) + if err := os.WriteFile(filepath.Join(dir, "slot1.sav"), []byte("left like this"), 0o666); err != nil { + t.Fatal(err) + } + + // Quiet for longer than quietAfter. + d.sessions.mu.Lock() + d.sessions.active[g.ID].last = time.Now().Add(-quietAfter - time.Second) + d.sessions.mu.Unlock() + d.activityQuiet(g.ID) + if d.changingNow(g.ID) || d.P2P.Sync.PlayingHere(g.ID) { + t.Error("still handled as being played after the save went quiet") + } + if got := snapComments(t, d, g.ID); len(got) != before+1 { + t.Errorf("snapshots %q: want one more, keeping the save as it was left", got) + } +} diff --git a/internal/sessions/procs_windows.go b/internal/sessions/procs_windows.go index 1fb4ceb8..5e22216b 100644 --- a/internal/sessions/procs_windows.go +++ b/internal/sessions/procs_windows.go @@ -9,8 +9,10 @@ import ( ) // listProcesses walks the process table and asks each process for its full -// program path. A process this user may not look at (a service, another -// user's) is skipped: no game of theirs is one of ours. +// program path. A process whose path cannot be read is listed by its file name +// alone (Proc.Name): a game protected against tampering keeps its path from +// everyone, and matching by name within its install folder's programs is +// still exact enough. func listProcesses() ([]Proc, error) { snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) if err != nil { @@ -29,6 +31,10 @@ func listProcesses() ([]Proc, error) { if pid := entry.ProcessID; pid > 4 { if exe := imagePath(pid, buf); exe != "" { out = append(out, Proc{PID: int(pid), Exe: exe}) + } else if name := windows.UTF16ToString(entry.ExeFile[:]); name != "" { + // Its path is not ours to read: a game protected against + // tampering, run elevated, or another user's. Known by name. + out = append(out, Proc{PID: int(pid), Name: name}) } } if err := windows.Process32Next(snap, &entry); err != nil { diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index 1f47e5c3..e482fb4c 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -32,6 +32,10 @@ type Proc struct { PID int // Exe is the program's full path, where the system says. Exe string + // Name is the program's file name, where the system gives no path: a + // game protected against tampering lets nobody read where it runs from, + // but the process list still says what it is called. + Name string // Args is the command line, where the system says (Linux). Args []string // SteamAppID is the Steam app the process was started for, where Steam @@ -49,6 +53,9 @@ type Target struct { // Dirs are the game's install folders: anything running from inside one // is the game. Dirs []string + // Programs are the file names of the programs in the install folders, for + // a process known only by name (Proc.Name). Lower-case. + Programs []string } // Running reports which targets have a process running, from a list of @@ -74,6 +81,17 @@ func matches(p Proc, t Target) bool { if p.Exe != "" && t.Exe != "" && samePath(p.Exe, t.Exe) { return true } + if p.Exe == "" && p.Name != "" { + name := strings.ToLower(p.Name) + if t.Exe != "" && strings.EqualFold(filepath.Base(t.Exe), name) { + return true + } + for _, prog := range t.Programs { + if prog == name { + return true + } + } + } for _, dir := range t.Dirs { if dir == "" { continue diff --git a/internal/sessions/sessions_test.go b/internal/sessions/sessions_test.go index c4311de4..5eadcecd 100644 --- a/internal/sessions/sessions_test.go +++ b/internal/sessions/sessions_test.go @@ -105,3 +105,16 @@ func TestListFindsThisProcess(t *testing.T) { t.Errorf("this process (%d, %s) was not found among %d: %v", os.Getpid(), self, len(procs), running) } } + +// A game protected against tampering keeps its path from everyone; its +// process is still recognised by name, among its install folder's programs. +func TestRunning_ProtectedProcessByName(t *testing.T) { + target := Target{GameID: "cd", Dirs: []string{`C:\Games\Crimson Desert`}, Programs: []string{"crimsondesert.exe"}} + procs := []Proc{{PID: 11, Name: "explorer.exe"}, {PID: 12, Name: "CrimsonDesert.exe"}} + if got := Running(procs, []Target{target}); got["cd"] != 12 { + t.Errorf("Running = %v, want the protected process recognised by name", got) + } + if got := Running([]Proc{{PID: 13, Name: "game.exe"}}, []Target{target}); len(got) != 0 { + t.Errorf("a program not in the game's folder was taken for it: %v", got) + } +} diff --git a/testutil/harness.go b/testutil/harness.go index 9323e7b4..ec69c98b 100644 --- a/testutil/harness.go +++ b/testutil/harness.go @@ -112,7 +112,7 @@ func NewTestDaemon(t *testing.T, name string) *TestDaemon { if err := os.MkdirAll(home, 0o777); err != nil { t.Fatal(err) } - d, err := daemon.New(daemon.Options{HomeOverride: home, DisableDiscovery: true}) + d, err := daemon.New(daemon.Options{HomeOverride: home, DisableDiscovery: true, SyncEveryChange: true}) if err != nil { t.Fatalf("daemon.New(%s): %v", name, err) } From c4b5b00ffec296dea31ac5233d62d1f9c85b7d09 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:08:45 +0200 Subject: [PATCH 43/75] Find a running game wherever it is installed A session of Crimson Desert installed at G:\Games\Crimson Desert was never seen: session detection knew install folders only from Steam's libraries and the folders games are usually kept in, and the game's entry read "not found on this PC". Its program path was readable all along. - A running program inside a folder named as one of the game's install folders is that game, wherever the folder is: the names the game database knows (Ludusavi's installDir, "Crimson Desert"), and the game's own name when it is two words or a long one - "Rust" is a folder in every Rust toolchain. Generic folder names never count. - The folder a session was seen running from is remembered (marks), so the game is known to be installed there, and its programs can be told by name from then on. - A change made while no session is seen is held from the first, not the second: it is kept and synced once the save has stayed unchanged for 5 minutes, longer than the gap between Crimson Desert's first two autosaves (3.6 minutes), so an unseen session sends one version, at its end. A second change within 10 minutes makes it a stretch that ends after 10 minutes unchanged, as before. - A change waiting out the watcher's debounce already counts as play: a sync landing in those two seconds sent one change on just before it was held (watcher.ChangePending). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/daemon/daemon.go | 2 +- internal/daemon/sessions.go | 176 ++++++++++++++++++++++++----- internal/daemon/sessions_test.go | 35 ++++-- internal/presets/devicesettings.go | 16 +++ internal/sessions/sessions.go | 43 +++++++ internal/sessions/sessions_test.go | 20 ++++ internal/watcher/watcher.go | 16 +++ 7 files changed, 273 insertions(+), 35 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 71614174..907b2bc9 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -182,7 +182,7 @@ func New(opts Options) (*Daemon, error) { d.P2P.Sync.ObserveSettings = d.observeSettings // A game being played here is synced when its session ends // (syncengine/playing.go). - d.P2P.Sync.Playing = func(gameID string) bool { return !d.PlayingSince(gameID).IsZero() || d.changingNow(gameID) } + d.P2P.Sync.Playing = d.playingHere // A restore or a branch switch rewrites a save folder; nothing syncing it // may read it half-way (syncengine/settle.go). diff --git a/internal/daemon/sessions.go b/internal/daemon/sessions.go index bf7f4db9..7f887fa2 100644 --- a/internal/daemon/sessions.go +++ b/internal/daemon/sessions.go @@ -101,6 +101,7 @@ func (d *Daemon) PollSessions() { return } running := sessions.Running(procs, targets) + d.rememberInstallFolders(procs, targets, running) d.notePrograms(procs, targets, learn, running) d.sessions.tracker.Poll(running, time.Now()) } @@ -156,6 +157,10 @@ func (d *Daemon) sessionTargets() ([]sessions.Target, map[string]bool, error) { } } dir, viaSteam := installFolder(g, byAppID, byFolder) + if dir == "" { + dir = d.knownInstallFolder(g.ID) + } + t.FolderNames = d.installFolderNames(g) if dir != "" { t.Dirs = append(t.Dirs, dir) } @@ -211,6 +216,9 @@ func (d *Daemon) InstallState(g store.Game) string { if dir, _ := installFolder(g, byAppID, byFolder); dir != "" { return InstallFound } + if d.knownInstallFolder(g.ID) != "" { + return InstallFound + } if g.AppID != "" && steam { return InstallNotFound } @@ -590,13 +598,33 @@ const quietAfter = 10 * time.Minute type activity struct { start, last time.Time timer *time.Timer + // changes counts the changes in it; a change within quietAfter of the + // one before, even one already sent, counts as a second. + changes int +} + +// firstChangeWait is how long a change on its own is held before it is kept +// and synced: long enough that the next autosave of a game being played +// arrives first (Crimson Desert: 3.6 minutes between its first two), so a +// session not seen still sends one version, at its end. A game that saves +// once, as it closes, reaches the other devices this much later — when its +// session is not seen; the end of a session seen is sent at once. +const firstChangeWait = 5 * time.Minute + +// quietFor is how long the save must stay unchanged to end the stretch. +func (a *activity) quietFor() time.Duration { + if a.changes <= 1 { + return firstChangeWait + } + return quietAfter } -// noteActivity records a change to a game's save made here (not by a sync). -// A change within quietAfter of the one before begins a stretch, or goes on -// with one: it returns when the stretch began, and whether there is one. A -// change on its own — a game that saves once, as it is closed — is not one, -// and is kept and synced straight away, as every change was before. +// noteActivity records a change to a game's save made here (not by a sync) +// and returns when the stretch it belongs to began, and whether it is held +// as one. Every change is: a change on its own is kept and synced once the +// save has stayed unchanged for firstChangeWait, a second within quietAfter +// of the one before makes it a stretch that ends after quietAfter unchanged. +// Only SyncEveryChange sends each change as it comes. func (d *Daemon) noteActivity(gameID string) (time.Time, bool) { now := time.Now() d.sessions.mu.Lock() @@ -609,30 +637,34 @@ func (d *Daemon) noteActivity(gameID string) (time.Time, bool) { } prev, seen := d.sessions.lastChange[gameID] d.sessions.lastChange[gameID] = now + if d.opts.SyncEveryChange { + return now, false + } a := d.sessions.active[gameID] if a == nil { - if !seen || now.Sub(prev) >= quietAfter || d.opts.SyncEveryChange { - return now, false + a = &activity{start: now, changes: 1} + if seen && now.Sub(prev) < quietAfter { + a.start, a.changes = prev, 2 } - a = &activity{start: prev} d.sessions.active[gameID] = a - a.timer = time.AfterFunc(quietAfter, func() { d.activityQuiet(gameID) }) - if d.PlayingSince(gameID).IsZero() && !d.sessions.warned[gameID] { - if d.sessions.warned == nil { - d.sessions.warned = map[string]bool{} - } - d.sessions.warned[gameID] = true - name := gameID - if g, err := d.Store.GetGame(gameID); err == nil { - name = g.Name - } - go d.Log.Log("info", fmt.Sprintf("%q is changing its save, but its program is not seen running here — "+ - "handled as being played: kept and synced once it has not changed for %s", name, spokenLength(quietAfter))) - } + a.timer = time.AfterFunc(a.quietFor(), func() { d.activityQuiet(gameID) }) } else { - a.timer.Reset(quietAfter) + a.changes++ + a.timer.Reset(a.quietFor()) } a.last = now + if a.changes == 2 && d.PlayingSince(gameID).IsZero() && !d.sessions.warned[gameID] { + if d.sessions.warned == nil { + d.sessions.warned = map[string]bool{} + } + d.sessions.warned[gameID] = true + name := gameID + if g, err := d.Store.GetGame(gameID); err == nil { + name = g.Name + } + go d.Log.Log("info", fmt.Sprintf("%q is changing its save, but its program is not seen running here — "+ + "handled as being played: kept and synced once it has not changed for %s", name, spokenLength(quietAfter))) + } return a.start, true } @@ -664,14 +696,14 @@ func (d *Daemon) activityQuiet(gameID string) { d.sessions.mu.Unlock() return } - if wait := quietAfter - time.Since(a.last); wait > 0 { + if wait := a.quietFor() - time.Since(a.last); wait > 0 { a.timer.Reset(wait) d.sessions.mu.Unlock() return } delete(d.sessions.active, gameID) delete(d.sessions.checkpoint, gameID) - start, last := a.start, a.last + start, last, stretch := a.start, a.last, a.changes > 1 d.sessions.mu.Unlock() if !d.PlayingSince(gameID).IsZero() { @@ -692,8 +724,10 @@ func (d *Daemon) activityQuiet(gameID string) { } } d.P2P.Sync.NoteLocalChange(gameID) - d.Log.Log("info", fmt.Sprintf("%q has not changed its save for %s; sending it to your other devices", - game.Name, spokenLength(quietAfter))) + if stretch { + d.Log.Log("info", fmt.Sprintf("%q has not changed its save for %s; sending it to your other devices", + game.Name, spokenLength(quietAfter))) + } if d.OnGameChanged != nil { d.OnGameChanged(gameID) } @@ -703,3 +737,93 @@ func (d *Daemon) activityQuiet(gameID string) { _, _ = d.P2P.SyncGame(ctx, gameID) }() } + +// Games installed where no library lists them — a folder of the user's own, +// "G:\Games\Crimson Desert" — are found by the name of their install folder: +// the names the game database knows it by, and the game's own name when it +// is specific enough not to be a folder of something else. Once a session +// has been seen that way, the folder is remembered, so the game is known to +// be installed and its programs can be told by name (programsIn). + +// genericFolders are names no game's folder is recognised by alone. +var genericFolders = map[string]bool{ + "games": true, "game": true, "steam": true, "steamapps": true, "common": true, "program files": true, + "program files x86": true, "bin": true, "bin64": true, "win64": true, "binaries": true, "launcher": true, + "data": true, "x64": true, "app": true, "client": true, "epic games": true, "gog games": true, +} + +func (d *Daemon) installFolderNames(g store.Game) []string { + var names []string + add := func(n string) { + n = sessions.FolderName(n) + if n == "" || genericFolders[n] { + return + } + for _, have := range names { + if have == n { + return + } + } + names = append(names, n) + } + d.devMu.Lock() + db := d.devDBLocked() + d.devMu.Unlock() + for _, n := range db.InstallNames(g.Name, g.AppID) { + if len(sessions.FolderName(n)) >= 4 { + add(n) + } + } + // The game's own name only when it could hardly name anything else: + // two words, or one long one. "Rust" is a folder in every Rust toolchain. + if n := sessions.FolderName(g.Name); strings.Contains(n, " ") && len(n) >= 6 || len(n) >= 10 { + add(g.Name) + } + return names +} + +func installFolderMark(gameID string) string { return "installdir:" + gameID } + +// knownInstallFolder is the install folder a session of the game was seen +// running from, when it is still there. +func (d *Daemon) knownInstallFolder(gameID string) string { + dir := d.Store.Mark(installFolderMark(gameID)) + if dir == "" { + return "" + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return "" + } + return dir +} + +// rememberInstallFolders records, for each game found running by the name of +// its install folder, which folder that was. +func (d *Daemon) rememberInstallFolders(procs []sessions.Proc, targets []sessions.Target, running map[string]int) { + for _, t := range targets { + pid, ok := running[t.GameID] + if !ok || len(t.Dirs) > 0 { + continue + } + for _, p := range procs { + if p.PID != pid { + continue + } + if dir := sessions.FolderOf(p, t); dir != "" && d.Store.Mark(installFolderMark(t.GameID)) != dir { + _ = d.Store.SetMark(installFolderMark(t.GameID), dir) + } + } + } +} + +// playingHere says whether a game counts as being played on this device, for +// the sync engine (syncengine/playing.go): a session is open, a stretch of +// changes is (noteActivity) — or one is about to be: a change made here waits +// out the watcher's debounce. A sync landing in that gap would otherwise +// send the change on the moment before it is held. +func (d *Daemon) playingHere(gameID string) bool { + if !d.PlayingSince(gameID).IsZero() || d.changingNow(gameID) { + return true + } + return !d.opts.SyncEveryChange && d.Watcher != nil && d.Watcher.ChangePending(gameID) +} diff --git a/internal/daemon/sessions_test.go b/internal/daemon/sessions_test.go index ab4785ed..5922ef32 100644 --- a/internal/daemon/sessions_test.go +++ b/internal/daemon/sessions_test.go @@ -199,9 +199,6 @@ func TestSpokenLength(t *testing.T) { func TestSessionHoldsSnapshotsAndSyncWhilePlaying(t *testing.T) { d := newTestDaemon(t) g, _ := sessionGame(t, d, "autosaving") - if d.holdSnapshotWhilePlaying(g.ID) { - t.Fatal("held while nothing is being played") - } d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{{PID: 7, Exe: g.ExePath}}, nil } d.PollSessions() if d.PlayingSince(g.ID).IsZero() { @@ -232,18 +229,28 @@ func TestSessionHoldsSnapshotsAndSyncWhilePlaying(t *testing.T) { } } -// A game whose program is not seen: one change is kept and synced at once; a -// second soon after begins a stretch handled as play — held, not synced — -// until the save has stayed unchanged for quietAfter, when it is kept once. +// A game whose program is not seen: a change is held, not synced, and kept +// once the save has stayed unchanged for firstChangeWait; a second soon after +// makes it a stretch, which ends after quietAfter unchanged. func TestActivityWithoutASessionIsHandledAsPlay(t *testing.T) { d := newTestDaemon(t) g, dir := sessionGame(t, d, "unseen") - if d.holdSnapshotWhilePlaying(g.ID) { - t.Fatal("a single change was held") + if !d.holdSnapshotWhilePlaying(g.ID) { + t.Fatal("a single change was not held") + } + d.sessions.mu.Lock() + if q := d.sessions.active[g.ID].quietFor(); q != firstChangeWait { + t.Errorf("a single change waits %v, want %v", q, firstChangeWait) } + d.sessions.mu.Unlock() if !d.holdSnapshotWhilePlaying(g.ID) { t.Fatal("a second change soon after was not held") } + d.sessions.mu.Lock() + if q := d.sessions.active[g.ID].quietFor(); q != quietAfter { + t.Errorf("a stretch waits %v, want %v", q, quietAfter) + } + d.sessions.mu.Unlock() if !d.changingNow(g.ID) || !d.P2P.Sync.PlayingHere(g.ID) { t.Fatal("a game changing its save is not handled as being played") } @@ -264,3 +271,15 @@ func TestActivityWithoutASessionIsHandledAsPlay(t *testing.T) { t.Errorf("snapshots %q: want one more, keeping the save as it was left", got) } } + +// The names a game's install folder is recognised by: never a generic folder, +// and the game's own name only when it could hardly be another folder's. +func TestInstallFolderNames(t *testing.T) { + d := newTestDaemon(t) + for name, want := range map[string]bool{"Crimson Desert": true, "Rust": false, "Games": false, "Satisfactory": true} { + got := d.installFolderNames(store.Game{ID: name, Name: name}) + if (len(got) > 0) != want { + t.Errorf("%q: folder names %v, want some = %v", name, got, want) + } + } +} diff --git a/internal/presets/devicesettings.go b/internal/presets/devicesettings.go index baaacd95..34f87234 100644 --- a/internal/presets/devicesettings.go +++ b/internal/presets/devicesettings.go @@ -122,3 +122,19 @@ func coversEverything(rel string) bool { first := strings.SplitN(rel, "/", 2)[0] return strings.Trim(first, "*") == "" || first == "*.*" } + +// InstallNames returns the names the game database knows a game's install +// folder by ("Crimson Desert"), found by App ID or name. +func (ds *DeviceSettings) InstallNames(name, appID string) []string { + if ds == nil { + return nil + } + g, ok := ds.byID[appID] + if !ok || appID == "" { + g, ok = ds.byName[normalizeGameName(stripNameSuffixes(name))] + } + if !ok { + return nil + } + return append([]string(nil), g.Installs...) +} diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index e482fb4c..9ad3cdd5 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -25,6 +25,7 @@ import ( "strings" "sync" "time" + "unicode" ) // Proc is a running process, as much of it as matching needs. @@ -53,6 +54,11 @@ type Target struct { // Dirs are the game's install folders: anything running from inside one // is the game. Dirs []string + // FolderNames are names the game's install folder goes by, for a game + // installed where no library lists it ("G:GamesCrimson Desert"): + // a program running from inside a folder of one of these names is the + // game. Normalised with FolderName. + FolderNames []string // Programs are the file names of the programs in the install folders, for // a process known only by name (Proc.Name). Lower-case. Programs []string @@ -92,6 +98,9 @@ func matches(p Proc, t Target) bool { } } } + if p.Exe != "" && FolderOf(p, t) != "" { + return true + } for _, dir := range t.Dirs { if dir == "" { continue @@ -270,3 +279,37 @@ func (t *Tracker) Finish(gameID string, at time.Time) { // List returns the processes running now. Where the system cannot say, it // returns none and an error, and nothing is ever seen playing. func List() ([]Proc, error) { return listProcesses() } + +// FolderName normalises a folder or game name for comparing the two: lower +// case, letters, digits and single spaces. +func FolderName(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + b.WriteRune(r) + default: + b.WriteRune(' ') + } + } + return strings.Join(strings.Fields(b.String()), " ") +} + +// FolderOf returns the folder, among those above a process's program, that +// is named as one of a target's install folders (Target.FolderNames), or "". +func FolderOf(p Proc, t Target) string { + if p.Exe == "" || len(t.FolderNames) == 0 { + return "" + } + dir := filepath.Dir(p.Exe) + for dir != "" && dir != filepath.Dir(dir) { + name := FolderName(filepath.Base(dir)) + for _, want := range t.FolderNames { + if name == want { + return dir + } + } + dir = filepath.Dir(dir) + } + return "" +} diff --git a/internal/sessions/sessions_test.go b/internal/sessions/sessions_test.go index 5eadcecd..2803e457 100644 --- a/internal/sessions/sessions_test.go +++ b/internal/sessions/sessions_test.go @@ -118,3 +118,23 @@ func TestRunning_ProtectedProcessByName(t *testing.T) { t.Errorf("a program not in the game's folder was taken for it: %v", got) } } + +// A game installed in a folder no library lists is found by the folder's +// name, wherever that folder is. +func TestRunning_ByInstallFolderName(t *testing.T) { + target := Target{GameID: "cd", FolderNames: []string{FolderName("Crimson Desert")}} + procs := []Proc{ + {PID: 5, Exe: `C:\Windows\explorer.exe`}, + {PID: 6, Exe: `G:\Games\Crimson Desert\bin64\CrimsonDesert.exe`}, + } + if got := Running(procs, []Target{target}); got["cd"] != 6 { + t.Fatalf("Running = %v, want the game found by its folder", got) + } + if dir := FolderOf(procs[1], target); dir != `G:\Games\Crimson Desert` { + t.Errorf("FolderOf = %q", dir) + } + other := []Proc{{PID: 7, Exe: `G:\Games\Crimson Desert Tools\editor.exe`}} + if got := Running(other, []Target{target}); len(got) != 0 { + t.Errorf("a folder whose name only begins the same was taken for the game: %v", got) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index fd8a0a20..786abb34 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -217,6 +217,9 @@ type gameWatch struct { // OpenSave applied itself (owntouch), anything else, or both. Read and // written only by the event loop. ownEvents, otherEvents bool + // pendingChange is set while a change made here (not by a sync) waits + // out the debounce, for ChangePending: read from other goroutines. + pendingChange atomic.Bool // foreignFolders: a folder queued for watching since the last rescan was // not one OpenSave created, or the watch started on a busy folder. The // rescan that follows registration counts as someone else's change only @@ -548,6 +551,15 @@ func (e *Engine) Unwatch(gameID string) { } } +// ChangePending reports whether a change made here to a game's save is +// waiting out the debounce: seen, and not yet judged. +func (e *Engine) ChangePending(gameID string) bool { + e.mu.Lock() + gw, ok := e.games[gameID] + e.mu.Unlock() + return ok && gw.pendingChange.Load() +} + // Stop shuts down every watch goroutine. func (e *Engine) Stop() { if e.stopCatch != nil { @@ -722,6 +734,7 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { gw.ownEvents = true } else { gw.otherEvents = true + gw.pendingChange.Store(true) } // New subdirectory in directory mode: extend the watch — on // registerFolders, never here. See registerFolders. @@ -743,6 +756,7 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { // folders they were: what OpenSave created, it also filled. if gw.foreignFolders { gw.otherEvents = true + gw.pendingChange.Store(true) } else { gw.ownEvents = true } @@ -784,6 +798,7 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { gw.ownEvents = true } else { gw.otherEvents = true + gw.pendingChange.Store(true) } } else { e.log("warn", fmt.Sprintf("watching %q: %v", gw.gameID, err)) @@ -833,6 +848,7 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { } ownOnly := gw.ownEvents && !gw.otherEvents gw.ownEvents, gw.otherEvents = false, false + gw.pendingChange.Store(false) e.handleChangeFrom(ctx, gw, ownOnly) } } From 9764adef40efa2d90f18683ea7fe9d6ff638d199 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:49:52 +0200 Subject: [PATCH 44/75] Sessions end when the game does; no delay or checkpoints left behind From a session of Crimson Desert on a device that now recognised it: - A process named CrimsonDesert.exe stayed behind for 15 minutes after the game closed, with under 1 MB of memory and no processor time, and held the session - and with it the save - open. A process is now measured (memory, processor time; sessions.Proc), and one holding under 32 MB that used no processor time since the last poll is not the game (sessions.Idle, daemon.activeProcesses). One that cannot be measured still counts. - A game whose sessions this device has seen (a session recorded, or the folder it ran from known) is not held back while it is closed: a change to its save then is not play, and is kept and synced at once; so is a change within two minutes of a session's end. The 5 and 10 minute holds stay for games never seen running. - A snapshot taken while a game is played is named "Checkpoint while playing", not left as an unnamed automatic one ("Save changed"), and the session's checkpoints are removed once its end is kept: one snapshot per session. Pinned ones stay; after a crash they are what is left. - In a session, a change no longer also starts a stretch: the stretch's timer ended inside the session, cleared its last checkpoint, and from half an hour in let nearly every autosave through as a checkpoint - "Save changed" every ten minutes in a session that was seen. - A stretch waits for twice the longest gap between the game's changes when that is longer than its usual wait (at most 45 minutes), and the gap is remembered per game: a game that autosaves every ten minutes is not let through at every autosave. - The e2e harness prints a daemon's log when a test fails. - "not found on this PC" under a game's title is a link that asks for the game's program, which then says where it is installed and what runs when it is played. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../frontend/src/lib/snapshots.js | 3 + .../frontend/src/lib/snapshots.test.js | 4 + .../frontend/src/views/game/GameHeader.svelte | 24 ++- internal/daemon/daemon.go | 9 +- internal/daemon/detectsettings.go | 11 +- internal/daemon/sessions.go | 159 ++++++++++++++++-- internal/daemon/sessions_test.go | 99 +++++++++++ internal/sessions/procs_windows.go | 49 +++++- internal/sessions/sessions.go | 24 +++ internal/sessions/sessions_test.go | 25 +++ testutil/harness.go | 7 + 11 files changed, 391 insertions(+), 23 deletions(-) diff --git a/cmd/opensave-app/frontend/src/lib/snapshots.js b/cmd/opensave-app/frontend/src/lib/snapshots.js index 9ad3a4ab..9dc80b5b 100644 --- a/cmd/opensave-app/frontend/src/lib/snapshots.js +++ b/cmd/opensave-app/frontend/src/lib/snapshots.js @@ -89,6 +89,9 @@ export function snapshotKind(snap, now = new Date()) { if (/^initial snapshot$/i.test(comment)) return { kind: 'start', label: 'Automatic', title: 'When tracking started' }; // The save as a play session left it (daemon/sessions.go). if (/^after playing/i.test(comment)) return { kind: 'session', label: 'After playing', title: comment }; + // Taken while a game was played, in case the session never got to end; + // removed once it did (daemon/sessions.go). + if (/^checkpoint while playing/i.test(comment)) return { kind: 'session', label: 'While playing', title: 'Checkpoint while playing' }; if (safetyPattern.test(comment)) { return { kind: 'safety', label: 'Safety copy', title: plainTitle(comment) }; } diff --git a/cmd/opensave-app/frontend/src/lib/snapshots.test.js b/cmd/opensave-app/frontend/src/lib/snapshots.test.js index d5e26ca2..75c9bdf7 100644 --- a/cmd/opensave-app/frontend/src/lib/snapshots.test.js +++ b/cmd/opensave-app/frontend/src/lib/snapshots.test.js @@ -64,6 +64,10 @@ describe('snapshotKind', () => { }); }); + it('names the checkpoints taken while a game is played', () => { + expect(snapshotKind(auto('Checkpoint while playing'))).toMatchObject({ kind: 'session', title: 'Checkpoint while playing' }); + }); + it('says a save changed for the watcher’s own snapshots', () => { expect(snapshotKind(auto(''))).toMatchObject({ kind: 'auto', title: 'Save changed' }); expect(snapshotKind(auto('Auto backup'))).toMatchObject({ kind: 'auto', title: 'Save changed' }); diff --git a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte index ceafac2a..327f2a34 100644 --- a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte +++ b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte @@ -26,6 +26,14 @@ export let runner; const { busy, run } = runner; + // A game OpenSave cannot find here: its program, chosen by hand, says where + // it is installed and what runs when it is played (sessions, launch). + async function pickProgram() { + const exe = await native.selectFile(`Program of “${game.name}”`); + if (!exe) return; + await run(`Program set for ${game.name}`, () => api.patch(`/api/games/${game.id}`, { exePath: exe })); + } + $: activity = $syncActivity[game.id]; // Is my Deck up to date with THIS save? One entry per paired device, in @@ -134,7 +142,12 @@ · <span title={new Date(game.lastPlayedAt).toLocaleString()}>played {timeAgo(game.lastPlayedAt, now)}, {playLength(game.playtimeMs)} in all</span> {/if} {#if game.installed === 'not-found'} - · <span title="Steam on this PC does not have it, and it is in no folder games are kept in. Set its program under Configuration to launch it from somewhere else.">not found on this PC</span> + · <button + class="linkish" + disabled={$busy} + title="OpenSave cannot tell where this game is installed here, so it cannot see when it is being played. Choose its program to fix both." + on:click={pickProgram}>not found on this PC — choose its program</button + > {/if} </div> {#if syncedWith.length > 0} @@ -232,6 +245,15 @@ {/if} <style> + .linkish { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent-text); + text-decoration: underline; + cursor: pointer; + } .title-row { display: flex; align-items: center; diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 907b2bc9..5324b5f2 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -251,7 +251,14 @@ func New(opts Options) (*Daemon, error) { }, SetLastManifestHash: s.SetLastManifestHash, CreateSnapshot: func(gameID string) error { - _, err := snaps.Create(gameID, "", true) + // While a game is played, the watcher only snapshots at a + // checkpoint (sessions.go), named so, and gone once the + // session's end is kept. + comment := "" + if d.playingHere(gameID) { + comment = checkpointComment + } + _, err := snaps.Create(gameID, comment, true) return err }, SyncWriting: func(gameID string) bool { diff --git a/internal/daemon/detectsettings.go b/internal/daemon/detectsettings.go index 4c656959..562206c4 100644 --- a/internal/daemon/detectsettings.go +++ b/internal/daemon/detectsettings.go @@ -124,8 +124,8 @@ func (d *Daemon) DetectSettings(game store.Game) { d.exclusionsChanged(game) if len(taken) > 0 && !game.SyncDeviceSettings { d.Log.Log("info", fmt.Sprintf("%q: %s %s this device's settings, not the save — each device keeps its own. "+ - "Untick it under the game's \"Files that shouldn't sync\" to sync it after all.", - game.Name, strings.Join(taken, ", "), isAre(len(taken)))) + "Untick %s under the game's \"Files that shouldn't sync\" to sync %s after all.", + game.Name, strings.Join(taken, ", "), isAre(len(taken)), itThem(len(taken)), itThem(len(taken)))) } if len(given) > 0 { d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.", @@ -247,3 +247,10 @@ func itThey(n int) string { } return "they are" } + +func itThem(n int) string { + if n == 1 { + return "it" + } + return "them" +} diff --git a/internal/daemon/sessions.go b/internal/daemon/sessions.go index 7f887fa2..638821ca 100644 --- a/internal/daemon/sessions.go +++ b/internal/daemon/sessions.go @@ -53,6 +53,13 @@ type sessionState struct { warned map[string]bool // lastChange: when each game's save last changed here. lastChange map[string]time.Time + // gap: the longest time seen between two changes of each game's save in + // a stretch (activity.maxGap). + gap map[string]time.Duration + // cpu: each process's processor time at the last poll (activeProcesses). + cpu map[int]time.Duration + // endedAt: when each game's last session ended (holdSnapshotWhilePlaying). + endedAt map[string]time.Time // startHash is each playing game's save as it was when play began, to // tell afterwards whether the session changed it. startHash map[string]string @@ -100,6 +107,7 @@ func (d *Daemon) PollSessions() { if err != nil { return } + procs = d.activeProcesses(procs) running := sessions.Running(procs, targets) d.rememberInstallFolders(procs, targets, running) d.notePrograms(procs, targets, learn, running) @@ -388,6 +396,12 @@ func (d *Daemon) sessionEnded(gameID string, started, ended time.Time) { // it overlapped would otherwise hold the sync back for quietAfter more. d.endActivity(gameID) d.sessions.mu.Lock() + if d.sessions.endedAt == nil { + d.sessions.endedAt = map[string]time.Time{} + } + d.sessions.endedAt[gameID] = time.Now() + d.sessions.mu.Unlock() + d.sessions.mu.Lock() startHash := d.sessions.startHash[gameID] delete(d.sessions.startHash, gameID) delete(d.sessions.checkpoint, gameID) @@ -423,6 +437,7 @@ func (d *Daemon) sessionEnded(gameID string, started, ended time.Time) { d.Log.Log("warn", fmt.Sprintf("could not keep %q as it was left: %v", game.Name, err)) return } + d.dropCheckpoints(game, started) // Onward to the other devices, so it is there on the next one picked up. go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) @@ -493,10 +508,18 @@ const checkpointEvery = 30 * time.Minute // since the last checkpoint. The end of the session, or of the stretch of // changes, keeps the save as it was left. func (d *Daemon) holdSnapshotWhilePlaying(gameID string) bool { - // Every change made here counts, session seen or not (noteActivity). - start, stretch := d.noteActivity(gameID) + // A game known to be closed: kept and synced at once. + if d.notBeingPlayed(gameID) { + return false + } + // In a session, its rules alone: a checkpoint every checkpointEvery from + // its start. Only a change no session accounts for is a stretch + // (noteActivity) — whose timer once ended inside a session, cleared the + // session's last checkpoint, and so let nearly every autosave after the + // first half hour through as one. since := d.PlayingSince(gameID) if since.IsZero() { + start, stretch := d.noteActivity(gameID) if !stretch { return false } @@ -601,6 +624,10 @@ type activity struct { // changes counts the changes in it; a change within quietAfter of the // one before, even one already sent, counts as a second. changes int + // maxGap is the longest time between two of its changes, or the one + // remembered for the game: a game that autosaves every ten minutes must + // not have each autosave taken for the end of a stretch. + maxGap time.Duration } // firstChangeWait is how long a change on its own is held before it is kept @@ -611,12 +638,24 @@ type activity struct { // session is not seen; the end of a session seen is sent at once. const firstChangeWait = 5 * time.Minute -// quietFor is how long the save must stay unchanged to end the stretch. +// maxQuiet caps how long a stretch can be held after its last change. +const maxQuiet = 45 * time.Minute + +// quietFor is how long the save must stay unchanged to end the stretch: the +// base wait, or twice the longest gap between the game's changes when that +// is longer. func (a *activity) quietFor() time.Duration { + wait := quietAfter if a.changes <= 1 { - return firstChangeWait + wait = firstChangeWait } - return quietAfter + if w := 2 * a.maxGap; w > wait { + wait = w + } + if wait > maxQuiet { + wait = maxQuiet + } + return wait } // noteActivity records a change to a game's save made here (not by a sync) @@ -642,16 +681,28 @@ func (d *Daemon) noteActivity(gameID string) (time.Time, bool) { } a := d.sessions.active[gameID] if a == nil { - a = &activity{start: now, changes: 1} - if seen && now.Sub(prev) < quietAfter { + a = &activity{start: now, changes: 1, maxGap: d.sessions.gap[gameID]} + if seen && now.Sub(prev) < maxQuiet && now.Sub(prev) < 2*(a.maxGap+quietAfter/2) { a.start, a.changes = prev, 2 + if g := now.Sub(prev); g > a.maxGap { + a.maxGap = g + } } d.sessions.active[gameID] = a a.timer = time.AfterFunc(a.quietFor(), func() { d.activityQuiet(gameID) }) } else { a.changes++ + if g := now.Sub(a.last); g > a.maxGap { + a.maxGap = g + } a.timer.Reset(a.quietFor()) } + if d.sessions.gap == nil { + d.sessions.gap = map[string]time.Duration{} + } + if a.maxGap > d.sessions.gap[gameID] { + d.sessions.gap[gameID] = a.maxGap + } a.last = now if a.changes == 2 && d.PlayingSince(gameID).IsZero() && !d.sessions.warned[gameID] { if d.sessions.warned == nil { @@ -702,13 +753,15 @@ func (d *Daemon) activityQuiet(gameID string) { return } delete(d.sessions.active, gameID) + if !d.PlayingSince(gameID).IsZero() { + // A session is open after all: its end keeps the save, and its + // checkpoints are its own. + d.sessions.mu.Unlock() + return + } delete(d.sessions.checkpoint, gameID) start, last, stretch := a.start, a.last, a.changes > 1 d.sessions.mu.Unlock() - - if !d.PlayingSince(gameID).IsZero() { - return // a session is open after all; its end keeps the save - } game, err := d.Store.GetGame(gameID) if err != nil { return @@ -722,6 +775,7 @@ func (d *Daemon) activityQuiet(gameID string) { d.Log.Log("warn", fmt.Sprintf("could not keep %q as it was left: %v", game.Name, err)) return } + d.dropCheckpoints(game, start) } d.P2P.Sync.NoteLocalChange(gameID) if stretch { @@ -825,5 +879,88 @@ func (d *Daemon) playingHere(gameID string) bool { if !d.PlayingSince(gameID).IsZero() || d.changingNow(gameID) { return true } + if d.notBeingPlayed(gameID) { + return false + } return !d.opts.SyncEveryChange && d.Watcher != nil && d.Watcher.ChangePending(gameID) } + +// activeProcesses leaves out the processes doing nothing: next to no memory, +// and no processor time since the last poll. A game leaves such a process +// behind when it closes — a crash handler, a stub of the same name — and it +// kept a session open for as long as it lingered, holding the save back +// from syncing after the game was long gone. +func (d *Daemon) activeProcesses(procs []sessions.Proc) []sessions.Proc { + d.sessions.mu.Lock() + defer d.sessions.mu.Unlock() + prev := d.sessions.cpu + next := make(map[int]time.Duration, len(procs)) + out := procs[:0:0] + for _, p := range procs { + if p.Measured { + next[p.PID] = p.CPU + } + before, seen := prev[p.PID] + if sessions.Idle(p, before, seen) { + continue + } + out = append(out, p) + } + d.sessions.cpu = next + return out +} + +// sessionEndGrace is how long after a session ends a change to its save is +// still the session's — the game writing as it closes — and sent at once. +const sessionEndGrace = 2 * time.Minute + +// recognised reports a game whose sessions this device has seen: one has +// been recorded, or the folder it ran from is known. While such a game is not +// running, a change to its save is not play, and is sent at once. +func (d *Daemon) recognised(gameID string) bool { + if d.knownInstallFolder(gameID) != "" { + return true + } + st, err := d.Store.PlayStatsFor(gameID) + return err == nil && st.Sessions > 0 +} + +// notBeingPlayed reports a change that is certainly not play: no session is +// open, and either one ended moments ago, or the game is one whose sessions +// are seen here. +func (d *Daemon) notBeingPlayed(gameID string) bool { + if !d.PlayingSince(gameID).IsZero() { + return false + } + d.sessions.mu.Lock() + ended, ok := d.sessions.endedAt[gameID] + d.sessions.mu.Unlock() + if ok && time.Since(ended) < sessionEndGrace { + return true + } + return d.recognised(gameID) +} + +// checkpointComment names the snapshots taken while a game is played. +const checkpointComment = "Checkpoint while playing" + +// dropCheckpoints removes a session's checkpoints once the save as the +// session left it is kept: they were there in case the session never got to +// end — a crash, the power going — and after it did, they are only clutter +// between one session's snapshot and the next. Pinned ones stay. +func (d *Daemon) dropCheckpoints(game store.Game, since time.Time) { + snaps, err := d.Store.ListSnapshots(game.ID, game.ActiveBranch) + if err != nil { + return + } + for _, s := range snaps { + if !s.IsSystemAuto || s.Pinned || s.Comment != checkpointComment { + continue + } + at, err := time.Parse(time.RFC3339Nano, s.Timestamp) + if err != nil || at.Before(since) { + continue + } + _, _ = d.Snapshots.DeleteSnapshot(game.ID, s.ID) + } +} diff --git a/internal/daemon/sessions_test.go b/internal/daemon/sessions_test.go index 5922ef32..138aaf1b 100644 --- a/internal/daemon/sessions_test.go +++ b/internal/daemon/sessions_test.go @@ -283,3 +283,102 @@ func TestInstallFolderNames(t *testing.T) { } } } + +// A process doing nothing does not keep a session open; once a game's +// sessions are seen here, a change while it is closed is sent at once. +func TestSessionEndsWhenOnlyAStubIsLeft(t *testing.T) { + d := newTestDaemon(t) + g, _ := sessionGame(t, d, "stub") + running := sessions.Proc{PID: 9, Exe: g.ExePath, Measured: true, Memory: 2 << 30, CPU: time.Second} + d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{running}, nil } + d.PollSessions() + if d.PlayingSince(g.ID).IsZero() { + t.Fatal("setup: the game was not seen running") + } + // The game closed; a stub of the same program lingers, doing nothing. + stub := sessions.Proc{PID: 9, Exe: g.ExePath, Measured: true, Memory: 800 << 10, CPU: time.Second} + d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{stub}, nil } + d.PollSessions() + since := d.PlayingSince(g.ID) + d.sessions.tracker.Finish(g.ID, since.Add(time.Hour)) // past the grace the tracker allows + d.PollSessions() + if !d.PlayingSince(g.ID).IsZero() { + t.Fatal("a lingering stub keeps the session open") + } + if d.holdSnapshotWhilePlaying(g.ID) { + t.Error("a change right after the session ended was held") + } + if !d.recognised(g.ID) { + t.Error("a game with a recorded session is not recognised") + } +} + +// A session's checkpoints are gone once the save it left is kept: one +// snapshot per session, not one per half hour. +func TestSessionEndDropsItsCheckpoints(t *testing.T) { + d := newTestDaemon(t) + g, dir := sessionGame(t, d, "checkpointed") + d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{{PID: 3, Exe: g.ExePath}}, nil } + d.PollSessions() + since := d.PlayingSince(g.ID) + if since.IsZero() { + t.Fatal("setup: not playing") + } + for i, data := range []string{"half an hour in", "an hour in"} { + if err := os.WriteFile(filepath.Join(dir, "slot1.sav"), []byte(data), 0o666); err != nil { + t.Fatal(err) + } + if _, err := d.Snapshots.Create(g.ID, checkpointComment, true); err != nil { + t.Fatalf("checkpoint %d: %v", i, err) + } + } + if err := os.WriteFile(filepath.Join(dir, "slot1.sav"), []byte("as it was left"), 0o666); err != nil { + t.Fatal(err) + } + d.sessions.tracker.Finish(g.ID, since.Add(90*time.Minute)) + + got := snapComments(t, d, g.ID) + if len(got) != 1 || got[0] != "After playing (1 h 30 min)" { + t.Errorf("snapshots = %q, want only the session's", got) + } +} + +// In a session, a change is the session's: no stretch is started alongside +// it, whose timer once cleared the session's last checkpoint and let nearly +// every autosave after the first half hour through as a new one. +func TestSessionChangesStartNoStretch(t *testing.T) { + d := newTestDaemon(t) + g, _ := sessionGame(t, d, "in-session") + d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{{PID: 4, Exe: g.ExePath}}, nil } + d.PollSessions() + if d.PlayingSince(g.ID).IsZero() { + t.Fatal("setup: not playing") + } + for i := 0; i < 3; i++ { + if !d.holdSnapshotWhilePlaying(g.ID) { + t.Fatalf("autosave %d in a fresh session was snapshotted", i) + } + } + if d.changingNow(g.ID) { + t.Error("a stretch runs alongside the session") + } +} + +// A game that autosaves every ten minutes keeps its stretch open: the wait +// is twice the longest gap seen, within bounds. +func TestStretchWaitAdaptsToAutosaves(t *testing.T) { + for _, c := range []struct { + a activity + want time.Duration + }{ + {activity{changes: 1}, firstChangeWait}, + {activity{changes: 2, maxGap: time.Minute}, quietAfter}, + {activity{changes: 3, maxGap: 10 * time.Minute}, 20 * time.Minute}, + {activity{changes: 1, maxGap: 10 * time.Minute}, 20 * time.Minute}, + {activity{changes: 4, maxGap: time.Hour}, maxQuiet}, + } { + if got := c.a.quietFor(); got != c.want { + t.Errorf("%d changes, gap %v: wait %v, want %v", c.a.changes, c.a.maxGap, got, c.want) + } + } +} diff --git a/internal/sessions/procs_windows.go b/internal/sessions/procs_windows.go index 5e22216b..73450d37 100644 --- a/internal/sessions/procs_windows.go +++ b/internal/sessions/procs_windows.go @@ -3,14 +3,16 @@ package sessions import ( + "time" "unsafe" "golang.org/x/sys/windows" ) // listProcesses walks the process table and asks each process for its full -// program path. A process whose path cannot be read is listed by its file name -// alone (Proc.Name): a game protected against tampering keeps its path from +// program path, how much memory it holds and how much processor time it has +// used. A process whose path cannot be read is listed by its file name alone +// (Proc.Name): a game protected against tampering keeps its path from // everyone, and matching by name within its install folder's programs is // still exact enough. func listProcesses() ([]Proc, error) { @@ -29,8 +31,8 @@ func listProcesses() ([]Proc, error) { buf := make([]uint16, windows.MAX_LONG_PATH) for { if pid := entry.ProcessID; pid > 4 { - if exe := imagePath(pid, buf); exe != "" { - out = append(out, Proc{PID: int(pid), Exe: exe}) + if p, ok := inspect(pid, buf); ok { + out = append(out, p) } else if name := windows.UTF16ToString(entry.ExeFile[:]); name != "" { // Its path is not ours to read: a game protected against // tampering, run elevated, or another user's. Known by name. @@ -44,15 +46,46 @@ func listProcesses() ([]Proc, error) { return out, nil } -func imagePath(pid uint32, buf []uint16) string { +var procGetProcessMemoryInfo = windows.NewLazySystemDLL("kernel32.dll").NewProc("K32GetProcessMemoryInfo") + +// processMemoryCounters is PROCESS_MEMORY_COUNTERS. +type processMemoryCounters struct { + cb uint32 + PageFaultCount uint32 + PeakWorkingSetSize uintptr + WorkingSetSize uintptr + QuotaPeakPagedPoolUsage uintptr + QuotaPagedPoolUsage uintptr + QuotaPeakNonPagedPoolUsage uintptr + QuotaNonPagedPoolUsage uintptr + PagefileUsage uintptr + PeakPagefileUsage uintptr +} + +func inspect(pid uint32, buf []uint16) (Proc, bool) { h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) if err != nil { - return "" + return Proc{}, false } defer windows.CloseHandle(h) n := uint32(len(buf)) if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &n); err != nil { - return "" + return Proc{}, false + } + p := Proc{PID: int(pid), Exe: windows.UTF16ToString(buf[:n])} + + var creation, exit, kernel, user windows.Filetime + timesOK := windows.GetProcessTimes(h, &creation, &exit, &kernel, &user) == nil + var mem processMemoryCounters + mem.cb = uint32(unsafe.Sizeof(mem)) + r, _, _ := procGetProcessMemoryInfo.Call(uintptr(h), uintptr(unsafe.Pointer(&mem)), uintptr(mem.cb)) + if timesOK && r != 0 { + p.Measured = true + p.Memory = uint64(mem.WorkingSetSize) + // FILETIME counts 100-nanosecond intervals. + ticks := (uint64(kernel.HighDateTime)<<32 | uint64(kernel.LowDateTime)) + + (uint64(user.HighDateTime)<<32 | uint64(user.LowDateTime)) + p.CPU = time.Duration(ticks * 100) } - return windows.UTF16ToString(buf[:n]) + return p, true } diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index 9ad3cdd5..c3c3ceac 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -39,6 +39,14 @@ type Proc struct { Name string // Args is the command line, where the system says (Linux). Args []string + // Measured says Memory and CPU were read: the program's working set in + // bytes, and the processor time it has used so far. A game being played + // holds hundreds of megabytes and keeps the processor busy; what a game + // leaves behind when it closes — a crash handler, a stub of the same name + // — holds next to nothing and does nothing (Idle). + Measured bool + Memory uint64 + CPU time.Duration // SteamAppID is the Steam app the process was started for, where Steam // tagged it (Linux: SteamAppId in the environment, or AppId=… on the // command line of Steam's launcher). @@ -313,3 +321,19 @@ func FolderOf(p Proc, t Target) string { } return "" } + +// Idle reports a process that holds next to no memory and has used no +// processor time since it was last seen (prevCPU, when it was): not a game +// being played, whatever it is called. A process that could not be measured +// is never called idle. +func Idle(p Proc, prevCPU time.Duration, seenBefore bool) bool { + if !p.Measured || p.Memory >= idleMemory { + return false + } + return !seenBefore || p.CPU-prevCPU < idleCPU +} + +const ( + idleMemory = 32 << 20 // a game holds far more + idleCPU = 100 * time.Millisecond // per poll: 1% of ten seconds +) diff --git a/internal/sessions/sessions_test.go b/internal/sessions/sessions_test.go index 2803e457..516dc3fc 100644 --- a/internal/sessions/sessions_test.go +++ b/internal/sessions/sessions_test.go @@ -138,3 +138,28 @@ func TestRunning_ByInstallFolderName(t *testing.T) { t.Errorf("a folder whose name only begins the same was taken for the game: %v", got) } } + +// What a game leaves behind when it closes is not the game being played: next +// to no memory, no processor time since it was last seen. +func TestIdle(t *testing.T) { + stub := Proc{PID: 1, Exe: `G:\Games\Crimson Desert\bin64\CrimsonDesert.exe`, Measured: true, Memory: 900 << 10, CPU: 2 * time.Second} + if !Idle(stub, 2*time.Second, true) { + t.Error("a lingering stub counts as the game") + } + if !Idle(stub, 0, false) { + t.Error("a stub seen for the first time counts as the game") + } + busy := stub + busy.CPU = 3 * time.Second + if Idle(busy, 2*time.Second, true) { + t.Error("a small process using the processor is called idle") + } + game := stub + game.Memory = 3 << 30 + if Idle(game, 2*time.Second, true) { + t.Error("a game holding gigabytes is called idle while paused") + } + if Idle(Proc{PID: 2, Name: "CrimsonDesert.exe"}, 0, false) { + t.Error("a process that could not be measured is called idle") + } +} diff --git a/testutil/harness.go b/testutil/harness.go index ec69c98b..c5d7d043 100644 --- a/testutil/harness.go +++ b/testutil/harness.go @@ -175,6 +175,13 @@ func NewTestDaemon(t *testing.T, name string) *TestDaemon { } td := &TestDaemon{T: t, Daemon: d, Server: srv, Addr: addr, Port: port, SaveDir: saveDir} + t.Cleanup(func() { + if t.Failed() { + for _, e := range d.Log.History() { + t.Logf("[%s] %s %s", name, e.Level, e.Message) + } + } + }) t.Cleanup(func() { srv.Stop() d.Stop() From ee9d18f90a09b8f57948446d92b7870eb9dbbb42 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:18:52 +0200 Subject: [PATCH 45/75] Track a game as the game database knows it; folders of Steam's alone are no saves Twenty-odd games were tracked at Steam's userdata/<user>/<appid> folders that held nothing but Steam's own remotecache.vdf - no save, nothing to sync. Measuring a found save now leaves out the files no save is made of (delta.NeverSynced), so such a folder is empty and is never offered or tracked as a new game. Tracking by hand gets the game database's knowledge, which a folder picked on its own lacked - name only, no App ID, so no cover art, no launching, no session detection, no device settings: - "Track game" beside "Track folder" on Home: a game running now (a program holding over 200 MB that the database knows), one found by name, or the one a program or a save folder chosen belongs to. It is tracked with its name, App ID, program, and a save location the database knows on this device (presets/gamedb.go, /api/gamedb/{running,search, identify}). - "Track folder" recognises the folder the same way and tracks it with the game's name and App ID ("Recognised as ..."). - A program is recognised by the Steam install folder it is in, or a folder above it named as the game's install folder, compared without spaces: the database writes "ProjectZomboid" for "Project Zomboid". Session detection compares install folder names the same way. - A game re-tracked on another device is not restored at a folder here that holds no save (only Steam's own file): placed afresh, it follows the other device's save location instead of coming back at the empty folder it was untracked for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../frontend/src/views/Home.svelte | 8 + .../src/views/home/AddGameCard.svelte | 25 +- .../src/views/home/TrackGameDialog.svelte | 329 ++++++++++++++++++ internal/api/routes.go | 8 +- internal/api/routes_gamedb.go | 36 ++ internal/daemon/daemon.go | 8 + internal/daemon/devicesettings_test.go | 25 ++ internal/daemon/gamedb.go | 124 +++++++ internal/presets/devicesettings.go | 12 + internal/presets/gamedb.go | 221 ++++++++++++ internal/presets/gamedb_test.go | 69 ++++ internal/presets/savestats.go | 8 + internal/presets/savestats_test.go | 14 + internal/sessions/sessions.go | 6 +- 14 files changed, 887 insertions(+), 6 deletions(-) create mode 100644 cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte create mode 100644 internal/api/routes_gamedb.go create mode 100644 internal/daemon/gamedb.go create mode 100644 internal/presets/gamedb.go create mode 100644 internal/presets/gamedb_test.go diff --git a/cmd/opensave-app/frontend/src/views/Home.svelte b/cmd/opensave-app/frontend/src/views/Home.svelte index bcfd5091..afad2c09 100644 --- a/cmd/opensave-app/frontend/src/views/Home.svelte +++ b/cmd/opensave-app/frontend/src/views/Home.svelte @@ -5,6 +5,7 @@ import { gameStatus, conflictedIds } from '../lib/gamestatus.js'; import OfferedGames from './home/OfferedGames.svelte'; import AddGameCard from './home/AddGameCard.svelte'; + import TrackGameDialog from './home/TrackGameDialog.svelte'; import ScanDialog from './home/ScanDialog.svelte'; import HomeSummary from './home/HomeSummary.svelte'; import LibraryGrid from './home/LibraryGrid.svelte'; @@ -30,6 +31,8 @@ // on the params object, so each request — the sidebar's +, Ctrl+K — opens // it once, and not again on every re-render. let showAdd = false; + // Tracking a game the game database knows (TrackGameDialog). + let showTrackGame = false; let addPath = ''; let handledAdd = null; $: if (params.add && params !== handledAdd) { @@ -108,6 +111,7 @@ </button> <button class="btn" on:click={syncAll} disabled={$gameList.length === 0 || $syncPause.paused} title={$syncPause.paused ? 'Syncing is paused' : ''}><RefreshCw size={15} />Sync all</button> <PauseButton /> + <button class="btn" on:click={() => (showTrackGame = true)} title="Pick a running game, find one by name, or choose its program"><Gamepad2 size={16} />Track game</button> <button class="btn" on:click={trackFolder} title="Pick a save folder or file to track"><FolderPlus size={16} />Track folder</button> </div> </div> @@ -118,6 +122,10 @@ <AddGameCard path={addPath} on:close={() => (showAdd = false)} /> {/if} +{#if showTrackGame} + <TrackGameDialog on:close={() => (showTrackGame = false)} /> +{/if} + <ScanDialog bind:this={scanner} bind:scanning /> {#if !$stateLoaded} diff --git a/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte b/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte index 9b9952b3..7df79cfa 100644 --- a/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte +++ b/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte @@ -20,6 +20,10 @@ export let path = ''; let name = ''; + // The game the folder is a save location of, in the game database: its + // name and App ID (cover art, launching, seeing it played). + let appId = ''; + let knownAs = ''; let typed = false; // once typed in, a late suggestion must not replace it let adding = false; let nameInput; @@ -31,10 +35,24 @@ suggestedFor = p; if (!p) return; let guess = folderName(p); + appId = ''; + knownAs = ''; try { - guess = (await api.get(`/api/suggest-name?path=${encodeURIComponent(p)}`)).name || guess; + const id = await api.get(`/api/gamedb/identify?path=${encodeURIComponent(p)}`); + if (id.found && p === path) { + appId = id.game.appId ?? ''; + knownAs = id.game.name; + guess = id.game.name; + } } catch { - // The folder's own name will do. + // Named from the path instead. + } + if (!knownAs) { + try { + guess = (await api.get(`/api/suggest-name?path=${encodeURIComponent(p)}`)).name || guess; + } catch { + // The folder's own name will do. + } } if (p !== path || typed) return; name = guess; @@ -64,7 +82,7 @@ if (!name.trim() || !path || adding) return; adding = true; try { - const game = await api.post('/api/games', { name: name.trim(), savePath: path }); + const game = await api.post('/api/games', { name: name.trim(), savePath: path, appId }); toast(`Now tracking "${game.name ?? name}"`, 'success'); dispatch('close'); navigate('game', { gameId: game.id }); @@ -101,6 +119,7 @@ on:keydown={(e) => e.key === 'Enter' && add()} /> <span class="hint">How it shows in your library, and on your other devices.</span> + {#if knownAs}<span class="hint">Recognised as <strong>{knownAs}</strong> — cover art and its settings come from the game database.</span>{/if} </div> </div> <ModalFoot> diff --git a/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte b/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte new file mode 100644 index 00000000..d83d6187 --- /dev/null +++ b/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte @@ -0,0 +1,329 @@ +<script> + // Tracking a game by hand, as the game database knows it: one running now, + // one found by name, or the one a program or folder belongs to. Whatever it + // is picked by, it is tracked with its name, App ID (cover art, launching, + // seeing it played) and a save location the database knows on this device — + // what a folder tracked on its own lacked. Fires `close` on Cancel and after + // tracking, which also opens the new game's page. + import { createEventDispatcher, onMount, onDestroy } from 'svelte'; + import Gamepad2 from 'lucide-svelte/icons/gamepad-2'; + import Modal from '../../components/ui/Modal.svelte'; + import ModalFoot from '../../components/ui/ModalFoot.svelte'; + import Spinner from '../../components/ui/Spinner.svelte'; + import { navigate, toast } from '../../lib/stores.js'; + import { api, native, coverURL } from '../../lib/api.js'; + + const dispatch = createEventDispatcher(); + const canPick = native.isWails(); + + let running = null; // null while being looked for + let query = ''; + let results = []; + let searching = false; + let note = ''; + + // The game picked, and how it is to be tracked. + let picked = null; + let name = ''; + let savePath = ''; + let exePath = ''; + let adding = false; + + onMount(async () => { + try { + running = (await api.get('/api/gamedb/running')).games ?? []; + } catch { + running = []; + } + }); + + let searchTimer = null; + onDestroy(() => clearTimeout(searchTimer)); + $: queueSearch(query); + function queueSearch(q) { + clearTimeout(searchTimer); + if ((q ?? '').trim().length < 2) { + results = []; + return; + } + searchTimer = setTimeout(async () => { + searching = true; + try { + results = (await api.get(`/api/gamedb/search?q=${encodeURIComponent(q.trim())}`)).games ?? []; + } catch { + results = []; + } finally { + searching = false; + } + }, 250); + } + + function choose(game, exe = '') { + picked = game; + name = game.name; + savePath = game.savePaths?.[0] ?? ''; + exePath = exe; + note = ''; + } + + async function identify(path, exe) { + note = ''; + try { + const res = await api.get(`/api/gamedb/identify?path=${encodeURIComponent(path)}`); + if (res.found) { + choose(res.game, exe ? path : ''); + if (!exe && !savePath) savePath = path; + return; + } + } catch { + // Not known: tracked as picked, under a name to give it. + } + picked = { name: '', appId: '', savePaths: [] }; + name = ''; + exePath = exe ? path : ''; + savePath = exe ? '' : path; + note = 'Not in the game database — give it a name, and pick its save folder if it is not set.'; + } + + async function pickProgram() { + const exe = await native.selectFile('Choose the game’s program'); + if (exe) await identify(exe, true); + } + + async function pickFolder() { + const dir = await native.selectDirectory('Choose the game’s save folder'); + if (dir) await identify(dir, false); + } + + async function pickSaveFolder() { + const dir = await native.selectDirectory(`Save folder of “${name || 'the game'}”`); + if (dir) savePath = dir; + } + + async function track() { + if (!name.trim() || !savePath || adding) return; + adding = true; + try { + const game = await api.post('/api/games', { + name: name.trim(), + savePath, + appId: picked?.appId ?? '', + exePath + }); + toast(`Now tracking "${game.name ?? name}"`, 'success'); + dispatch('close'); + navigate('game', { gameId: game.id }); + } catch (e) { + toast(e.message, 'error'); + } finally { + adding = false; + } + } +</script> + +<Modal title="Track a game" icon={Gamepad2} width={620} height="auto" onClose={() => dispatch('close')}> + <div class="body"> + {#if !picked} + <div class="field"> + <span class="label">Running now</span> + {#if running === null} + <p class="hint"><Spinner size={13} /> Looking at what is running…</p> + {:else if running.length === 0} + <p class="hint">No game the game database knows is running right now.</p> + {:else} + <div class="list"> + {#each running as g (g.exe)} + <button class="row" disabled={g.tracked} on:click={() => choose(g, g.exe)}> + <span class="row-name">{g.name}</span> + <span class="row-sub">{g.tracked ? 'already tracked' : g.installDir || g.exe}</span> + </button> + {/each} + </div> + {/if} + </div> + + <div class="field"> + <label for="tg-search">Find by name</label> + <input id="tg-search" placeholder="e.g. Crimson Desert" bind:value={query} /> + {#if searching} + <p class="hint"><Spinner size={13} /> Searching…</p> + {:else if results.length > 0} + <div class="list"> + {#each results as g (g.name)} + <button class="row" on:click={() => choose(g)}> + <span class="row-name">{g.name}</span> + <span class="row-sub"> + {g.savePaths?.length ? g.savePaths[0] : 'no saves found on this PC'} + </span> + </button> + {/each} + </div> + {:else if query.trim().length >= 2} + <p class="hint">Nothing by that name in the game database.</p> + {/if} + </div> + + {#if canPick} + <div class="alt"> + <button class="linklike" on:click={pickProgram}>Choose the game’s program…</button> + <button class="linklike" on:click={pickFolder}>Choose its save folder…</button> + </div> + {/if} + {:else} + <div class="chosen"> + {#if picked.appId} + <img class="cover" src={coverURL(picked.appId)} alt="" on:error={(e) => (e.currentTarget.style.display = 'none')} /> + {/if} + <div class="field grow"> + <label for="tg-name">Name</label> + <input id="tg-name" bind:value={name} placeholder="e.g. Elden Ring" /> + </div> + </div> + {#if note}<p class="hint">{note}</p>{/if} + + <div class="field"> + <span class="label">Save folder</span> + {#if picked.savePaths?.length > 1} + {#each picked.savePaths as p} + <label class="check"><input type="radio" bind:group={savePath} value={p} /> <bdi>{p}</bdi></label> + {/each} + {:else if savePath} + <div class="picked" title={savePath}><bdi>{savePath}</bdi></div> + {:else} + <p class="hint">No saves of it found on this PC yet. Choose where it saves, or play it once and track it then.</p> + {/if} + {#if canPick} + <div class="alt"><button class="linklike" on:click={pickSaveFolder}>Choose another save folder…</button></div> + {/if} + </div> + {#if exePath} + <div class="field"> + <span class="label">Program</span> + <div class="picked" title={exePath}><bdi>{exePath}</bdi></div> + </div> + {/if} + <div class="alt"><button class="linklike" on:click={() => (picked = null)}>← Pick another game</button></div> + {/if} + </div> + <ModalFoot> + <span /> + <div class="actions"> + <button class="btn" on:click={() => dispatch('close')}>Cancel</button> + <button class="btn primary" disabled={!picked || !name.trim() || !savePath || adding} on:click={track}> + {adding ? 'Adding…' : 'Start tracking'} + </button> + </div> + </ModalFoot> +</Modal> + +<style> + .body { + padding: 18px 22px 20px; + display: flex; + flex-direction: column; + gap: 16px; + } + .field { + display: flex; + flex-direction: column; + gap: 6px; + } + .grow { + flex: 1; + } + .label, + label { + font-size: 0.82rem; + color: var(--text-dim); + } + .list { + display: flex; + flex-direction: column; + max-height: 220px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 8px; + } + .row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 8px 10px; + background: none; + border: none; + border-bottom: 1px solid var(--border); + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; + } + .row:last-child { + border-bottom: none; + } + .row:hover:not(:disabled) { + background: var(--bg); + } + .row:disabled { + opacity: 0.5; + cursor: default; + } + .row-sub { + font-size: 0.78rem; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + .chosen { + display: flex; + gap: 14px; + align-items: flex-end; + } + .cover { + width: 140px; + aspect-ratio: 460 / 215; + object-fit: cover; + border-radius: 8px; + border: 1px solid var(--border); + } + .picked { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8rem; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; + } + .alt { + display: flex; + gap: 16px; + } + .linklike { + background: none; + border: none; + padding: 0; + font: inherit; + font-size: 0.8rem; + color: var(--text-dim); + text-decoration: underline; + cursor: pointer; + } + .linklike:hover { + color: var(--text); + } + .hint { + margin: 0; + } + .actions { + display: flex; + gap: 8px; + } +</style> diff --git a/internal/api/routes.go b/internal/api/routes.go index f7bd3c59..6ccc81e0 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -30,6 +30,10 @@ func (s *Server) routes(r chi.Router) { r.Get("/api/games", s.handleListGames) r.Post("/api/games", s.handleTrackGame) + // The game database, for tracking a game by hand (daemon/gamedb.go). + r.Get("/api/gamedb/search", s.handleGameDBSearch) + r.Get("/api/gamedb/identify", s.handleGameDBIdentify) + r.Get("/api/gamedb/running", s.handleGameDBRunning) r.Get("/api/suggest-name", s.handleSuggestName) r.Post("/api/games/untrack-bulk", s.handleBulkUntrack) @@ -345,6 +349,8 @@ func (s *Server) handleTrackGame(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` SavePath string `json:"savePath"` AppID string `json:"appId"` + // ExePath: the game's program, when it was picked by it. + ExePath string `json:"exePath"` } if err := readJSON(r, &body); err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -355,7 +361,7 @@ func (s *Server) handleTrackGame(w http.ResponseWriter, r *http.Request) { return } - game, err := s.Daemon.TrackGame(store.Game{Name: body.Name, SavePath: body.SavePath, AppID: body.AppID}) + game, err := s.Daemon.TrackGame(store.Game{Name: body.Name, SavePath: body.SavePath, AppID: body.AppID, ExePath: body.ExePath}) if err != nil { // Duplicates (id or path) are conflicts; anything else the daemon // rejects is bad input. diff --git a/internal/api/routes_gamedb.go b/internal/api/routes_gamedb.go new file mode 100644 index 00000000..e75b1995 --- /dev/null +++ b/internal/api/routes_gamedb.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + + "github.com/opensave/opensave/internal/daemon" + "github.com/opensave/opensave/internal/presets" +) + +// GET /api/gamedb/search?q= — games in the game database by name. +func (s *Server) handleGameDBSearch(w http.ResponseWriter, r *http.Request) { + out := s.Daemon.SearchGames(r.URL.Query().Get("q")) + if out == nil { + out = []presets.GameMatch{} + } + writeJSON(w, http.StatusOK, map[string]any{"games": out}) +} + +// GET /api/gamedb/identify?path= — the game a program or folder belongs to. +func (s *Server) handleGameDBIdentify(w http.ResponseWriter, r *http.Request) { + m, ok := s.Daemon.IdentifyGame(r.URL.Query().Get("path")) + if !ok { + writeJSON(w, http.StatusOK, map[string]any{"found": false}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"found": true, "game": m}) +} + +// GET /api/gamedb/running — games running on this device now. +func (s *Server) handleGameDBRunning(w http.ResponseWriter, r *http.Request) { + out := s.Daemon.RunningGames() + if out == nil { + out = []daemon.RunningGame{} + } + writeJSON(w, http.StatusOK, map[string]any{"games": out}) +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 5324b5f2..808d29c6 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1355,6 +1355,14 @@ func (d *Daemon) retrackFromPeer(gameID string) { d.Log.Log("info", fmt.Sprintf("game %q re-tracked on a paired device; its old folder here (%s) is gone, so it will be placed afresh", gameID, savePath)) return } + // Nor at one that holds no save: a game untracked because it was tracked + // where it never saved (a Steam folder of Steam's own files) and tracked + // again where it does on the other device must not come back here at + // the empty folder. Placed afresh, it follows the other device's. + if holdsNoSave(savePath) { + d.Log.Log("info", fmt.Sprintf("game %q re-tracked on a paired device; its old folder here (%s) held no save, so it will be placed afresh", gameID, savePath)) + return + } if name == "" { name = gameID } diff --git a/internal/daemon/devicesettings_test.go b/internal/daemon/devicesettings_test.go index 786900f0..3fc6ee23 100644 --- a/internal/daemon/devicesettings_test.go +++ b/internal/daemon/devicesettings_test.go @@ -126,3 +126,28 @@ func TestObserveSettings_GivesBackWhatChangesLikeASave(t *testing.T) { } } } + +// A game re-tracked on another device is not restored here at a folder that +// never held a save — only Steam's own file — but at one that does. +func TestRetrackFromPeer_NotAtAFolderWithoutASave(t *testing.T) { + d := newTestDaemon(t) + empty := t.TempDir() + if err := os.WriteFile(filepath.Join(empty, "remotecache.vdf"), []byte("steam"), 0o666); err != nil { + t.Fatal(err) + } + _ = d.Store.AddUntrackedTombstone("pal", "Pal", empty) + d.retrackFromPeer("pal") + if _, err := d.Store.GetGame("pal"); err == nil { + t.Error("restored at a folder holding only Steam's file") + } + + saves := t.TempDir() + if err := os.WriteFile(filepath.Join(saves, "world.sav"), []byte("progress"), 0o666); err != nil { + t.Fatal(err) + } + _ = d.Store.AddUntrackedTombstone("real", "Real", saves) + d.retrackFromPeer("real") + if _, err := d.Store.GetGame("real"); err != nil { + t.Error("not restored at a folder holding its save") + } +} diff --git a/internal/daemon/gamedb.go b/internal/daemon/gamedb.go new file mode 100644 index 00000000..6775468a --- /dev/null +++ b/internal/daemon/gamedb.go @@ -0,0 +1,124 @@ +package daemon + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/presets" + "github.com/opensave/opensave/internal/sessions" +) + +// Tracking a game by hand, with what the game database knows about it: a +// game picked from those running, found by name, or recognised from a +// program or folder chosen. See presets/gamedb.go. + +func (d *Daemon) gameDB() *presets.DeviceSettings { + if d.Scanner == nil { + return nil + } + d.devMu.Lock() + defer d.devMu.Unlock() + return d.devDBLocked() +} + +// SearchGames finds games in the game database by name. +func (d *Daemon) SearchGames(query string) []presets.GameMatch { + return d.gameDB().Search(query, 20) +} + +// IdentifyGame says which game a program or folder belongs to: a program by +// where it is installed, a folder by the game whose save location it is. +func (d *Daemon) IdentifyGame(path string) (presets.GameMatch, bool) { + db := d.gameDB() + info, err := os.Stat(path) + if err != nil { + return presets.GameMatch{}, false + } + if !info.IsDir() && strings.EqualFold(filepath.Ext(path), ".exe") { + return db.IdentifyProgram(path) + } + if !info.IsDir() { + path = filepath.Dir(path) // a save file: its folder + } + return db.IdentifyFolder(path) +} + +// RunningGame is a game found running on this device. +type RunningGame struct { + presets.GameMatch + Exe string `json:"exe"` + Tracked bool `json:"tracked"` +} + +// runningMinMemory is what a program must hold to be offered as a game being +// played: a game holds hundreds of megabytes, and what is small is a tool, a +// launcher, or something a game left behind. +const runningMinMemory = 200 << 20 + +// RunningGames lists the programs running now that the game database knows +// as games, largest first, once per game. +func (d *Daemon) RunningGames() []RunningGame { + if d.sessions.list == nil { + return nil + } + procs, err := d.sessions.list() + if err != nil { + return nil + } + sort.Slice(procs, func(i, j int) bool { return procs[i].Memory > procs[j].Memory }) + db := d.gameDB() + winDir := strings.ToLower(os.Getenv("WINDIR")) + tracked := d.trackedGameKeys() + seen := map[string]bool{} + var out []RunningGame + for _, p := range procs { + if !p.Measured || p.Memory < runningMinMemory || p.Exe == "" { + continue + } + if winDir != "" && strings.HasPrefix(strings.ToLower(p.Exe), winDir+string(filepath.Separator)) { + continue + } + m, ok := db.IdentifyProgram(p.Exe) + if !ok || seen[m.Name] { + continue + } + seen[m.Name] = true + out = append(out, RunningGame{GameMatch: m, Exe: p.Exe, Tracked: tracked[m.AppID] || tracked[sessions.FolderName(m.Name)]}) + } + return out +} + +// trackedGameKeys holds every tracked game's App ID and name, to say which +// running games are tracked already. +func (d *Daemon) trackedGameKeys() map[string]bool { + keys := map[string]bool{} + games, err := d.Store.ListGames() + if err != nil { + return keys + } + for _, g := range games { + if g.AppID != "" { + keys[g.AppID] = true + } + keys[sessions.FolderName(g.Name)] = true + } + return keys +} + +// holdsNoSave reports a folder with no file any save is made of: empty, or +// only what a launcher keeps there (delta.NeverSynced). +func holdsNoSave(path string) bool { + m, err := delta.BuildManifest(path) + if err != nil { + return false // cannot tell: not "nothing" + } + for p := range m.Files { + if !delta.NeverSynced(p) { + return false + } + } + return true +} diff --git a/internal/presets/devicesettings.go b/internal/presets/devicesettings.go index 34f87234..c78080c5 100644 --- a/internal/presets/devicesettings.go +++ b/internal/presets/devicesettings.go @@ -23,6 +23,12 @@ type DeviceSettings struct { byName map[string]indexedGame protonIx map[string][]string bases func([]string) []string + // all is every game in the index, for looking one up by what is on + // disk (gamedb.go); blocked, the folders no save location may be. + all []indexedGame + blocked map[string]bool + // byInstall: games by install folder name, built when first asked. + byInstall map[string][]indexedGame } // DeviceSettings loads the manifest index once for any number of lookups. @@ -36,6 +42,7 @@ func (sc *Scanner) DeviceSettings() *DeviceSettings { return nil } ds := &DeviceSettings{sc: sc, byID: map[string]indexedGame{}, byName: map[string]indexedGame{}} + ds.all = idx for _, g := range idx { if len(g.Config) == 0 && len(g.Paths) == 0 { continue @@ -50,6 +57,11 @@ func (sc *Scanner) DeviceSettings() *DeviceSettings { if sc.goos() == "linux" { ds.protonIx = sc.protonPrefixIndex() } + if sc.goos() == "windows" { + ds.blocked = blockedRoots(windowsPathVars()) + } else { + ds.blocked = linuxBlockedRoots(sc.linuxHome()) + } ds.bases = sc.installBaseCandidates() return ds } diff --git a/internal/presets/gamedb.go b/internal/presets/gamedb.go new file mode 100644 index 00000000..ca678a84 --- /dev/null +++ b/internal/presets/gamedb.go @@ -0,0 +1,221 @@ +package presets + +import ( + "path/filepath" + "sort" + "strings" +) + +// Finding a game in the game database by what is on this device: a program +// that is running or was picked, a folder that was picked, or a name typed. +// What it answers is everything tracking needs and a folder picked by hand +// lacked: the game's name, its Steam App ID (cover art, launching, sessions), +// and where its saves are on this device. + +// GameMatch is a game from the game database, as found on this device. +type GameMatch struct { + Name string `json:"name"` + AppID string `json:"appId,omitempty"` + // SavePaths are the game's save locations that exist on this device, + // most likely first. + SavePaths []string `json:"savePaths"` + // InstallDir is where it is installed, when that is known. + InstallDir string `json:"installDir,omitempty"` +} + +// savePathsHere lists a game's save locations that exist on this device. +func (ds *DeviceSettings) savePathsHere(g indexedGame) []string { + if ds == nil || len(g.Paths) == 0 { + return nil + } + return expandGamePaths(g, ds.sc.ludusaviVarSets(g, ds.protonIx), ds.bases, ds.blocked) +} + +func (ds *DeviceSettings) matchOf(g indexedGame, installDir string) GameMatch { + return GameMatch{Name: g.Name, AppID: g.SteamID, SavePaths: ds.savePathsHere(g), InstallDir: installDir} +} + +// Search finds games by name: the whole name first, then names that begin +// with what was typed, then names that contain it. +func (ds *DeviceSettings) Search(query string, limit int) []GameMatch { + q := normalizeGameName(query) + if ds == nil || len(q) < 2 { + return nil + } + type hit struct { + g indexedGame + rank int + } + var hits []hit + for _, g := range ds.all { + n := normalizeGameName(g.Name) + switch { + case n == q: + hits = append(hits, hit{g, 0}) + case strings.HasPrefix(n, q): + hits = append(hits, hit{g, 1}) + case strings.Contains(n, q): + hits = append(hits, hit{g, 2}) + } + } + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].rank != hits[j].rank { + return hits[i].rank < hits[j].rank + } + return len(hits[i].g.Name) < len(hits[j].g.Name) + }) + if len(hits) > limit { + hits = hits[:limit] + } + out := make([]GameMatch, 0, len(hits)) + for _, h := range hits { + out = append(out, ds.matchOf(h.g, "")) + } + return out +} + +// IdentifyProgram finds the game a program belongs to: by the Steam install +// folder it is in, or by the name of a folder above it that is the game's +// install folder in the game database ("G:\Games\Crimson Desert"). +func (ds *DeviceSettings) IdentifyProgram(exe string) (GameMatch, bool) { + if ds == nil || exe == "" { + return GameMatch{}, false + } + exe = filepath.Clean(exe) + byAppID, _ := ds.sc.InstallDirs() + for appID, dir := range byAppID { + if dir != "" && isSubPath(dir, exe) { + if g, ok := ds.gameByID(appID); ok { + return ds.matchOf(g, dir), true + } + } + } + byFolder := ds.installNameIndex() + for dir := filepath.Dir(exe); dir != filepath.Dir(dir); dir = filepath.Dir(dir) { + if gs := byFolder[installKey(filepath.Base(dir))]; len(gs) > 0 { + return ds.matchOf(pickGame(gs), dir), true + } + } + return GameMatch{}, false +} + +// IdentifyFolder finds the game a save folder belongs to: the game whose save +// location in the game database is this folder, or holds it, or is held by it +// (a folder picked one level too high). +func (ds *DeviceSettings) IdentifyFolder(folder string) (GameMatch, bool) { + if ds == nil || folder == "" { + return GameMatch{}, false + } + folder = filepath.Clean(folder) + low := strings.ToLower(folder) + best, bestScore := indexedGame{}, 0 + for _, g := range ds.all { + if len(g.Paths) == 0 { + continue + } + // An install folder is only worked out for a game whose install + // folder name is in the path at all: doing it for every game in the + // database would look on disk tens of thousands of times. + bases := func([]string) []string { return nil } + for _, inst := range g.Installs { + if inst != "" && strings.Contains(low, strings.ToLower(inst)) { + bases = ds.bases + break + } + } + for _, vars := range ds.sc.ludusaviVarSets(g, ds.protonIx) { + for _, tpl := range g.Paths { + for _, expanded := range expandTemplate(tpl, vars, bases(g.Installs), g.SteamID) { + score := 0 + switch { + case sameUnder(folder, expanded): + score = 3 + case under(expanded, folder): + score = 2 // a folder inside the save location + case under(folder, expanded): + score = 1 // the save location inside the folder picked + } + if score > bestScore || (score == bestScore && score > 0 && better(g, best)) { + best, bestScore = g, score + } + } + } + } + } + if bestScore == 0 { + return GameMatch{}, false + } + return ds.matchOf(best, ""), true +} + +// sameUnder reports whether path is pattern (a template expanded, possibly +// with wildcards), compared component by component. +func sameUnder(path, pattern string) bool { + rel, ok := relativeUnder(path, pattern+string(filepath.Separator)+"x") + return ok && rel == "x" +} + +// under reports whether path lies strictly inside pattern. +func under(pattern, path string) bool { + _, ok := relativeUnder(pattern, path) + return ok +} + +// better prefers a game with a Steam App ID — cover art, launching — when two +// match equally. +func better(g, than indexedGame) bool { + return g.SteamID != "" && than.SteamID == "" +} + +func pickGame(gs []indexedGame) indexedGame { + best := gs[0] + for _, g := range gs[1:] { + if better(g, best) { + best = g + } + } + return best +} + +func (ds *DeviceSettings) gameByID(appID string) (indexedGame, bool) { + if g, ok := ds.byID[appID]; ok { + return g, true + } + for _, g := range ds.all { + if g.SteamID == appID { + return g, true + } + } + return indexedGame{}, false +} + +// installNameIndex maps install folder names, normalised, to the games that +// use them, for names specific enough to say which game a folder is. +func (ds *DeviceSettings) installNameIndex() map[string][]indexedGame { + if ds.byInstall != nil { + return ds.byInstall + } + ds.byInstall = map[string][]indexedGame{} + for _, g := range ds.all { + for _, inst := range g.Installs { + n := installKey(inst) + if len(n) < 4 || genericInstallNames[n] { + continue + } + ds.byInstall[n] = append(ds.byInstall[n], g) + } + } + return ds.byInstall +} + +var genericInstallNames = map[string]bool{ + "game": true, "games": true, "data": true, "bin": true, "binaries": true, "launcher": true, + "client": true, "common": true, "steam": true, "app": true, +} + +// installKey is a folder name as compared with the game database's install +// folder names: normalised, and without spaces, so "Project Zomboid" is the +// "ProjectZomboid" the database knows. +func installKey(name string) string { + return strings.ReplaceAll(normalizeGameName(name), " ", "") +} diff --git a/internal/presets/gamedb_test.go b/internal/presets/gamedb_test.go new file mode 100644 index 00000000..d0b0a3a7 --- /dev/null +++ b/internal/presets/gamedb_test.go @@ -0,0 +1,69 @@ +package presets + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestGameDB_FindsGamesByNameFolderAndProgram(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("templates below use Windows placeholders") + } + home := t.TempDir() + t.Setenv("USERPROFILE", home) + t.Setenv("HOME", home) + local := filepath.Join(home, "AppData", "Local") + t.Setenv("LOCALAPPDATA", local) + save := filepath.Join(local, "Pearl", "CD", "save") + if err := os.MkdirAll(filepath.Join(save, "123"), 0o777); err != nil { + t.Fatal(err) + } + + sc := manifestScanner(t, ` +Pearl Game: + files: + "<winLocalAppData>/Pearl/CD/save": + tags: [save] + installDir: + Pearl Game: {} + steam: + id: 3321460 +Project Things: + files: + "<winLocalAppData>/Things/Saves": + tags: [save] + installDir: + ProjectThings: {} +`) + ds := sc.DeviceSettings() + if ds == nil { + t.Fatal("no index") + } + + got := ds.Search("pearl", 5) + if len(got) != 1 || got[0].AppID != "3321460" || len(got[0].SavePaths) != 1 || got[0].SavePaths[0] != save { + t.Errorf("search = %+v", got) + } + + for _, folder := range []string{save, filepath.Join(save, "123"), filepath.Join(local, "Pearl", "CD")} { + if m, ok := ds.IdentifyFolder(folder); !ok || m.Name != "Pearl Game" { + t.Errorf("folder %s: %+v %v", folder, m, ok) + } + } + if _, ok := ds.IdentifyFolder(filepath.Join(home, "Elsewhere")); ok { + t.Error("an unrelated folder was taken for a game") + } + + m, ok := ds.IdentifyProgram(`G:\Games\Pearl Game\bin64\game.exe`) + if !ok || m.Name != "Pearl Game" || m.InstallDir != `G:\Games\Pearl Game` { + t.Errorf("program in a folder of its own = %+v %v", m, ok) + } + if m, ok := ds.IdentifyProgram(`D:\Stuff\Project Things\run.exe`); !ok || m.Name != "Project Things" { + t.Errorf("install folder written with a space the database leaves out = %+v %v", m, ok) + } + if _, ok := ds.IdentifyProgram(`C:\Tools\editor.exe`); ok { + t.Error("an unrelated program was taken for a game") + } +} diff --git a/internal/presets/savestats.go b/internal/presets/savestats.go index 4a750b93..9152feb4 100644 --- a/internal/presets/savestats.go +++ b/internal/presets/savestats.go @@ -7,6 +7,8 @@ import ( "path/filepath" "sync" "time" + + "github.com/opensave/opensave/internal/delta" ) // Measuring is deliberately separate from detection. Scan answers "could a @@ -149,6 +151,12 @@ func measureOne(path string, deadline time.Time) (count int, bytes int64, latest if d.IsDir() { return nil } + // Not part of any save (Steam's remotecache.vdf): a folder holding + // only that is a game Steam knows of, not a save, and was tracked as + // one — twenty-odd games with nothing to sync. + if delta.NeverSynced(d.Name()) { + return nil + } fi, err := d.Info() if err != nil { return nil diff --git a/internal/presets/savestats_test.go b/internal/presets/savestats_test.go index 1d8b544d..8f7d5e41 100644 --- a/internal/presets/savestats_test.go +++ b/internal/presets/savestats_test.go @@ -211,3 +211,17 @@ func TestMeasure_FolderHoldingOnlyAJunctionIsEmpty(t *testing.T) { saves[0].FileCount) } } + +// A Steam userdata folder holding only Steam's own remotecache.vdf is not a +// save: measured, it is empty, and so it is never offered as a new game. +func TestMeasure_OnlyLauncherFilesIsEmpty(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "remotecache.vdf"), []byte("steam"), 0o666); err != nil { + t.Fatal(err) + } + saves := []DiscoveredSave{{SavePath: dir}} + Measure(saves) + if !saves[0].IsEmpty() { + t.Errorf("measured %d file(s): a folder of Steam's bookkeeping counts as a save", saves[0].FileCount) + } +} diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index c3c3ceac..705f3a35 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -311,9 +311,11 @@ func FolderOf(p Proc, t Target) string { } dir := filepath.Dir(p.Exe) for dir != "" && dir != filepath.Dir(dir) { - name := FolderName(filepath.Base(dir)) + // Without spaces: the game database writes "ProjectZomboid" for a + // folder anyone else calls "Project Zomboid". + name := strings.ReplaceAll(FolderName(filepath.Base(dir)), " ", "") for _, want := range t.FolderNames { - if name == want { + if name == strings.ReplaceAll(want, " ", "") { return dir } } From d442237a6247ac8009cd3d493a92333eb1488f0e Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:58:32 +0200 Subject: [PATCH 46/75] One folder, one game; link an entry to the game database instead Tracking Escape From Duckov from the game database put a second game on ...\TeamSoda\Duckov\Saves, inside the folder an entry named "Duckov" already tracked: the same files watched, snapshotted and synced twice. Only the very same folder was refused. - A new game is not tracked at a folder inside a tracked game's folder, or holding one; the refusal names the game (TrackGame, overlappingGame). Moving a game's own folder is not affected. - "Track game" says when the game picked is tracked already under another entry - its folder is, holds or is held by one the game database knows, or it has the App ID - and offers to link that entry to it instead: the database's name, App ID and program, without tracking the folder twice. - A game tracked without an App ID is linked to the game database once, when its folder is the game's save location, lies inside it, or holds it one folder up (IdentifyFolderStrictly) - never a folder holding many games' saves. It gets cover art, launching, session detection and its device settings; its name stays as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/views/home/TrackGameDialog.svelte | 47 ++++++++- internal/daemon/daemon.go | 6 ++ internal/daemon/detectsettings.go | 1 + internal/daemon/devicesettings_test.go | 21 ++++ internal/daemon/gamedb.go | 95 +++++++++++++++++-- internal/presets/gamedb.go | 30 +++++- internal/presets/gamedb_test.go | 33 +++++++ 7 files changed, 221 insertions(+), 12 deletions(-) diff --git a/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte b/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte index d83d6187..6f3a11cb 100644 --- a/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte +++ b/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte @@ -100,6 +100,26 @@ if (dir) savePath = dir; } + // The game is tracked already under another entry: give that one what the + // game database knows instead of tracking the same folder twice. + async function link() { + if (!picked?.trackedId || adding) return; + adding = true; + try { + const body = { name: picked.name }; + if (picked.appId) body.appId = picked.appId; + if (exePath) body.exePath = exePath; + await api.patch(`/api/games/${picked.trackedId}`, body); + toast(`“${picked.trackedName}” is now ${picked.name}`, 'success'); + dispatch('close'); + navigate('game', { gameId: picked.trackedId }); + } catch (e) { + toast(e.message, 'error'); + } finally { + adding = false; + } + } + async function track() { if (!name.trim() || !savePath || adding) return; adding = true; @@ -133,9 +153,9 @@ {:else} <div class="list"> {#each running as g (g.exe)} - <button class="row" disabled={g.tracked} on:click={() => choose(g, g.exe)}> + <button class="row" disabled={g.tracked && !g.trackedId} on:click={() => choose(g, g.exe)}> <span class="row-name">{g.name}</span> - <span class="row-sub">{g.tracked ? 'already tracked' : g.installDir || g.exe}</span> + <span class="row-sub">{g.trackedName ? `tracked as “${g.trackedName}”` : g.tracked ? 'already tracked' : g.installDir || g.exe}</span> </button> {/each} </div> @@ -153,7 +173,7 @@ <button class="row" on:click={() => choose(g)}> <span class="row-name">{g.name}</span> <span class="row-sub"> - {g.savePaths?.length ? g.savePaths[0] : 'no saves found on this PC'} + {g.trackedName ? `tracked as “${g.trackedName}”` : g.savePaths?.length ? g.savePaths[0] : 'no saves found on this PC'} </span> </button> {/each} @@ -180,6 +200,16 @@ </div> </div> {#if note}<p class="hint">{note}</p>{/if} + {#if picked.trackedId} + <div class="already"> + <p class="hint"> + Already tracked here as <strong>“{picked.trackedName}”</strong>, at the same save folder. Tracked again it would + sync the same files twice; link that entry to {picked.name || 'this game'} instead — its name, cover art, + settings and seeing it played come from the game database. + </p> + <button class="btn" disabled={adding} on:click={link}>Link “{picked.trackedName}” to {picked.name}</button> + </div> + {/if} <div class="field"> <span class="label">Save folder</span> @@ -209,7 +239,7 @@ <span /> <div class="actions"> <button class="btn" on:click={() => dispatch('close')}>Cancel</button> - <button class="btn primary" disabled={!picked || !name.trim() || !savePath || adding} on:click={track}> + <button class="btn primary" disabled={!picked || picked.trackedId || !name.trim() || !savePath || adding} on:click={track}> {adding ? 'Adding…' : 'Start tracking'} </button> </div> @@ -322,6 +352,15 @@ .hint { margin: 0; } + .already { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + } .actions { display: flex; gap: 8px; diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 808d29c6..150d2aa2 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -760,6 +760,12 @@ func (d *Daemon) TrackGame(game store.Game) (store.Game, error) { return store.Game{}, err } game.SavePath = abs + // Nor a folder inside another game's, or holding one: the same files + // would be watched, snapshotted and synced twice, under two games that + // each think them theirs. + if other, ok := d.overlappingGame(abs); ok { + return store.Game{}, fmt.Errorf("%q already tracks %s, which this folder is inside or holds — track one or the other", other.Name, other.SavePath) + } // Fresh track from the UI (no id supplied): derive the id from the name, // but disambiguate collisions so a second save location for a same-named diff --git a/internal/daemon/detectsettings.go b/internal/daemon/detectsettings.go index 562206c4..f3cfd998 100644 --- a/internal/daemon/detectsettings.go +++ b/internal/daemon/detectsettings.go @@ -58,6 +58,7 @@ func (d *Daemon) detectSettingsDue(ctx context.Context) { continue } } + game = d.linkToGameDatabase(game) d.DetectSettings(game) _ = d.Store.SetMark(settingsDetectMark(game.ID), want+"@"+time.Now().UTC().Format(time.RFC3339)) } diff --git a/internal/daemon/devicesettings_test.go b/internal/daemon/devicesettings_test.go index 3fc6ee23..57d87ced 100644 --- a/internal/daemon/devicesettings_test.go +++ b/internal/daemon/devicesettings_test.go @@ -151,3 +151,24 @@ func TestRetrackFromPeer_NotAtAFolderWithoutASave(t *testing.T) { t.Error("not restored at a folder holding its save") } } + +// A folder inside a tracked game's folder, or holding it, is not tracked as +// another game: the same files would sync twice. +func TestTrackGame_RefusesAFolderInsideAnother(t *testing.T) { + d := newTestDaemon(t) + outer := t.TempDir() + inner := filepath.Join(outer, "Saves") + if err := os.MkdirAll(inner, 0o777); err != nil { + t.Fatal(err) + } + if _, err := d.TrackGame(store.Game{Name: "Duckov", SavePath: outer}); err != nil { + t.Fatal(err) + } + if _, err := d.TrackGame(store.Game{Name: "Escape From Duckov", SavePath: inner}); err == nil { + t.Error("a folder inside a tracked game's was tracked as another game") + } + other := t.TempDir() + if _, err := d.TrackGame(store.Game{Name: "Unrelated", SavePath: other}); err != nil { + t.Errorf("an unrelated folder was refused: %v", err) + } +} diff --git a/internal/daemon/gamedb.go b/internal/daemon/gamedb.go index 6775468a..3f97d0c2 100644 --- a/internal/daemon/gamedb.go +++ b/internal/daemon/gamedb.go @@ -1,6 +1,7 @@ package daemon import ( + "fmt" "os" "path/filepath" "sort" @@ -9,6 +10,7 @@ import ( "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/presets" "github.com/opensave/opensave/internal/sessions" + "github.com/opensave/opensave/internal/store" ) // Tracking a game by hand, with what the game database knows about it: a @@ -26,7 +28,11 @@ func (d *Daemon) gameDB() *presets.DeviceSettings { // SearchGames finds games in the game database by name. func (d *Daemon) SearchGames(query string) []presets.GameMatch { - return d.gameDB().Search(query, 20) + out := d.gameDB().Search(query, 20) + for i := range out { + d.markTracked(&out[i]) + } + return out } // IdentifyGame says which game a program or folder belongs to: a program by @@ -37,13 +43,20 @@ func (d *Daemon) IdentifyGame(path string) (presets.GameMatch, bool) { if err != nil { return presets.GameMatch{}, false } - if !info.IsDir() && strings.EqualFold(filepath.Ext(path), ".exe") { - return db.IdentifyProgram(path) + var m presets.GameMatch + var ok bool + switch { + case !info.IsDir() && strings.EqualFold(filepath.Ext(path), ".exe"): + m, ok = db.IdentifyProgram(path) + case !info.IsDir(): + m, ok = db.IdentifyFolder(filepath.Dir(path)) // a save file: its folder + default: + m, ok = db.IdentifyFolder(path) } - if !info.IsDir() { - path = filepath.Dir(path) // a save file: its folder + if ok { + d.markTracked(&m) } - return db.IdentifyFolder(path) + return m, ok } // RunningGame is a game found running on this device. @@ -86,7 +99,8 @@ func (d *Daemon) RunningGames() []RunningGame { continue } seen[m.Name] = true - out = append(out, RunningGame{GameMatch: m, Exe: p.Exe, Tracked: tracked[m.AppID] || tracked[sessions.FolderName(m.Name)]}) + d.markTracked(&m) + out = append(out, RunningGame{GameMatch: m, Exe: p.Exe, Tracked: m.TrackedID != "" || tracked[m.AppID] || tracked[sessions.FolderName(m.Name)]}) } return out } @@ -122,3 +136,70 @@ func holdsNoSave(path string) bool { } return true } + +// overlappingGame is the tracked game whose folder holds path, or is inside +// it, if there is one. +func (d *Daemon) overlappingGame(path string) (store.Game, bool) { + games, err := d.Store.ListGames() + if err != nil { + return store.Game{}, false + } + for _, g := range games { + if g.SavePath != "" && store.PathsOverlap(path, g.SavePath) { + return g, true + } + } + return store.Game{}, false +} + +// markTracked fills in which tracked game, if any, a match already is. +func (d *Daemon) markTracked(m *presets.GameMatch) { + games, err := d.Store.ListGames() + if err != nil { + return + } + for _, g := range games { + same := m.AppID != "" && g.AppID == m.AppID + for _, p := range m.SavePaths { + if g.SavePath != "" && store.PathsOverlap(p, g.SavePath) { + same = true + } + } + if same { + m.TrackedID, m.TrackedName = g.ID, g.Name + return + } + } +} + +func gameDBMark(gameID string) string { return "gamedb:" + gameID } + +// linkToGameDatabase gives a game tracked without an App ID the one the game +// database knows its folder by, strictly (IdentifyFolderStrictly): a folder +// tracked by hand, or found under a name of the scan's own making ("Pal +// (Epic/Unreal Save)"), gets cover art, launching, session detection and its +// device settings. The name is the user's and is left as it is. Once per +// game and folder. +func (d *Daemon) linkToGameDatabase(game store.Game) store.Game { + if game.AppID != "" || game.SavePath == "" { + return game + } + if d.Store.Mark(gameDBMark(game.ID)) == game.SavePath { + return game + } + _ = d.Store.SetMark(gameDBMark(game.ID), game.SavePath) + m, ok := d.gameDB().IdentifyFolderStrictly(game.SavePath) + if !ok || m.AppID == "" { + return game + } + game.AppID = m.AppID + if game.CoverURL == "" { + game.CoverURL = SteamCoverURL(m.AppID) + } + if err := d.Store.UpdateGame(game); err != nil { + return game + } + d.Log.Log("info", fmt.Sprintf("%q is %s in the game database: cover art, its settings and seeing it played come from there", game.Name, m.Name)) + d.exclusionsChanged(game) + return game +} diff --git a/internal/presets/gamedb.go b/internal/presets/gamedb.go index ca678a84..6afa159d 100644 --- a/internal/presets/gamedb.go +++ b/internal/presets/gamedb.go @@ -1,6 +1,7 @@ package presets import ( + "path" "path/filepath" "sort" "strings" @@ -21,6 +22,11 @@ type GameMatch struct { SavePaths []string `json:"savePaths"` // InstallDir is where it is installed, when that is known. InstallDir string `json:"installDir,omitempty"` + // TrackedID and TrackedName name the game already tracked here that + // this one is — its folder is, holds or is held by one of SavePaths, or it + // has the App ID. Filled in by the daemon. + TrackedID string `json:"trackedId,omitempty"` + TrackedName string `json:"trackedName,omitempty"` } // savePathsHere lists a game's save locations that exist on this device. @@ -103,6 +109,20 @@ func (ds *DeviceSettings) IdentifyProgram(exe string) (GameMatch, bool) { // location in the game database is this folder, or holds it, or is held by it // (a folder picked one level too high). func (ds *DeviceSettings) IdentifyFolder(folder string) (GameMatch, bool) { + return ds.identifyFolder(folder, 0) +} + +// IdentifyFolderStrictly is IdentifyFolder for naming a game nobody asked +// about: a folder holding the save location only counts when that is at most +// one folder further down ("Duckov" holding "DuckovSaves"), never a folder +// like Documents that holds many games'. +func (ds *DeviceSettings) IdentifyFolderStrictly(folder string) (GameMatch, bool) { + return ds.identifyFolder(folder, 1) +} + +// identifyFolder: maxDepth limits how far below the folder a save location +// held by it may be, 0 for no limit. +func (ds *DeviceSettings) identifyFolder(folder string, maxDepth int) (GameMatch, bool) { if ds == nil || folder == "" { return GameMatch{}, false } @@ -133,7 +153,15 @@ func (ds *DeviceSettings) IdentifyFolder(folder string) (GameMatch, bool) { case under(expanded, folder): score = 2 // a folder inside the save location case under(folder, expanded): - score = 1 // the save location inside the folder picked + // The save location inside the folder picked, counted + // to the folder a file location is in. + rel, _ := relativeUnder(folder, expanded) + if path.Ext(path.Base(rel)) != "" { + rel = path.Dir(rel) + } + if maxDepth == 0 || strings.Count(rel, "/") < maxDepth { + score = 1 + } } if score > bestScore || (score == bestScore && score > 0 && better(g, best)) { best, bestScore = g, score diff --git a/internal/presets/gamedb_test.go b/internal/presets/gamedb_test.go index d0b0a3a7..5470d86c 100644 --- a/internal/presets/gamedb_test.go +++ b/internal/presets/gamedb_test.go @@ -67,3 +67,36 @@ Project Things: t.Error("an unrelated program was taken for a game") } } + +// Naming a game nobody asked about: a folder one level above its save +// location counts, a folder holding many games' does not. +func TestGameDB_IdentifyFolderStrictly(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("templates below use Windows placeholders") + } + home := t.TempDir() + t.Setenv("USERPROFILE", home) + t.Setenv("HOME", home) + low := filepath.Join(home, "AppData", "LocalLow") + if err := os.MkdirAll(filepath.Join(low, "Soda", "Duck", "Saves"), 0o777); err != nil { + t.Fatal(err) + } + sc := manifestScanner(t, ` +Duck Game: + files: + "<home>/AppData/LocalLow/Soda/Duck/Saves/*.sav": + tags: [save] + steam: + id: 3167020 +`) + ds := sc.DeviceSettings() + if m, ok := ds.IdentifyFolderStrictly(filepath.Join(low, "Soda", "Duck")); !ok || m.AppID != "3167020" { + t.Errorf("the folder above the save location: %+v %v", m, ok) + } + if _, ok := ds.IdentifyFolderStrictly(low); ok { + t.Error("a folder holding many games' saves was named after one") + } + if _, ok := ds.IdentifyFolder(low); !ok { + t.Error("asked about, a folder holding the save location further down is not recognised") + } +} From 548831b2fca7a9194201e7b832ee6101d58fb614 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:05:14 +0200 Subject: [PATCH 47/75] A save put back is the other device's; emptying is no version until meant Putting Project Zomboid back after its folder was emptied (test B on four devices) ended with the restored save as a version of its own on the device it was put back on, which every other device was asked to take, and with a file from the local snapshot winning over the other device's newer copy because its date was later. And it waited ten minutes first. - An emptied save is not a version while nobody has said the emptying was meant (emptyingUnconfirmed): neither the watcher's change nor a refresh names it one, and nothing written while it is held back does. Only "delete them there too" makes it a version the other devices take. Put back, the device keeps the version it had, so no device is told it is behind. - While a save is put back, every file both devices hold differently is the other device's, whatever the dates say; nothing is offered from here, nothing deleted there, and no conflict is asked about (putBackDecision). - An emptied save, or one held back after being emptied, is never handled as play: no stretch holds the answer back, and OpenSave's own restore is not taken for a game writing its save. - The window says what is happening: putting back from which snapshot, fetching the rest (with progress) or waiting for another device to be online; and when changes are held back while a game seems to be played. - A device asked for a game whose folder is not on it now (a drive not plugged in) says so, as a device with no folder for it yet does, instead of an error the asking device logged every few seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../frontend/src/views/game/GameHeader.svelte | 16 ++++++- e2e/emptied_folder_test.go | 42 +++++++++++++++++++ internal/api/server.go | 7 +++- internal/daemon/sessions.go | 42 +++++++++++++++++++ internal/daemon/sessions_test.go | 20 +++++++++ internal/p2p/routes.go | 4 ++ internal/p2p/syncengine/engine.go | 13 +++++- internal/p2p/syncengine/hold.go | 34 +++++++++++++++ internal/p2p/syncengine/version.go | 6 +++ internal/p2p/wanclient_handlers.go | 3 ++ 10 files changed, 182 insertions(+), 5 deletions(-) diff --git a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte index 327f2a34..89f4a7d5 100644 --- a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte +++ b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte @@ -10,6 +10,7 @@ import { timeAgo } from '../../lib/timeago.js'; import { playLength } from '../../lib/format.js'; import { canLaunch } from '../../lib/gameactions.js'; + import Spinner from '../../components/ui/Spinner.svelte'; import ArrowLeft from 'lucide-svelte/icons/arrow-left'; import Play from 'lucide-svelte/icons/play'; import RefreshCw from 'lucide-svelte/icons/refresh-cw'; @@ -136,6 +137,9 @@ {#if activity?.state === 'running'} · <span class="syncing">syncing {activity.percentage ?? 0}%</span> {/if} + {#if game.holdingChanges && !game.playingSince} + · <span class="playing" title="Its save keeps changing though the game is not seen running here, so it is handled as being played: nothing syncs until the save has been quiet for a while (10 minutes, longer for a game that saves less often).">changes held back while it seems to be played — synced once its save is quiet</span> + {/if} {#if game.playingSince} · <span class="playing">in session since {new Date(game.playingSince).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span> {:else if game.lastPlayedAt} @@ -193,9 +197,17 @@ <div class="missing emptied" role="status"> <TriangleAlert size={16} /> <div> - {#if game.emptied.state === 'fetching'} + {#if answering} + <strong><Spinner size={13} /> Putting the files back{game.emptied.putBackFrom ? ` from the snapshot of ${whenLabel(game.emptied.putBackFromTime)}` : ''}…</strong> + A large save takes a few minutes. Anything newer comes from your other devices afterwards. + {:else if game.emptied.state === 'fetching'} <strong>Putting the files back.</strong> - Some are still to come from your other devices; this game syncs as usual once they're all here. + {#if activity?.state === 'running'} + Fetching the rest from your other devices — {activity.percentage ?? 0}%. + {:else} + Some are still to come from your other devices: waiting for one of them to send them — it has to be + online. This game syncs as usual once they're all here. + {/if} {:else} <strong>Every save file in {emptiedWhere(game.emptied.locations)} was deleted on this device.</strong> Your other devices still have theirs, and nothing is synced until you choose. diff --git a/e2e/emptied_folder_test.go b/e2e/emptied_folder_test.go index 85ecf78d..174a1217 100644 --- a/e2e/emptied_folder_test.go +++ b/e2e/emptied_folder_test.go @@ -4,6 +4,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "testing" "time" @@ -248,3 +249,44 @@ func TestEmptiedFolder_AnUnconfirmedEmptyFolderIsNotTakenAsDeletions(t *testing. } } } + +// A save put back is the other device's: every file it holds differently, +// even one whose copy here looks newer by its date, comes from there, and +// this device ends on the other's version rather than one of its own that +// every other device would then be asked to take. +func TestEmptiedFolder_PutBackTakesTheOtherDevicesSaveAndVersion(t *testing.T) { + a, b, gameID := emptiedAndHeld(t, "Emptied Source Of Truth Game") + + // The other device moves on meanwhile: one file changed, and written + // with a date older than the copy in the snapshot here. + path := filepath.Join(b.SaveDir, "slot2.sav") + if err := os.WriteFile(path, []byte("played on since"), 0o666); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + syncBothWays(t, gameID, b, a) + + before := len(b.Daemon.Log.History()) + a.API(http.MethodPost, "/api/games/"+gameID+"/emptied", map[string]string{"answer": "restore"}, nil) + syncBothWays(t, gameID, a, b) + + if got := a.ReadSave("slot2.sav"); got != "played on since" { + t.Errorf("put back, slot2.sav is %q: the copy here won over the other device's", got) + } + if got := b.ReadSave("slot2.sav"); got != "played on since" { + t.Errorf("the other device's slot2.sav became %q", got) + } + va, _ := a.Daemon.Store.GetGameVersion(gameID) + vb, _ := b.Daemon.Store.GetGameVersion(gameID) + if va.Vector != vb.Vector { + t.Errorf("versions differ after the put-back: here %s, there %s", va.Vector, vb.Vector) + } + for _, e := range b.Daemon.Log.History()[before:] { + if strings.Contains(e.Message, "newer version") && strings.Contains(e.Message, "taking") { + t.Errorf("the other device took a version from the one put back: %s", e.Message) + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index dc7ad5e6..aa91d557 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -585,8 +585,11 @@ func (s *Server) gamePayload(g store.Game) map[string]any { "saveDriveMissing": daemon.SaveFolderMissing(g.SavePath) && daemon.SaveDriveMissing(g.SavePath), "lastPlayedAt": lastPlayed, "playingSince": playingSince, - "playtimeMs": play.PlaytimeMs, - "playSessions": play.Sessions, + // Changes to it are held back as play though no session of it is seen + // (daemon/sessions.go): said, so nothing seems stuck. + "holdingChanges": s.Daemon.HoldingChanges(g.ID), + "playtimeMs": play.PlaytimeMs, + "playSessions": play.Sessions, // Whether the game is installed on this device: "found", "not-found", // or "" when there is no telling (daemon.InstallState). A game that is // not here is one nobody plays here, which is why it shows no play. diff --git a/internal/daemon/sessions.go b/internal/daemon/sessions.go index 638821ca..545ff57a 100644 --- a/internal/daemon/sessions.go +++ b/internal/daemon/sessions.go @@ -10,6 +10,7 @@ import ( "time" "unicode" + "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/sessions" "github.com/opensave/opensave/internal/store" ) @@ -508,6 +509,12 @@ const checkpointEvery = 30 * time.Minute // since the last checkpoint. The end of the session, or of the stretch of // changes, keeps the save as it was left. func (d *Daemon) holdSnapshotWhilePlaying(gameID string) bool { + // An emptied save, or one held back after being emptied (hold.go), is not + // play: its own rules ask, and fetch it back when told to. + if d.emptiedOrHeld(gameID) { + d.endActivity(gameID) + return false + } // A game known to be closed: kept and synced at once. if d.notBeingPlayed(gameID) { return false @@ -876,6 +883,10 @@ func (d *Daemon) rememberInstallFolders(procs []sessions.Proc, targets []session // out the watcher's debounce. A sync landing in that gap would otherwise // send the change on the moment before it is held. func (d *Daemon) playingHere(gameID string) bool { + if d.PlayingSince(gameID).IsZero() && d.emptiedOrHeld(gameID) { + d.endActivity(gameID) + return false + } if !d.PlayingSince(gameID).IsZero() || d.changingNow(gameID) { return true } @@ -964,3 +975,34 @@ func (d *Daemon) dropCheckpoints(game store.Game, since time.Time) { _, _ = d.Snapshots.DeleteSnapshot(game.ID, s.ID) } } + +// emptiedOrHeld reports a game whose save folder holds no save at its top — +// emptied — or that is held back after being emptied (a deletion hold, in any +// state). Neither is play: a stretch over it held the save back from being +// asked about, and a save being fetched back from the other devices from +// being fetched. +func (d *Daemon) emptiedOrHeld(gameID string) bool { + if _, ok, err := d.Store.GetDeletionHold(gameID); err == nil && ok { + return true + } + game, err := d.Store.GetGame(gameID) + if err != nil || game.SavePath == "" { + return false + } + entries, err := os.ReadDir(game.SavePath) + if err != nil { + return false // a single-file save, or not there: not this case + } + for _, e := range entries { + if !delta.NeverSynced(e.Name()) { + return false + } + } + return true +} + +// HoldingChanges reports a game whose changes are held back as play while no +// session of it is seen (noteActivity), for the window to say so. +func (d *Daemon) HoldingChanges(gameID string) bool { + return d.PlayingSince(gameID).IsZero() && d.changingNow(gameID) +} diff --git a/internal/daemon/sessions_test.go b/internal/daemon/sessions_test.go index 138aaf1b..cd91fc60 100644 --- a/internal/daemon/sessions_test.go +++ b/internal/daemon/sessions_test.go @@ -382,3 +382,23 @@ func TestStretchWaitAdaptsToAutosaves(t *testing.T) { } } } + +// An emptied save is not play: no stretch holds it back from being asked +// about and fetched back. +func TestEmptiedSaveIsNotPlay(t *testing.T) { + d := newTestDaemon(t) + g, dir := sessionGame(t, d, "emptied") + d.holdSnapshotWhilePlaying(g.ID) + if !d.changingNow(g.ID) { + t.Fatal("setup: no stretch") + } + if err := os.Remove(filepath.Join(dir, "slot1.sav")); err != nil { + t.Fatal(err) + } + if d.holdSnapshotWhilePlaying(g.ID) { + t.Error("the change that emptied the save was held as play") + } + if d.changingNow(g.ID) || d.P2P.Sync.PlayingHere(g.ID) { + t.Error("an emptied save is still handled as being played") + } +} diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index 233eda52..68fd542e 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -740,6 +740,10 @@ func (e *Engine) handleManifest(w http.ResponseWriter, r *http.Request) { } manifest, failures, err := delta.BuildMultiManifest(game.SavePath, extra) if err != nil { + if _, statErr := os.Stat(game.SavePath); statErr != nil { + jsonError(w, http.StatusNotFound, syncengine.FolderMissingMessage) + return + } jsonError(w, http.StatusInternalServerError, "manifest build failed: "+err.Error()) return } diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 05bd7458..2fcfc9b3 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -240,6 +240,13 @@ func isGameNotFound(err error) bool { // into one. const AwaitingFolderMessage = "This device has not been given a folder for this game yet" +// FolderMissingMessage is what a device says when the folder it tracks a game +// at is not there now — on a drive not plugged in, or moved. Nothing to +// compare with, and nothing wrong to report over and over: it contains +// AwaitingFolderMessage, so the asking device waits for it as it does for +// a folder not yet chosen. +const FolderMissingMessage = AwaitingFolderMessage + " here: the folder it was given is not on this device now" + // isAwaitingFolder reports whether the peer is holding this game until someone // chooses where it lives there — as opposed to not tracking it at all. func isAwaitingFolder(err error) bool { @@ -749,7 +756,8 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // A side that is merely behind the other has not diverged from it, whatever // the clocks and the base say (OnlyBehind). - if DetectConflict(judged, remoteData.Manifest, lastSyncMs, agreedHash) && + // Not while being put back: the other device's save is what is wanted. + if !fetchingBack && DetectConflict(judged, remoteData.Manifest, lastSyncMs, agreedHash) && !OnlyBehind(judged, remoteData.Manifest, lineageFiles) { e.registerConflict(gameID, peer, localManifest, remoteData) return Result{Status: "conflict", PeerID: peer.ID, PeerName: peer.Name}, nil @@ -775,6 +783,9 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re } } decision := ComputeWithDeletions(localManifest, remoteData.Manifest, lineageFiles, lineageDirs, agreedHash, deleted) + if fetchingBack { + decision = putBackDecision(decision, remoteData.Manifest) + } if !decision.HasChanges() { e.persistLineage(gameID, peer.ID, localManifest, remoteData.Manifest) diff --git a/internal/p2p/syncengine/hold.go b/internal/p2p/syncengine/hold.go index b444156d..a227ba4a 100644 --- a/internal/p2p/syncengine/hold.go +++ b/internal/p2p/syncengine/hold.go @@ -535,3 +535,37 @@ func describeLocations(names []string) string { } return strings.Join(parts, " and ") } + +// emptyingUnconfirmed reports a save that holds nothing, or is held back +// after being emptied, while nobody has said the emptying was meant. That is +// not a version of the save: named one, it made this device the newest, so a +// save put back from a snapshot and the other devices came out as this +// device's own new version, which every other device was then asked to take. +// Only "delete them there too" (ConfirmHold) makes it one. +func (e *Engine) emptyingUnconfirmed(gameID string, local delta.Manifest) bool { + hold, has, err := e.Store.GetDeletionHold(gameID) + if err == nil && has { + return hold.State != store.HoldConfirmed + } + for p := range local.Files { + if !delta.NeverSynced(p) { + return false + } + } + return len(local.Extra) == 0 +} + +// putBackDecision turns a decision taken while a save is being put back +// (HoldFetching) into fetching only: every file both devices hold but differ +// on is the other device's, nothing is offered from here, and nothing is +// deleted over there. A file only this device holds is left where it is. +func putBackDecision(d Decision, remote delta.Manifest) Decision { + for _, p := range d.FilesToPush { + if _, ok := remote.Files[p]; ok { + d.FilesToPull = append(d.FilesToPull, p) + } + } + d.FilesToPush, d.DirsToPush = nil, nil + d.FilesToDeleteOnPeer, d.DirsToDeleteOnPeer = nil, nil + return d +} diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index e7f8e96d..3655fac1 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -388,6 +388,9 @@ func (e *Engine) refreshLocked(gv *gameVersion, game store.Game, local delta.Man if gv.rec.Pulling || len(gv.vec) == 0 || gv.rec.Hash == "" || gv.rec.Hash == hash { return false } + if e.emptyingUnconfirmed(game.ID, local) { + return false + } if sameSaveAs(gv.rec.Hash, hash) { // Taken under other exclusion rules, so it cannot say whether the // save changed: re-taken under the new ones. A change made at the @@ -503,6 +506,9 @@ func (e *Engine) NoteLocalChange(gameID string) { } return } + if e.emptyingUnconfirmed(gameID, m) { + return // not a version until someone says it was meant (hold.go) + } if hash == gv.rec.Hash { // Already a version: a sync noticed it first (refreshLocked), or the // change was only to files nobody syncs. diff --git a/internal/p2p/wanclient_handlers.go b/internal/p2p/wanclient_handlers.go index 39668b2d..766dbcdb 100644 --- a/internal/p2p/wanclient_handlers.go +++ b/internal/p2p/wanclient_handlers.go @@ -618,6 +618,9 @@ func (w *WanClient) serveManifest(route string, body json.RawMessage, peerID str manifest, err := delta.BuildManifest(game.SavePath) if err != nil { + if _, statErr := os.Stat(game.SavePath); statErr != nil { + return 404, map[string]string{"error": syncengine.FolderMissingMessage} + } return 500, map[string]string{"error": err.Error()} } w.engine.Sync.NoteServed(game.ID, peerID, manifest) // syncengine/served.go From 6b932716a43a6f3b1843b3f7f0d46a83b579d7a2 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:25:10 +0200 Subject: [PATCH 48/75] Give the database's unused space back to the disk opensave.db had grown to 663 MB on one device, 262 MB of it pages that deleted rows had left: SQLite never shrinks the file on its own, and snapshots - each with a row per file of its save, a quarter of a million for one game - are merged, pruned and replaced all the time. The database is switched to incremental auto-vacuum, which takes one full VACUUM when it is opened (8 seconds for that database, 663 to 380 MB). After that its free pages are handed back a few at a time - when it is opened and hourly - so deleting a snapshot makes the file smaller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/daemon/daemon.go | 15 ++++++++++++ internal/store/compact.go | 43 +++++++++++++++++++++++++++++++++ internal/store/compact_test.go | 44 ++++++++++++++++++++++++++++++++++ internal/store/db.go | 1 + 4 files changed, 103 insertions(+) create mode 100644 internal/store/compact.go create mode 100644 internal/store/compact_test.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 150d2aa2..b2be99f4 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -418,6 +418,21 @@ func (d *Daemon) Start() error { } }) + // The database file gives back the space of what was deleted from it + // (store/compact.go), hourly. + d.P2P.GoSync(func(ctx context.Context) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + d.Store.Compact() + } + } + }) + // Find each game's settings by what they hold (detectsettings.go): a few // minutes after start, then hourly for whatever is due. d.P2P.GoSync(func(ctx context.Context) { diff --git a/internal/store/compact.go b/internal/store/compact.go new file mode 100644 index 00000000..069bb895 --- /dev/null +++ b/internal/store/compact.go @@ -0,0 +1,43 @@ +package store + +// Giving the space of deleted rows back to the disk. +// +// SQLite keeps the pages rows were deleted from, for rows to come; the file +// never shrinks on its own. Snapshots are merged, pruned and replaced all the +// time, and each held a row per file of its save — a quarter of a million +// for one game — so the file grew by what every snapshot ever deleted had +// held: 663 MB on one device, 262 MB of it unused. +// +// With auto_vacuum set to INCREMENTAL the free pages can be handed back a +// few at a time (Compact), never holding the database for long. Setting it +// takes one full VACUUM, done once, when the database is opened and nothing +// else is using it: some seconds for a large one. + +// freePagesWorthCompacting is how many free pages are given back at a time, +// and fewer are not worth the bother: 4 MB at the default page size. +const freePagesWorthCompacting = 1024 + +func (s *Store) compactOnOpen() { + var mode int + if err := s.db.Get(&mode, `PRAGMA auto_vacuum`); err != nil { + return + } + if mode != 2 { // 2 = INCREMENTAL + if _, err := s.db.Exec(`PRAGMA auto_vacuum = INCREMENTAL`); err != nil { + return + } + _, _ = s.db.Exec(`VACUUM`) + return + } + s.Compact() +} + +// Compact hands the database file's unused pages back to the disk, when +// there are enough of them to matter. +func (s *Store) Compact() { + var free int + if err := s.db.Get(&free, `PRAGMA freelist_count`); err != nil || free < freePagesWorthCompacting { + return + } + _, _ = s.db.Exec(`PRAGMA incremental_vacuum`) +} diff --git a/internal/store/compact_test.go b/internal/store/compact_test.go new file mode 100644 index 00000000..9d020e5a --- /dev/null +++ b/internal/store/compact_test.go @@ -0,0 +1,44 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// Deleted rows give their space back to the disk: the file shrinks, rather +// than holding everything it ever held. +func TestCompactGivesSpaceBack(t *testing.T) { + path := filepath.Join(t.TempDir(), "opensave.db") + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + var mode int + _ = s.db.Get(&mode, `PRAGMA auto_vacuum`) + if mode != 2 { + t.Fatalf("auto_vacuum = %d, want incremental (2)", mode) + } + pad := strings.Repeat("x", 2000) + tx := s.db.MustBegin() + for i := 0; i < 4000; i++ { + tx.MustExec(`INSERT INTO marks (name, value) VALUES (?, ?)`, fmt.Sprint("k", i), pad) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + _, _ = s.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`) + full, _ := os.Stat(path) + if _, err := s.db.Exec(`DELETE FROM marks`); err != nil { + t.Fatal(err) + } + s.Compact() + _, _ = s.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`) + after, _ := os.Stat(path) + if after.Size() >= full.Size()/2 { + t.Errorf("file %d bytes after deleting everything, %d before: nothing given back", after.Size(), full.Size()) + } + s.Close() +} diff --git a/internal/store/db.go b/internal/store/db.go index 9e69aeac..c0da20ae 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -51,6 +51,7 @@ func Open(path string) (*Store, error) { db.Close() return nil, err } + s.compactOnOpen() // The file holds the device's private key, the Google tokens and the // vault keys. SQLite creates it with the process umask, which is usually // world-readable; the directory around it is private now, but the file From 1dc38f4073997353bf7571c6ac3a7b068c49e436 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:43:29 +0200 Subject: [PATCH 49/75] Write the database through a WAL; clear deletion records in one go Putting Project Zomboid back after its folder was emptied spent most of an hour in the database, the save untouched meanwhile: every file's deletion record was cleared with a statement of its own, a quarter of a million of them, and with SQLite's default rollback journal every one of them created, flushed and deleted a journal file. - Deletion records are cleared in one transaction per save location, on putting a save back and when files come back in a snapshot (ClearDeletedFiles): 50,000 take a third of a second. - The database runs in WAL mode with synchronous NORMAL: writes are appended to one log instead of each making a journal file of its own. The database stays whole whatever happens; a power cut can cost only the last moments' writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/syncengine/hold.go | 2 +- internal/snapshot/snapshot.go | 4 ++- internal/store/compact_test.go | 51 +++++++++++++++++++++++++++++++++ internal/store/db.go | 9 +++++- internal/store/deleted_files.go | 25 ++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/internal/p2p/syncengine/hold.go b/internal/p2p/syncengine/hold.go index a227ba4a..fb069238 100644 --- a/internal/p2p/syncengine/hold.go +++ b/internal/p2p/syncengine/hold.go @@ -387,8 +387,8 @@ func (e *Engine) PutBack(gameID string) (fetching int, err error) { } for root, files := range hold.Files() { var missing []string + _ = e.Store.ClearDeletedFiles(gameID, root, files) for _, f := range files { - _ = e.Store.ClearDeletedFile(gameID, root, f) if _, ok := x.present[root][f]; !ok { missing = append(missing, f) } diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 6f1ed40d..58c5cd83 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -1248,14 +1248,16 @@ func (m *Manager) recordDeletionsSince(gameID, branch, snapshotID string, captur if err != nil || len(records) == 0 { continue } + var back []string for _, f := range captured { if f.Root != root { continue } if _, remembered := records[f.Path]; remembered { - _ = m.Store.ClearDeletedFile(gameID, root, f.Path) + back = append(back, f.Path) } } + _ = m.Store.ClearDeletedFiles(gameID, root, back) } } diff --git a/internal/store/compact_test.go b/internal/store/compact_test.go index 9d020e5a..05c1c26d 100644 --- a/internal/store/compact_test.go +++ b/internal/store/compact_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) // Deleted rows give their space back to the disk: the file shrinks, rather @@ -42,3 +43,53 @@ func TestCompactGivesSpaceBack(t *testing.T) { } s.Close() } + +// Clearing a large save's deletion records is one transaction, not one write +// to disk per file. +func TestClearDeletedFilesIsOneTransaction(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "opensave.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.CreateGame(Game{ID: "g", Name: "g", SavePath: t.TempDir(), ActiveBranch: "main"}); err != nil { + t.Fatal(err) + } + var files []DeletedFile + var paths []string + for i := 0; i < 50000; i++ { + p := fmt.Sprintf("map/%d/%d.bin", i/100, i) + files = append(files, DeletedFile{Path: p, Hash: "h"}) + paths = append(paths, p) + } + if err := s.RecordDeletedFiles("g", files); err != nil { + t.Fatal(err) + } + start := time.Now() + if err := s.ClearDeletedFiles("g", "", paths); err != nil { + t.Fatal(err) + } + took := time.Since(start) + left, _ := s.DeletedFiles("g", "") + if len(left) != 0 { + t.Errorf("%d records left", len(left)) + } + if took > 10*time.Second { + t.Errorf("clearing 50,000 records took %v", took) + } + t.Logf("cleared 50,000 deletion records in %v", took) +} + +// The database writes through a write-ahead log, not a rollback journal +// created and deleted for every write. +func TestDatabaseUsesWAL(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "opensave.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + var mode string + if err := s.db.Get(&mode, `PRAGMA journal_mode`); err != nil || mode != "wal" { + t.Errorf("journal_mode = %q (%v), want wal", mode, err) + } +} diff --git a/internal/store/db.go b/internal/store/db.go index c0da20ae..44e17004 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -32,7 +32,14 @@ func Open(path string) (*Store, error) { // _pragma params ensure foreign keys are enforced (SQLite defaults them // off per-connection) and busy_timeout avoids spurious SQLITE_BUSY // errors from the watcher/api/p2p goroutines all touching the DB. - dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)", path) + // + // WAL: a write is appended to one log and the database file is updated + // from it later, rather than every write creating, flushing and deleting a + // rollback journal of its own — which made a quarter of a million small + // writes take most of an hour. synchronous NORMAL is what WAL is meant to + // run with: the database stays whole whatever happens; a power cut can + // only lose the last moments' writes, never corrupt it. + dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)", path) db, err := sqlx.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite database: %w", err) diff --git a/internal/store/deleted_files.go b/internal/store/deleted_files.go index 1cda3429..d3b11246 100644 --- a/internal/store/deleted_files.go +++ b/internal/store/deleted_files.go @@ -107,6 +107,31 @@ func (s *Store) ClearDeletedFile(gameID, root, path string) error { return nil } +// ClearDeletedFiles forgets the deletions recorded for paths of one save +// location, in one transaction: one at a time, each its own write to disk, +// the quarter of a million of a large save took a quarter of an hour. +func (s *Store) ClearDeletedFiles(gameID, root string, paths []string) error { + if len(paths) == 0 { + return nil + } + tx, err := s.db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + stmt, err := tx.Preparex(`DELETE FROM deleted_files WHERE game_id = ? AND root = ? AND path = ?`) + if err != nil { + return fmt.Errorf("prepare clear deletion records: %w", err) + } + defer stmt.Close() + for _, p := range paths { + if _, err := stmt.Exec(gameID, root, p); err != nil { + return fmt.Errorf("clear deletion record for %s: %w", p, err) + } + } + return tx.Commit() +} + // ClearDeletedFilesForGame forgets every deletion recorded for a game, for use // when it is untracked — the records describe a folder this device no longer // has an opinion about. From 07baaa9447f4267c8983f804102fbf8e92e9c29c Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:44:34 +0200 Subject: [PATCH 50/75] Restore as many files at a time as the drive does best, with progress Putting a large save back - a quarter of a million files - wrote them one after another, looking up each one's folder again, while the drive sat at 90% active time and 15 MB/s, and the window showed a spinner for many minutes with nothing to say what it was doing. - internal/iopar runs many small file operations as many at a time as suits the drive they are on: one at a time on a hard disk, starting at 8 on a SATA SSD and 32 on NVMe (Windows: the volume's seek penalty and bus type; Linux: rotational and nvme). On an SSD the number then moves every 750 ms the way that finishes more - on while it helps, back when it hurts, and now and then the other way once settled, so a drive that slows as it goes is followed. - A restore makes every folder once, then writes the files through it; checking the archive reads its entries through it too. On a 4-thread machine restoring Project Zomboid (238,410 files) the processor, not the SSD, is the limit: writing went from 2m29s to 1m42s, settling at 3-8 at once; machines with more cores go further by themselves. - A restore says how far it is - rebuilding a compacted snapshot, checking it, writing the files - with percent and, once it can be told, the time left, in the game's header and while a save is being put back. - An emptied save, or one part-way through being put back, is not snapshotted: the empty folder and a restore stopped half-way were kept as snapshots, the half one as "Latest". It is asked about at once all the same, and kept as "Put back" once every file is back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../frontend/src/lib/restoreprogress.js | 23 ++ .../frontend/src/lib/restoreprogress.test.js | 17 ++ .../frontend/src/views/game/GameHeader.svelte | 7 +- e2e/emptied_folder_test.go | 41 +++ internal/api/server.go | 6 +- internal/daemon/daemon.go | 8 + internal/daemon/emptied.go | 19 ++ internal/daemon/restoreprogress.go | 79 ++++++ internal/daemon/sessions.go | 9 +- internal/daemon/sessions_test.go | 10 +- internal/iopar/drive.go | 52 ++++ internal/iopar/drive_other.go | 56 +++++ internal/iopar/drive_windows.go | 94 +++++++ internal/iopar/run.go | 234 ++++++++++++++++++ internal/iopar/run_test.go | 109 ++++++++ internal/p2p/syncengine/engine.go | 4 + internal/p2p/syncengine/hold.go | 8 + internal/snapshot/extract.go | 137 ++++++++++ internal/snapshot/shared.go | 20 +- internal/snapshot/snapshot.go | 16 +- internal/snapshot/verify.go | 31 ++- internal/snapshot/zip.go | 12 +- internal/snapshot/zip_roots.go | 15 +- 23 files changed, 977 insertions(+), 30 deletions(-) create mode 100644 cmd/opensave-app/frontend/src/lib/restoreprogress.js create mode 100644 cmd/opensave-app/frontend/src/lib/restoreprogress.test.js create mode 100644 internal/daemon/restoreprogress.go create mode 100644 internal/iopar/drive.go create mode 100644 internal/iopar/drive_other.go create mode 100644 internal/iopar/drive_windows.go create mode 100644 internal/iopar/run.go create mode 100644 internal/iopar/run_test.go create mode 100644 internal/snapshot/extract.go diff --git a/cmd/opensave-app/frontend/src/lib/restoreprogress.js b/cmd/opensave-app/frontend/src/lib/restoreprogress.js new file mode 100644 index 00000000..51822570 --- /dev/null +++ b/cmd/opensave-app/frontend/src/lib/restoreprogress.js @@ -0,0 +1,23 @@ +// How far a restore has come, in words: the phase, percent and, once it can +// be told, roughly how long is left (daemon/restoreprogress.go). + +const phases = { + rebuilding: 'Rebuilding the snapshot', + checking: 'Checking the snapshot', + writing: 'Writing the files' +}; + +export function restoreProgressText(p) { + if (!p || !p.phase) return ''; + const what = phases[p.phase] ?? 'Restoring'; + const pct = p.total > 0 ? Math.floor((p.done / p.total) * 100) : 0; + let text = `${what} — ${pct}% (${p.done.toLocaleString()} of ${p.total.toLocaleString()})`; + if (p.secondsLeft > 0) text += `, ${timeLeft(p.secondsLeft)} left`; + return text; +} + +function timeLeft(s) { + if (s < 60) return 'under a minute'; + const m = Math.round(s / 60); + return m === 1 ? 'about a minute' : `about ${m} minutes`; +} diff --git a/cmd/opensave-app/frontend/src/lib/restoreprogress.test.js b/cmd/opensave-app/frontend/src/lib/restoreprogress.test.js new file mode 100644 index 00000000..594320e1 --- /dev/null +++ b/cmd/opensave-app/frontend/src/lib/restoreprogress.test.js @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { restoreProgressText } from './restoreprogress.js'; + +describe('restoreProgressText', () => { + it('says nothing when no restore is under way', () => { + expect(restoreProgressText(null)).toBe(''); + }); + it('says the phase, percent and time left', () => { + const t = restoreProgressText({ phase: 'writing', done: 60000, total: 240000, secondsLeft: 150 }); + expect(t).toContain('Writing the files'); + expect(t).toContain('25%'); + expect(t).toContain('about 3 minutes left'); + }); + it('leaves out the time while it cannot be told', () => { + expect(restoreProgressText({ phase: 'checking', done: 10, total: 100 })).not.toContain('left'); + }); +}); diff --git a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte index 89f4a7d5..8718ad38 100644 --- a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte +++ b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte @@ -11,6 +11,7 @@ import { playLength } from '../../lib/format.js'; import { canLaunch } from '../../lib/gameactions.js'; import Spinner from '../../components/ui/Spinner.svelte'; + import { restoreProgressText } from '../../lib/restoreprogress.js'; import ArrowLeft from 'lucide-svelte/icons/arrow-left'; import Play from 'lucide-svelte/icons/play'; import RefreshCw from 'lucide-svelte/icons/refresh-cw'; @@ -137,6 +138,9 @@ {#if activity?.state === 'running'} · <span class="syncing">syncing {activity.percentage ?? 0}%</span> {/if} + {#if game.restoring && !answering} + · <span class="syncing"><Spinner size={11} /> {restoreProgressText(game.restoring)}</span> + {/if} {#if game.holdingChanges && !game.playingSince} · <span class="playing" title="Its save keeps changing though the game is not seen running here, so it is handled as being played: nothing syncs until the save has been quiet for a while (10 minutes, longer for a game that saves less often).">changes held back while it seems to be played — synced once its save is quiet</span> {/if} @@ -199,7 +203,8 @@ <div> {#if answering} <strong><Spinner size={13} /> Putting the files back{game.emptied.putBackFrom ? ` from the snapshot of ${whenLabel(game.emptied.putBackFromTime)}` : ''}…</strong> - A large save takes a few minutes. Anything newer comes from your other devices afterwards. + {#if game.restoring}{restoreProgressText(game.restoring)}.{:else}A large save takes a few minutes.{/if} + Anything newer comes from your other devices afterwards. {:else if game.emptied.state === 'fetching'} <strong>Putting the files back.</strong> {#if activity?.state === 'running'} diff --git a/e2e/emptied_folder_test.go b/e2e/emptied_folder_test.go index 174a1217..456d1daf 100644 --- a/e2e/emptied_folder_test.go +++ b/e2e/emptied_folder_test.go @@ -290,3 +290,44 @@ func TestEmptiedFolder_PutBackTakesTheOtherDevicesSaveAndVersion(t *testing.T) { } } } + +// Emptying a save and putting it back leaves no snapshot of the empty folder +// or of the save part-way back; the save whole again is kept as "Put back". +func TestEmptiedFolder_NoSnapshotOfTheEmptyOrHalfSave(t *testing.T) { + a, b, gameID := emptiedAndHeld(t, "Emptied Snapshots Game") + a.API(http.MethodPost, "/api/games/"+gameID+"/emptied", map[string]string{"answer": "restore"}, nil) + syncBothWays(t, gameID, a, b) + + var games map[string]struct { + Branches map[string]struct { + Snapshots []struct { + Comment string `json:"comment"` + SizeBytes int64 `json:"sizeBytes"` + } `json:"snapshots"` + } `json:"branches"` + } + var comments []string + if !testutil.WaitFor(15*time.Second, func() bool { + a.API(http.MethodGet, "/api/games", nil, &games) + comments = comments[:0] + putBack := false + for _, br := range games[gameID].Branches { + for _, s := range br.Snapshots { + comments = append(comments, s.Comment) + if s.Comment == "Put back" { + putBack = true + } + } + } + return putBack + }) { + t.Errorf("no snapshot of the save put back; snapshots: %q", comments) + } + for _, br := range games[gameID].Branches { + for _, s := range br.Snapshots { + if s.SizeBytes < 100 { + t.Errorf("a snapshot of the emptied folder was kept: %q, %d bytes", s.Comment, s.SizeBytes) + } + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index aa91d557..f9bb82bb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -588,8 +588,10 @@ func (s *Server) gamePayload(g store.Game) map[string]any { // Changes to it are held back as play though no session of it is seen // (daemon/sessions.go): said, so nothing seems stuck. "holdingChanges": s.Daemon.HoldingChanges(g.ID), - "playtimeMs": play.PlaytimeMs, - "playSessions": play.Sessions, + // A restore of it under way, and how far it has come. + "restoring": s.Daemon.RestoringNow(g.ID), + "playtimeMs": play.PlaytimeMs, + "playSessions": play.Sessions, // Whether the game is installed on this device: "found", "not-found", // or "" when there is no telling (daemon.InstallState). A game that is // not here is one nobody plays here, which is why it shows no play. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index b2be99f4..e3f41d4a 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -74,6 +74,9 @@ type Daemon struct { // Play sessions; see sessions.go. sessions sessionState + // Restores under way; see restoreprogress.go. + restoring restoreState + // Device settings; see devicesettings.go. devMu sync.Mutex dev devSettingsCache @@ -177,12 +180,17 @@ func New(opts Options) (*Daemon, error) { d.initSessions() + // How far a restore has come, for the window (restoreprogress.go). + snaps.OnRestoreProgress = d.noteRestoreProgress + // A game's device settings are left out of syncing (devicesettings.go). d.P2P.Sync.DeviceSettingsFor = d.deviceSettingsFor d.P2P.Sync.ObserveSettings = d.observeSettings // A game being played here is synced when its session ends // (syncengine/playing.go). d.P2P.Sync.Playing = d.playingHere + // An emptied save whole again is kept (emptied.go). + d.P2P.Sync.OnPutBack = d.snapshotPutBack // A restore or a branch switch rewrites a save folder; nothing syncing it // may read it half-way (syncengine/settle.go). diff --git a/internal/daemon/emptied.go b/internal/daemon/emptied.go index 3ca01b64..6fc17bc9 100644 --- a/internal/daemon/emptied.go +++ b/internal/daemon/emptied.go @@ -194,3 +194,22 @@ func (d *Daemon) AnswerEmptied(gameID, answer string) (EmptiedAnswer, error) { }) return out, nil } + +// snapshotPutBack keeps a save once every file of it is back after it was +// emptied: nothing was snapshotted while it was empty or part-way back. +func (d *Daemon) snapshotPutBack(gameID string) { + game, err := d.Store.GetGame(gameID) + if err != nil { + return + } + if _, err := d.Snapshots.Create(gameID, "Put back", true); err != nil { + d.Log.Log("warn", fmt.Sprintf("could not keep %q as it was put back: %v", game.Name, err)) + return + } + if hash, err := d.currentContentHash(game); err == nil { + _ = d.Store.SetLastManifestHash(gameID, hash) + } + if d.OnGameChanged != nil { + d.OnGameChanged(gameID) + } +} diff --git a/internal/daemon/restoreprogress.go b/internal/daemon/restoreprogress.go new file mode 100644 index 00000000..3801df5d --- /dev/null +++ b/internal/daemon/restoreprogress.go @@ -0,0 +1,79 @@ +package daemon + +import ( + "sync" + "time" +) + +// How far a restore has come, for the window: a large save is put back in +// minutes, and a spinner alone left it unclear whether anything was +// happening at all. + +// RestoreProgress is a restore under way. +type RestoreProgress struct { + Phase string `json:"phase"` // snapshot.PhaseRebuilding, PhaseChecking, PhaseWriting + Done int `json:"done"` + Total int `json:"total"` + // SecondsLeft estimates how long the phase still takes; 0 until there + // is enough to go on. + SecondsLeft int `json:"secondsLeft,omitempty"` +} + +type restoreState struct { + mu sync.Mutex + games map[string]*restoreEntry +} + +type restoreEntry struct { + RestoreProgress + phaseAt time.Time // when the phase began + sentAt time.Time // when the window was last told +} + +// noteRestoreProgress records a restore's progress (snapshot.Manager's +// OnRestoreProgress), telling the window about once a second. +func (d *Daemon) noteRestoreProgress(gameID, phase string, done, total int) { + now := time.Now() + d.restoring.mu.Lock() + if d.restoring.games == nil { + d.restoring.games = map[string]*restoreEntry{} + } + tell := false + if phase == "" { + delete(d.restoring.games, gameID) + tell = true + } else { + e := d.restoring.games[gameID] + if e == nil || e.Phase != phase { + e = &restoreEntry{phaseAt: now} + d.restoring.games[gameID] = e + tell = true + } + e.Phase, e.Done, e.Total = phase, done, total + e.SecondsLeft = 0 + if elapsed := now.Sub(e.phaseAt); done > 0 && total > done && elapsed > 2*time.Second { + e.SecondsLeft = int(elapsed.Seconds() / float64(done) * float64(total-done)) + } + if now.Sub(e.sentAt) >= time.Second { + tell = true + } + if tell { + e.sentAt = now + } + } + d.restoring.mu.Unlock() + if tell && d.OnGameChanged != nil { + d.OnGameChanged(gameID) + } +} + +// RestoringNow is the progress of a restore of gameID under way, or nil. +func (d *Daemon) RestoringNow(gameID string) *RestoreProgress { + d.restoring.mu.Lock() + defer d.restoring.mu.Unlock() + if e := d.restoring.games[gameID]; e != nil { + p := e.RestoreProgress + return &p + } + return nil +} diff --git a/internal/daemon/sessions.go b/internal/daemon/sessions.go index 545ff57a..5508af3b 100644 --- a/internal/daemon/sessions.go +++ b/internal/daemon/sessions.go @@ -511,9 +511,16 @@ const checkpointEvery = 30 * time.Minute func (d *Daemon) holdSnapshotWhilePlaying(gameID string) bool { // An emptied save, or one held back after being emptied (hold.go), is not // play: its own rules ask, and fetch it back when told to. + // + // Nor is it snapshotted: an emptied folder holds nothing worth keeping, + // and one part-way through being put back — a restore stopped half-way, + // files still coming from the other devices — is a save no device ever + // had. The save is kept once it is whole again (snapshotPutBack). Asked + // about at once all the same. if d.emptiedOrHeld(gameID) { d.endActivity(gameID) - return false + _, _ = d.P2P.Sync.CheckHold(gameID, false) + return true } // A game known to be closed: kept and synced at once. if d.notBeingPlayed(gameID) { diff --git a/internal/daemon/sessions_test.go b/internal/daemon/sessions_test.go index cd91fc60..dd2975dd 100644 --- a/internal/daemon/sessions_test.go +++ b/internal/daemon/sessions_test.go @@ -383,8 +383,8 @@ func TestStretchWaitAdaptsToAutosaves(t *testing.T) { } } -// An emptied save is not play: no stretch holds it back from being asked -// about and fetched back. +// An emptied save is not play — no stretch holds it back from being asked +// about and fetched back — and is not snapshotted either. func TestEmptiedSaveIsNotPlay(t *testing.T) { d := newTestDaemon(t) g, dir := sessionGame(t, d, "emptied") @@ -395,8 +395,10 @@ func TestEmptiedSaveIsNotPlay(t *testing.T) { if err := os.Remove(filepath.Join(dir, "slot1.sav")); err != nil { t.Fatal(err) } - if d.holdSnapshotWhilePlaying(g.ID) { - t.Error("the change that emptied the save was held as play") + // Not snapshotted (an empty folder is no state worth keeping), but not + // as play: no stretch begins over it. + if !d.holdSnapshotWhilePlaying(g.ID) { + t.Error("the emptied save was snapshotted") } if d.changingNow(g.ID) || d.P2P.Sync.PlayingHere(g.ID) { t.Error("an emptied save is still handled as being played") diff --git a/internal/iopar/drive.go b/internal/iopar/drive.go new file mode 100644 index 00000000..14d9a0f5 --- /dev/null +++ b/internal/iopar/drive.go @@ -0,0 +1,52 @@ +// Package iopar runs many small file operations — a save of a quarter of a +// million files restored, checked, written — as many at a time as the drive +// they are on does best: one at a time on a spinning disk, whose head would +// otherwise be sent back and forth across it; many at once on an SSD, which +// works through several requests in parallel and is otherwise left waiting +// on each file's own small delays (its creation, a virus scanner's look at +// it). How many is a starting point by kind of drive, adjusted as the work +// goes by what it actually achieves (Run). +package iopar + +// Kind is the kind of drive a path is on. +type Kind int + +const ( + // Unknown: it could not be told. Treated as a SATA SSD — parallel, but + // modestly so. + Unknown Kind = iota + HDD + SATA + NVMe +) + +func (k Kind) String() string { + switch k { + case HDD: + return "hard disk" + case SATA: + return "SATA SSD" + case NVMe: + return "NVMe SSD" + } + return "unknown drive" +} + +// start is how many operations run at once to begin with. +func (k Kind) start() int { + switch k { + case HDD: + return 1 + case NVMe: + return 32 + } + return 8 +} + +// adapts reports whether the number is adjusted as the work goes. A spinning +// disk is not: it does best one at a time, and finding that out would cost +// the seeking it exists to avoid. +func (k Kind) adapts() bool { return k != HDD } + +// DriveKind says what kind of drive path is on. +func DriveKind(path string) Kind { return driveKind(path) } diff --git a/internal/iopar/drive_other.go b/internal/iopar/drive_other.go new file mode 100644 index 00000000..77cf455b --- /dev/null +++ b/internal/iopar/drive_other.go @@ -0,0 +1,56 @@ +//go:build !windows + +package iopar + +import ( + "os" + "path/filepath" + "strings" + "syscall" +) + +// On Linux the kernel says whether a block device rotates; an NVMe drive is +// named so. Elsewhere it is not told. +func driveKind(path string) Kind { + var st syscall.Stat_t + if err := syscall.Stat(path, &st); err != nil { + return Unknown + } + major, minor := (st.Dev>>8)&0xfff, (st.Dev&0xff)|((st.Dev>>12)&0xfff00) + dev, err := os.Readlink("/sys/dev/block/" + itoa(uint64(major)) + ":" + itoa(uint64(minor))) + if err != nil { + return Unknown + } + // A partition: its disk is the folder above it. + disk := filepath.Base(dev) + sys := filepath.Join("/sys/class/block", disk) + if _, err := os.Stat(filepath.Join(sys, "partition")); err == nil { + disk = filepath.Base(filepath.Dir(dev)) + sys = filepath.Join("/sys/class/block", disk) + } + if strings.HasPrefix(disk, "nvme") { + return NVMe + } + b, err := os.ReadFile(filepath.Join(sys, "queue", "rotational")) + if err != nil { + return Unknown + } + if strings.TrimSpace(string(b)) == "1" { + return HDD + } + return SATA +} + +func itoa(n uint64) string { + if n == 0 { + return "0" + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} diff --git a/internal/iopar/drive_windows.go b/internal/iopar/drive_windows.go new file mode 100644 index 00000000..8bb019cf --- /dev/null +++ b/internal/iopar/drive_windows.go @@ -0,0 +1,94 @@ +//go:build windows + +package iopar + +import ( + "path/filepath" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Asked of the volume a path is on, as Windows' own optimiser asks it: does +// it incur a seek penalty (a spinning disk), and over which bus is it +// attached (NVMe or otherwise). + +const ( + ioctlStorageQueryProperty = 0x002D1400 + + storageDeviceProperty = 0 + storageDeviceSeekPenaltyProperty = 7 + propertyStandardQuery = 0 + + busTypeNvme = 17 +) + +type storagePropertyQuery struct { + PropertyID uint32 + QueryType uint32 + Additional [1]byte +} + +type deviceSeekPenaltyDescriptor struct { + Version uint32 + Size uint32 + IncursSeekPenalty byte +} + +type storageDeviceDescriptor struct { + Version uint32 + Size uint32 + DeviceType byte + DeviceTypeModifier byte + RemovableMedia byte + CommandQueueing byte + VendorIDOffset uint32 + ProductIDOffset uint32 + ProductRevisionOffset uint32 + SerialNumberOffset uint32 + BusType uint32 + RawPropertiesLength uint32 +} + +func driveKind(path string) Kind { + abs, err := filepath.Abs(path) + if err != nil { + return Unknown + } + vol := filepath.VolumeName(abs) + if len(vol) != 2 || vol[1] != ':' { + return Unknown // a network share or the like: no drive to ask + } + name, err := windows.UTF16PtrFromString(`\\.\` + strings.ToUpper(vol)) + if err != nil { + return Unknown + } + // No access rights are needed to ask these properties. + h, err := windows.CreateFile(name, 0, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, nil, windows.OPEN_EXISTING, 0, 0) + if err != nil { + return Unknown + } + defer windows.CloseHandle(h) + + var seek deviceSeekPenaltyDescriptor + if query(h, storageDeviceSeekPenaltyProperty, unsafe.Pointer(&seek), uint32(unsafe.Sizeof(seek))) && seek.IncursSeekPenalty != 0 { + return HDD + } + var dev storageDeviceDescriptor + if query(h, storageDeviceProperty, unsafe.Pointer(&dev), uint32(unsafe.Sizeof(dev))) { + if dev.BusType == busTypeNvme { + return NVMe + } + return SATA + } + return Unknown +} + +func query(h windows.Handle, property uint32, out unsafe.Pointer, size uint32) bool { + q := storagePropertyQuery{PropertyID: property, QueryType: propertyStandardQuery} + var returned uint32 + err := windows.DeviceIoControl(h, ioctlStorageQueryProperty, (*byte)(unsafe.Pointer(&q)), uint32(unsafe.Sizeof(q)), + (*byte)(out), size, &returned, nil) + return err == nil && returned >= 8 +} diff --git a/internal/iopar/run.go b/internal/iopar/run.go new file mode 100644 index 00000000..3cd15028 --- /dev/null +++ b/internal/iopar/run.go @@ -0,0 +1,234 @@ +package iopar + +import ( + "context" + "sync" + "sync/atomic" + "time" +) + +// The adjusting: every window the number of operations finished is +// measured, and the number running at once moves the way that made it +// faster — on while it keeps helping, back when it hurt. A drive that slows +// as it goes, its cache full or a scanner busy, gets fewer; one with more to +// give gets more. Once it settles it still looks the other way now and then, +// so a change later in a long run is followed too. + +const ( + // maxAtOnce bounds how many run at once. + maxAtOnce = 64 + // Changes smaller than this are noise, not a reason to move. + better = 1.05 + worse = 0.95 + // probeAfter windows without change before looking the other way. + probeAfter = 4 +) + +// window is how long each measurement runs; a variable, for tests. +var window = 750 * time.Millisecond + +// Stats is how a run went, for the log. +type Stats struct { + Kind Kind + Start int // operations at once to begin with + Settled int // at the end + Peak float64 + Duration time.Duration +} + +// Run calls work for each of n items, as many at a time as suits the drive +// path is on. progress, if not nil, is told how many are done now and then +// and at the end. The first error stops the run and is returned; work +// already started finishes first. +func Run(ctx context.Context, path string, n int, work func(i int) error, progress func(done, total int)) (Stats, error) { + return RunKind(ctx, DriveKind(path), n, work, progress) +} + +// RunFixed runs work with exactly at operations at once, not adjusting: for +// measuring what a drive does at each number. +func RunFixed(ctx context.Context, at, n int, work func(i int) error, progress func(done, total int)) (Stats, error) { + return run(ctx, Unknown, at, false, n, work, progress) +} + +// RunKind is Run for a drive whose kind is known. +func RunKind(ctx context.Context, kind Kind, n int, work func(i int) error, progress func(done, total int)) (Stats, error) { + return run(ctx, kind, kind.start(), kind.adapts(), n, work, progress) +} + +func run(ctx context.Context, kind Kind, start int, adapts bool, n int, work func(i int) error, progress func(done, total int)) (Stats, error) { + started := time.Now() + st := Stats{Kind: kind, Start: start} + if n <= 0 { + return st, nil + } + p := &pool{limit: min(start, n)} + p.cond = sync.NewCond(&p.mu) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var next, done atomic.Int64 + var firstErr error + var errOnce sync.Once + fail := func(err error) { + errOnce.Do(func() { + firstErr = err + cancel() + p.cond.Broadcast() // wake the ones waiting, to stop + }) + } + + workers := min(maxAtOnce, n) + if !adapts { + workers = p.limit + } + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + if !p.acquire(ctx) { + return + } + i := int(next.Add(1) - 1) + if i >= n { + p.release() + return + } + if err := work(i); err != nil { + p.release() + fail(err) + return + } + done.Add(1) + p.release() + } + }() + } + + // Adjusting, and telling progress, until the workers are done. + finished := make(chan struct{}) + go func() { + wg.Wait() + close(finished) + }() + tick := time.NewTicker(window) + defer tick.Stop() + c := climber{dir: 1} + last, lastAt := int64(0), time.Now() +loop: + for { + select { + case <-finished: + break loop + case now := <-tick.C: + d := done.Load() + rate := float64(d-last) / now.Sub(lastAt).Seconds() + last, lastAt = d, now + if rate > st.Peak { + st.Peak = rate + } + if progress != nil { + progress(int(d), n) + } + if adapts { + p.setLimit(c.next(p.getLimit(), rate)) + } + } + } + if progress != nil { + progress(int(done.Load()), n) + } + st.Settled = p.getLimit() + st.Duration = time.Since(started) + if firstErr != nil { + return st, firstErr + } + return st, ctx.Err() +} + +// climber moves the number running at once towards where the most finishes. +type climber struct { + dir int // +1 more, -1 fewer + prevRate float64 + still int // windows without a move +} + +func (c *climber) next(limit int, rate float64) int { + step := max(1, limit/4) + switch { + case c.prevRate == 0: + // The first window: try more. + case rate >= c.prevRate*better: + // That helped: go on the same way. + c.still = 0 + case rate <= c.prevRate*worse: + // That hurt: back the other way. + c.dir = -c.dir + c.still = 0 + default: + // No difference worth the name: stay, and look the other way after + // a while. + c.still++ + if c.still < probeAfter { + c.prevRate = rate + return limit + } + c.still = 0 + c.dir = -c.dir + } + c.prevRate = rate + limit += c.dir * step + if limit < 1 { + limit, c.dir = 1, 1 + } + if limit > maxAtOnce { + limit, c.dir = maxAtOnce, -1 + } + return limit +} + +// pool lets at most limit workers work at once; the limit can move while +// they do. +type pool struct { + mu sync.Mutex + cond *sync.Cond + active int + limit int +} + +func (p *pool) acquire(ctx context.Context) bool { + p.mu.Lock() + defer p.mu.Unlock() + for p.active >= p.limit { + if ctx.Err() != nil { + return false + } + p.cond.Wait() + } + if ctx.Err() != nil { + return false + } + p.active++ + return true +} + +func (p *pool) release() { + p.mu.Lock() + p.active-- + p.mu.Unlock() + p.cond.Broadcast() +} + +func (p *pool) setLimit(n int) { + p.mu.Lock() + p.limit = n + p.mu.Unlock() + p.cond.Broadcast() +} + +func (p *pool) getLimit() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.limit +} diff --git a/internal/iopar/run_test.go b/internal/iopar/run_test.go new file mode 100644 index 00000000..a0f8e21e --- /dev/null +++ b/internal/iopar/run_test.go @@ -0,0 +1,109 @@ +package iopar + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +// A drive that finishes the most with best operations at once: each +// operation takes longer the more run beside it, slowly up to best and +// steeply beyond. +func simulated(best int) func(i int) error { + var active atomic.Int64 + return func(int) error { + a := float64(active.Add(1)) + defer active.Add(-1) + over := a / float64(best) + lat := 200 * time.Microsecond + if over > 1 { + lat = time.Duration(float64(lat) * over * over * over) + } + time.Sleep(lat) + return nil + } +} + +func TestRun_SettlesNearWhatTheDriveDoesBest(t *testing.T) { + defer func(w time.Duration) { window = w }(window) + window = 60 * time.Millisecond + for _, kind := range []Kind{NVMe, SATA} { + st, err := RunKind(context.Background(), kind, 40000, simulated(12), nil) + if err != nil { + t.Fatal(err) + } + t.Logf("%s: started at %d, settled at %d, peak %.0f/s in %v", kind, st.Start, st.Settled, st.Peak, st.Duration) + if st.Settled < 5 || st.Settled > 28 { + t.Errorf("%s: settled at %d, far from the 12 the drive does best", kind, st.Settled) + } + } +} + +func TestRun_HardDiskOneAtATime(t *testing.T) { + var active, most atomic.Int64 + st, err := RunKind(context.Background(), HDD, 300, func(int) error { + a := active.Add(1) + defer active.Add(-1) + for { + m := most.Load() + if a <= m || most.CompareAndSwap(m, a) { + break + } + } + time.Sleep(100 * time.Microsecond) + return nil + }, nil) + if err != nil { + t.Fatal(err) + } + if most.Load() != 1 || st.Settled != 1 { + t.Errorf("a hard disk ran %d at once (settled %d), want 1", most.Load(), st.Settled) + } +} + +func TestRun_EveryItemOnceAndProgress(t *testing.T) { + const n = 5000 + var seen [n]atomic.Int32 + var lastDone int + _, err := RunKind(context.Background(), NVMe, n, func(i int) error { + seen[i].Add(1) + return nil + }, func(done, total int) { + if total != n || done < lastDone { + t.Errorf("progress %d/%d after %d", done, total, lastDone) + } + lastDone = done + }) + if err != nil { + t.Fatal(err) + } + for i := range seen { + if seen[i].Load() != 1 { + t.Fatalf("item %d done %d times", i, seen[i].Load()) + } + } + if lastDone != n { + t.Errorf("progress ended at %d of %d", lastDone, n) + } +} + +func TestRun_StopsAtTheFirstError(t *testing.T) { + boom := errors.New("disk full") + var calls atomic.Int64 + _, err := RunKind(context.Background(), SATA, 100000, func(i int) error { + calls.Add(1) + if i == 50 { + return boom + } + time.Sleep(50 * time.Microsecond) + return nil + }, nil) + if !errors.Is(err, boom) { + t.Fatalf("err = %v, want the work's error", err) + } + if calls.Load() > 5000 { + t.Errorf("went on for %d items after the error", calls.Load()) + } +} diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 2fcfc9b3..d3233dfb 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -171,6 +171,10 @@ type Engine struct { // Playing reports whether a game is being played on this device // (playing.go). Set by the daemon from its play sessions. Playing func(gameID string) bool + // OnPutBack runs when every file of an emptied save is back, put back or + // by hand: no snapshot was taken of it meanwhile, so the save is kept as + // it is now. Set by the daemon. + OnPutBack func(gameID string) // unwritable: games whose save folder could not be written, and until // when pulls into it wait (version.go). unwritable map[string]time.Time diff --git a/internal/p2p/syncengine/hold.go b/internal/p2p/syncengine/hold.go index fb069238..1e794a8d 100644 --- a/internal/p2p/syncengine/hold.go +++ b/internal/p2p/syncengine/hold.go @@ -300,6 +300,11 @@ func (e *Engine) CheckHold(gameID string, serving bool) (held bool, err error) { case has: if x.back(hold.Files()) { _ = e.Store.ClearDeletionHold(gameID) + // The save is whole again: kept as it is now (OnPutBack), the + // first snapshot of it since it was emptied. + if e.OnPutBack != nil { + go e.OnPutBack(gameID) + } if hold.State == store.HoldAsking { e.Log("success", fmt.Sprintf("every save file of %q is back, so nothing is held back any more", game.Name)) } else { @@ -400,6 +405,9 @@ func (e *Engine) PutBack(gameID string) (fetching int, err error) { } if fetching == 0 { err = e.Store.ClearDeletionHold(gameID) + if err == nil && e.OnPutBack != nil { + go e.OnPutBack(gameID) // whole again already + } } else { err = e.Store.SetDeletionHoldState(gameID, store.HoldFetching) } diff --git a/internal/snapshot/extract.go b/internal/snapshot/extract.go new file mode 100644 index 00000000..f7ab5120 --- /dev/null +++ b/internal/snapshot/extract.go @@ -0,0 +1,137 @@ +package snapshot + +import ( + "archive/zip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "sync/atomic" + + "github.com/opensave/opensave/internal/iopar" +) + +// Progress is told how far a long operation on a save has come: which phase +// ("rebuilding", "checking", "writing"), how many of how many done. +type Progress func(phase string, done, total int) + +// Phases of a restore, as Progress names them. +const ( + PhaseRebuilding = "rebuilding" + PhaseChecking = "checking" + PhaseWriting = "writing" +) + +// extractJob is one archive entry to write: under destDir, at rel. +type extractJob struct { + f *zip.File + destDir string + rel string +} + +// extractAll writes archive entries into place. Every folder is made first, +// once — not looked up again for each of a quarter of a million files — and +// the files are then written as many at a time as suits the drive they go to +// (iopar): one at a time on a spinning disk, many at once on an SSD, where +// written one after another a large save took most of a quarter of an hour +// at a fraction of what the drive can do. +func extractAll(jobs []extractJob, onDrive string, progress Progress) error { + type target struct { + f *zip.File + dest string + } + var files []target + dirs := map[string]struct{}{} + for _, j := range jobs { + dest, err := entryDest(j.f.Name, j.destDir, j.rel) + if err != nil { + return err + } + if j.f.FileInfo().IsDir() { + dirs[dest] = struct{}{} + continue + } + dirs[filepath.Dir(dest)] = struct{}{} + files = append(files, target{j.f, dest}) + } + ordered := make([]string, 0, len(dirs)) + for d := range dirs { + ordered = append(ordered, d) + } + sort.Strings(ordered) // a parent before what is inside it + for _, d := range ordered { + if err := os.MkdirAll(d, 0o777); err != nil { + return err + } + } + + var report func(done, total int) + if progress != nil { + report = func(done, total int) { progress(PhaseWriting, done, total) } + } + st, err := iopar.RunKind(context.Background(), driveKindOf(onDrive), len(files), func(i int) error { + return writeEntry(files[i].f, files[i].dest) + }, report) + lastExtract.Store(&st) + return err +} + +// driveKindOf is iopar.DriveKind; a variable, so a test can compare one at a +// time with many on the same drive. +var driveKindOf = iopar.DriveKind + +// lastExtract is how the last extraction went, for a test to report. +var lastExtract atomic.Pointer[iopar.Stats] + +// entryDest is where an entry is written: rel under destDir, refused if it +// would land outside it (zip-slip). Checked on the name actually written, +// since that is the one that decides where bytes land. +func entryDest(name, destDir, rel string) (string, error) { + clean := filepath.Clean(filepath.FromSlash(rel)) + if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) { + return "", fmt.Errorf("zip entry %q escapes destination", name) + } + return filepath.Join(destDir, clean), nil +} + +var copyBuffers = sync.Pool{New: func() any { b := make([]byte, 64<<10); return &b }} + +// writeEntry writes one file entry to dest, whose folder exists. +func writeEntry(f *zip.File, dest string) error { + src, err := f.Open() + if err != nil { + return err + } + defer src.Close() + dst, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o666) + if err != nil { + return err + } + buf := copyBuffers.Get().(*[]byte) + defer copyBuffers.Put(buf) + if _, err := io.CopyBuffer(dst, src, *buf); err != nil { + dst.Close() + return err + } + return dst.Close() +} + +// progressFor is how a restore of gameID tells OnRestoreProgress how far it +// has come; nil when nobody listens. +func (m *Manager) progressFor(gameID string) Progress { + if m.OnRestoreProgress == nil { + return nil + } + return func(phase string, done, total int) { m.OnRestoreProgress(gameID, phase, done, total) } +} + +// progressDone tells OnRestoreProgress the restore of gameID is over. +func (m *Manager) progressDone(gameID string) { + if m.OnRestoreProgress != nil { + m.OnRestoreProgress(gameID, "", 0, 0) + } +} diff --git a/internal/snapshot/shared.go b/internal/snapshot/shared.go index 81de0691..b3d8504d 100644 --- a/internal/snapshot/shared.go +++ b/internal/snapshot/shared.go @@ -140,6 +140,12 @@ var reading = struct { // with it. A missing snapshot is an error that matches fs.ErrNotExist; a // compacted one whose shared files are damaged, one that matches ErrDamaged. func OpenArchive(zipPath string) (path string, done func(), err error) { + return OpenArchiveProgress(zipPath, nil) +} + +// OpenArchiveProgress is OpenArchive telling progress how far a rebuild of a +// compacted archive has come. +func OpenArchiveProgress(zipPath string, progress Progress) (path string, done func(), err error) { reading.Lock() if _, statErr := os.Stat(zipPath); statErr == nil { reading.n[zipPath]++ @@ -165,7 +171,7 @@ func OpenArchive(zipPath string) (path string, done func(), err error) { return "", func() {}, err } name := tmp.Name() - err = rebuild(zipPath, tmp) + err = rebuild(zipPath, tmp, progress) if closeErr := tmp.Close(); err == nil { err = closeErr } @@ -250,7 +256,7 @@ func validSum(s string) bool { } // rebuild writes a compacted snapshot's archive back out whole. -func rebuild(zipPath string, out io.Writer) error { +func rebuild(zipPath string, out io.Writer, progress Progress) error { man, err := readManifest(manifestPath(zipPath)) if err != nil { return err @@ -265,7 +271,10 @@ func rebuild(zipPath string, out io.Writer) error { } w := zip.NewWriter(out) next := 0 - for _, e := range man.Entries { + for i, e := range man.Entries { + if progress != nil && i%500 == 0 { + progress(PhaseRebuilding, i, len(man.Entries)) + } if e.Blob != "" { if err := copyShared(w, store, e); err != nil { return err @@ -280,6 +289,9 @@ func rebuild(zipPath string, out io.Writer) error { } next++ } + if progress != nil { + progress(PhaseRebuilding, len(man.Entries), len(man.Entries)) + } return w.Close() } @@ -603,7 +615,7 @@ func sameArchive(zipPath string, orig *zip.ReadCloser) error { return err } defer os.Remove(tmp.Name()) - err = rebuild(zipPath, tmp) + err = rebuild(zipPath, tmp, nil) if closeErr := tmp.Close(); err == nil { err = closeErr } diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 58c5cd83..ae7dd171 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -56,8 +56,12 @@ func (e *IncompleteError) Error() string { // Manager performs snapshot/branch operations against the store and // filesystem. type Manager struct { - Store *store.Store - OnUpload UploadHook + Store *store.Store + // OnRestoreProgress is told how far a restore of a game has come: its + // phase (PhaseRebuilding, PhaseChecking, PhaseWriting) and how many of + // how many done; phase "" when it is over. Optional. + OnRestoreProgress func(gameID, phase string, done, total int) + OnUpload UploadHook // OnCreated fires for every snapshot written, before its upload starts. // current is false for a copy kept of a save about to be replaced (see // CreateBeforeReplacing). Optional; runs on the snapshotting goroutine @@ -800,10 +804,12 @@ func (m *Manager) Restore(gameID, snapshotID string) (store.Snapshot, error) { // restore empties the save folder and then extracts; an archive found // damaged part-way through would leave neither the save that was there // nor this one. - archive, done, err := OpenArchive(snap.ZipPath) + progress := m.progressFor(gameID) + defer m.progressDone(gameID) + archive, done, err := OpenArchiveProgress(snap.ZipPath, progress) if err == nil { defer done() - err = VerifyArchive(archive) + err = VerifyArchiveProgress(archive, progress) } else { err = damagedArchive(snap.ZipPath, err) } @@ -836,7 +842,7 @@ func (m *Manager) Restore(gameID, snapshotID string) (store.Snapshot, error) { if rootsErr != nil { restoreRoots = nil } - unplaced, err := UnzipRoots(restoreZip, game.SavePath, restoreRoots) + unplaced, err := UnzipRootsProgress(restoreZip, game.SavePath, restoreRoots, progress) for _, name := range unplaced { // Named but not placed: the snapshot holds files for a location this // device has no folder for. Saying so is the only honest option — diff --git a/internal/snapshot/verify.go b/internal/snapshot/verify.go index 69f1ff47..ecce0bf9 100644 --- a/internal/snapshot/verify.go +++ b/internal/snapshot/verify.go @@ -2,6 +2,7 @@ package snapshot import ( "archive/zip" + "context" "errors" "fmt" "io" @@ -10,6 +11,7 @@ import ( "sync" "time" + "github.com/opensave/opensave/internal/iopar" "github.com/opensave/opensave/internal/store" ) @@ -34,6 +36,14 @@ var ErrDamaged = errors.New("snapshot is damaged") // by its parts: its own small files read in full, and every shared file it // names read back against the hash of the bytes it was stored with. func VerifyArchive(path string) error { + return VerifyArchiveProgress(path, nil) +} + +// VerifyArchiveProgress is VerifyArchive telling progress how far it is. The +// entries are read as many at a time as suits the drive the archive is on +// (iopar): each is a small read of its own, and one after another a large +// save's quarter of a million of them took minutes. +func VerifyArchiveProgress(path string, progress Progress) error { r, err := zip.OpenReader(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { @@ -45,15 +55,25 @@ func VerifyArchive(path string) error { return fmt.Errorf("%w: its archive cannot be opened: %v", ErrDamaged, err) } defer r.Close() + var files []*zip.File for _, f := range r.File { - if f.FileInfo().IsDir() { - continue + if !f.FileInfo().IsDir() { + files = append(files, f) } + } + var report func(done, total int) + if progress != nil { + report = func(done, total int) { progress(PhaseChecking, done, total) } + } + _, err = iopar.RunKind(context.Background(), driveKindOf(path), len(files), func(i int) error { + f := files[i] rc, err := f.Open() if err != nil { return fmt.Errorf("%w: %s cannot be read: %v", ErrDamaged, f.Name, err) } - _, err = io.Copy(io.Discard, rc) + buf := copyBuffers.Get().(*[]byte) + _, err = io.CopyBuffer(io.Discard, rc, *buf) + copyBuffers.Put(buf) rc.Close() if err != nil { if errors.Is(err, zip.ErrChecksum) { @@ -61,8 +81,9 @@ func VerifyArchive(path string) error { } return fmt.Errorf("%w: %s cannot be read: %v", ErrDamaged, f.Name, err) } - } - return nil + return nil + }, report) + return err } // Verify checks one snapshot and records what it found. diff --git a/internal/snapshot/zip.go b/internal/snapshot/zip.go index b60ea395..53db6e11 100644 --- a/internal/snapshot/zip.go +++ b/internal/snapshot/zip.go @@ -266,6 +266,11 @@ func newSnapshotWriter(out io.Writer) *zip.Writer { // directory and extracts into it — both matching unzipDirectory() in the // JS app. func UnzipTo(zipPath, targetPath string) error { + return UnzipToProgress(zipPath, targetPath, nil) +} + +// UnzipToProgress is UnzipTo telling progress how far it is. +func UnzipToProgress(zipPath, targetPath string, progress Progress) error { // Defence in depth, not the load-bearing guard. Extraction does not // restore the archived modification time — the written file gets the // current one — so the hash cache would notice on its own today. It is @@ -326,12 +331,11 @@ func UnzipTo(zipPath, targetPath string) error { } } + jobs := make([]extractJob, 0, len(r.File)) for _, entry := range r.File { - if err := extractEntry(entry, destDir); err != nil { - return err - } + jobs = append(jobs, extractJob{f: entry, destDir: destDir, rel: entry.Name}) } - return nil + return extractAll(jobs, destDir, progress) } func extractEntry(entry *zip.File, destDir string) error { diff --git a/internal/snapshot/zip_roots.go b/internal/snapshot/zip_roots.go index 5100f6ea..540e538f 100644 --- a/internal/snapshot/zip_roots.go +++ b/internal/snapshot/zip_roots.go @@ -212,6 +212,11 @@ func rootOfEntry(entry string) (string, bool) { // restore — the operation someone reaches for when things have already gone // wrong. func UnzipRoots(zipPath, primary string, extra map[string]string) (unplaced []string, err error) { + return UnzipRootsProgress(zipPath, primary, extra, nil) +} + +// UnzipRootsProgress is UnzipRoots telling progress how far it is. +func UnzipRootsProgress(zipPath, primary string, extra map[string]string, progress Progress) (unplaced []string, err error) { // Every location this can write into, dropped from the hash cache. See // UnzipTo for why this is defence in depth rather than the guard that is // currently doing the work. @@ -227,7 +232,7 @@ func UnzipRoots(zipPath, primary string, extra map[string]string) (unplaced []st return nil, err } if len(roots) == 0 { - return nil, UnzipTo(zipPath, primary) + return nil, UnzipToProgress(zipPath, primary, progress) } r, err := zip.OpenReader(zipPath) @@ -264,6 +269,7 @@ func UnzipRoots(zipPath, primary string, extra map[string]string) (unplaced []st cleared := map[string]bool{} missing := map[string]bool{} + var jobs []extractJob for _, f := range r.File { name, isRoot := rootOfEntry(f.Name) target := primary @@ -299,9 +305,10 @@ func UnzipRoots(zipPath, primary string, extra map[string]string) (unplaced []st continue } } - if err := extractEntryAs(f, target, entry); err != nil { - return nil, err - } + jobs = append(jobs, extractJob{f: f, destDir: target, rel: entry}) + } + if err := extractAll(jobs, primary, progress); err != nil { + return nil, err } for name := range missing { From cf88373440baf31707baa954f8aa0bd35fabbdc6 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:59:58 +0200 Subject: [PATCH 51/75] A process doing nothing for a minute is not the game, whatever it holds Crimson Desert, killed, left two processes behind that kept their memory and used no processor time at all, and its session stayed open: idle was only what held under 32 MB as well. Each process's processor time is followed from poll to poll; one that has used none for a minute is not the game being played, however much memory it holds. A game being played uses some all the time - a paused one still draws its menu. One that cannot be measured still counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/daemon/sessions.go | 48 +++++++++++++++++++++++++++----- internal/daemon/sessions_test.go | 36 ++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/internal/daemon/sessions.go b/internal/daemon/sessions.go index 5508af3b..07825a60 100644 --- a/internal/daemon/sessions.go +++ b/internal/daemon/sessions.go @@ -57,8 +57,9 @@ type sessionState struct { // gap: the longest time seen between two changes of each game's save in // a stretch (activity.maxGap). gap map[string]time.Duration - // cpu: each process's processor time at the last poll (activeProcesses). - cpu map[int]time.Duration + // cpu: each process's processor time at the last poll, and when it last + // used any (activeProcesses). + cpu map[int]procSeen // endedAt: when each game's last session ended (holdSnapshotWhilePlaying). endedAt map[string]time.Time // startHash is each playing game's save as it was when play began, to @@ -909,19 +910,52 @@ func (d *Daemon) playingHere(gameID string) bool { // kept a session open for as long as it lingered, holding the save back // from syncing after the game was long gone. func (d *Daemon) activeProcesses(procs []sessions.Proc) []sessions.Proc { + return d.activeProcessesAt(procs, time.Now()) +} + +// procSeen is a process's processor time at the last poll, and when it was +// last seen using any. +type procSeen struct { + cpu time.Duration + busy time.Time +} + +// idleFor is how long a process may use no processor time at all before it +// is no longer the game, however much memory it holds: what a game leaves +// behind when it is closed or killed can keep its memory, and kept a session +// open with it. A game being played uses some all the time — a paused one +// still draws its menu. +const idleFor = time.Minute + +// busyCPU is the processor time between two polls that counts as using any. +const busyCPU = 20 * time.Millisecond + +func (d *Daemon) activeProcessesAt(procs []sessions.Proc, now time.Time) []sessions.Proc { d.sessions.mu.Lock() defer d.sessions.mu.Unlock() prev := d.sessions.cpu - next := make(map[int]time.Duration, len(procs)) + next := make(map[int]procSeen, len(procs)) out := procs[:0:0] for _, p := range procs { - if p.Measured { - next[p.PID] = p.CPU - } before, seen := prev[p.PID] - if sessions.Idle(p, before, seen) { + if sessions.Idle(p, before.cpu, seen) { + if p.Measured { + next[p.PID] = procSeen{cpu: p.CPU, busy: before.busy} + } + continue + } + if !p.Measured { + out = append(out, p) // cannot be told: counted continue } + s := procSeen{cpu: p.CPU, busy: before.busy} + if !seen || p.CPU-before.cpu >= busyCPU { + s.busy = now // new, or working + } + next[p.PID] = s + if now.Sub(s.busy) > idleFor { + continue // holding memory, doing nothing + } out = append(out, p) } d.sessions.cpu = next diff --git a/internal/daemon/sessions_test.go b/internal/daemon/sessions_test.go index dd2975dd..f8519286 100644 --- a/internal/daemon/sessions_test.go +++ b/internal/daemon/sessions_test.go @@ -404,3 +404,39 @@ func TestEmptiedSaveIsNotPlay(t *testing.T) { t.Error("an emptied save is still handled as being played") } } + +// What a killed game leaves behind can keep its memory; it is no longer the +// game once it has used no processor time for a minute. One still working — +// a paused game draws its menu — stays. +func TestActiveProcesses_HoldingMemoryDoingNothing(t *testing.T) { + d := newTestDaemon(t) + t0 := time.Now() + left := func(cpu time.Duration) sessions.Proc { + return sessions.Proc{PID: 1, Exe: `G:\Games\Crimson Desert\bin64\CrimsonDesert.exe`, Measured: true, Memory: 2 << 30, CPU: cpu} + } + playing := func(cpu time.Duration) sessions.Proc { + return sessions.Proc{PID: 2, Exe: `G:\Games\Other\game.exe`, Measured: true, Memory: 2 << 30, CPU: cpu} + } + count := func(ps []sessions.Proc) map[int]bool { + m := map[int]bool{} + for _, p := range ps { + m[p.PID] = true + } + return m + } + got := count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(time.Second)}, t0)) + if !got[1] || !got[2] { + t.Fatalf("first seen: %v, want both", got) + } + got = count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(2 * time.Second)}, t0.Add(30*time.Second))) + if !got[1] || !got[2] { + t.Errorf("half a minute in: %v, want both", got) + } + got = count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(3 * time.Second)}, t0.Add(70*time.Second))) + if got[1] { + t.Error("a process doing nothing for over a minute still counts as the game") + } + if !got[2] { + t.Error("a game using the processor was dropped") + } +} From a202a9313bd11e2b6a657b46d1f86c91315651eb Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:41:53 +0200 Subject: [PATCH 52/75] Settings patterns match with either separator; install-folder test on any system Linux CI found two things the Windows runs could not: - relativeUnder split paths only on the system's own separator, so on Linux a pattern written Windows-style (Ludusavi's data, a Proton prefix) never matched the folder it names, and path.Match read its backslashes as escapes. Both separators are split on, on every system. - TestRunning_ByInstallFolderName gave processes Windows paths; a process's program is a native path, so the test now builds them for the system it runs on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/presets/devicesettings.go | 6 ++++-- internal/sessions/sessions_test.go | 14 ++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/presets/devicesettings.go b/internal/presets/devicesettings.go index c78080c5..87a75a78 100644 --- a/internal/presets/devicesettings.go +++ b/internal/presets/devicesettings.go @@ -2,7 +2,6 @@ package presets import ( "path" - "path/filepath" "sort" "strings" ) @@ -106,7 +105,10 @@ func (ds *DeviceSettings) Patterns(name, appID string, roots []string) []string // Case-insensitive, as exclusion rules are. func relativeUnder(root, pattern string) (string, bool) { split := func(p string) []string { - p = strings.ToLower(filepath.ToSlash(filepath.Clean(p))) + // Either separator on every system: a pattern written for Windows + // (Ludusavi's data, a Proton prefix) is matched on Linux too, and + // path.Match would read a backslash as an escape. + p = strings.ToLower(path.Clean(strings.ReplaceAll(p, `\`, "/"))) var parts []string for _, c := range strings.Split(p, "/") { if c != "" { diff --git a/internal/sessions/sessions_test.go b/internal/sessions/sessions_test.go index 516dc3fc..87131882 100644 --- a/internal/sessions/sessions_test.go +++ b/internal/sessions/sessions_test.go @@ -122,18 +122,24 @@ func TestRunning_ProtectedProcessByName(t *testing.T) { // A game installed in a folder no library lists is found by the folder's // name, wherever that folder is. func TestRunning_ByInstallFolderName(t *testing.T) { + // Paths as this system writes them: a process's program is a native path. + root := string(filepath.Separator) + if runtime.GOOS == "windows" { + root = `G:\` + } + games := filepath.Join(root, "Games") target := Target{GameID: "cd", FolderNames: []string{FolderName("Crimson Desert")}} procs := []Proc{ - {PID: 5, Exe: `C:\Windows\explorer.exe`}, - {PID: 6, Exe: `G:\Games\Crimson Desert\bin64\CrimsonDesert.exe`}, + {PID: 5, Exe: filepath.Join(root, "Windows", "explorer.exe")}, + {PID: 6, Exe: filepath.Join(games, "Crimson Desert", "bin64", "CrimsonDesert.exe")}, } if got := Running(procs, []Target{target}); got["cd"] != 6 { t.Fatalf("Running = %v, want the game found by its folder", got) } - if dir := FolderOf(procs[1], target); dir != `G:\Games\Crimson Desert` { + if dir := FolderOf(procs[1], target); dir != filepath.Join(games, "Crimson Desert") { t.Errorf("FolderOf = %q", dir) } - other := []Proc{{PID: 7, Exe: `G:\Games\Crimson Desert Tools\editor.exe`}} + other := []Proc{{PID: 7, Exe: filepath.Join(games, "Crimson Desert Tools", "editor.exe")}} if got := Running(other, []Target{target}); len(got) != 0 { t.Errorf("a folder whose name only begins the same was taken for the game: %v", got) } From d7eec532bc7737c724ffc391ef5cb302011f5597 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:10:18 +0200 Subject: [PATCH 53/75] Clear deletion records 500 paths to a statement One statement per path, even inside one transaction, took 10s for 50,000 records under the race detector on CI's runners, and the test's limit failed. 500 paths to a statement: 50,000 records in about 0.3s here. The test's limit is generous now (30s): what it guards against took minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/store/compact_test.go | 4 +++- internal/store/deleted_files.go | 28 +++++++++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/internal/store/compact_test.go b/internal/store/compact_test.go index 05c1c26d..1cec9f8f 100644 --- a/internal/store/compact_test.go +++ b/internal/store/compact_test.go @@ -74,7 +74,9 @@ func TestClearDeletedFilesIsOneTransaction(t *testing.T) { if len(left) != 0 { t.Errorf("%d records left", len(left)) } - if took > 10*time.Second { + // Generous: CI runs this under the race detector on shared runners, where + // one statement per file took 10s; what this guards against took minutes. + if took > 30*time.Second { t.Errorf("clearing 50,000 records took %v", took) } t.Logf("cleared 50,000 deletion records in %v", took) diff --git a/internal/store/deleted_files.go b/internal/store/deleted_files.go index d3b11246..0a4a0e0e 100644 --- a/internal/store/deleted_files.go +++ b/internal/store/deleted_files.go @@ -109,7 +109,9 @@ func (s *Store) ClearDeletedFile(gameID, root, path string) error { // ClearDeletedFiles forgets the deletions recorded for paths of one save // location, in one transaction: one at a time, each its own write to disk, -// the quarter of a million of a large save took a quarter of an hour. +// the quarter of a million of a large save took a quarter of an hour. Paths go +// clearDeletedChunk at a time, so that is a few hundred statements, not one +// per file. func (s *Store) ClearDeletedFiles(gameID, root string, paths []string) error { if len(paths) == 0 { return nil @@ -119,19 +121,27 @@ func (s *Store) ClearDeletedFiles(gameID, root string, paths []string) error { return err } defer tx.Rollback() - stmt, err := tx.Preparex(`DELETE FROM deleted_files WHERE game_id = ? AND root = ? AND path = ?`) - if err != nil { - return fmt.Errorf("prepare clear deletion records: %w", err) - } - defer stmt.Close() - for _, p := range paths { - if _, err := stmt.Exec(gameID, root, p); err != nil { - return fmt.Errorf("clear deletion record for %s: %w", p, err) + for len(paths) > 0 { + n := min(len(paths), clearDeletedChunk) + args := make([]any, 0, n+2) + args = append(args, gameID, root) + for _, p := range paths[:n] { + args = append(args, p) } + q := `DELETE FROM deleted_files WHERE game_id = ? AND root = ? AND path IN (?` + + strings.Repeat(",?", n-1) + `)` + if _, err := tx.Exec(q, args...); err != nil { + return fmt.Errorf("clear deletion records: %w", err) + } + paths = paths[n:] } return tx.Commit() } +// clearDeletedChunk is how many paths one statement of ClearDeletedFiles +// names: well under SQLite's limit on bound parameters. +const clearDeletedChunk = 500 + // ClearDeletedFilesForGame forgets every deletion recorded for a game, for use // when it is untracked — the records describe a folder this device no longer // has an opinion about. From 08cd89f5ab2dfe603f26720492d9a28a1e00dd56 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:10:18 +0200 Subject: [PATCH 54/75] A file OpenSave wrote and someone then deleted is their change owntouch took any marked path found gone for one OpenSave had removed. A file pulled from a peer and then deleted within two minutes - by the game, or by a person - was therefore taken for OpenSave's own change: the watcher recorded the save's new state as already known and raised no change, no snapshot was taken, and with save versions the next sync saw this device still holding the peer's version and did nothing. The deletion never reached the other device (CI: TestReverseDeletionPropagation, Windows). Removals are marked as such now (MarkRemoved, at each place OpenSave removes a file or folder in a save), and only a path marked that way counts as OpenSave's when it is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/owntouch/owntouch.go | 43 +++++++++++++++++++++-------- internal/owntouch/owntouch_test.go | 36 ++++++++++++++++++++++++ internal/p2p/routes.go | 2 +- internal/p2p/syncengine/conflict.go | 2 +- internal/p2p/syncengine/engine.go | 4 +-- 5 files changed, 71 insertions(+), 16 deletions(-) diff --git a/internal/owntouch/owntouch.go b/internal/owntouch/owntouch.go index 7675623c..f32f259c 100644 --- a/internal/owntouch/owntouch.go +++ b/internal/owntouch/owntouch.go @@ -26,9 +26,15 @@ const window = 2 * time.Minute var ( mu sync.Mutex - marked = map[string]time.Time{} + marked = map[string]mark{} ) +// mark is when OpenSave touched a path, and whether it removed it. +type mark struct { + at time.Time + removed bool +} + func key(path string) string { p := filepath.Clean(path) if runtime.GOOS == "windows" { @@ -40,18 +46,26 @@ func key(path string) string { // Mark records that OpenSave itself just created, wrote, removed or re-dated // path. Its parent folder is marked too: creating or removing an entry is an // event on the folder as well. -func Mark(path string) { +func Mark(path string) { record(path, false) } + +// MarkRemoved records that OpenSave itself is about to remove path. Only a +// path marked this way counts as OpenSave's when it is found gone: a file +// OpenSave has just written and a game or a person then deleted is their +// change, and has to sync and be snapshotted as one. +func MarkRemoved(path string) { record(path, true) } + +func record(path string, removed bool) { if path == "" { return } now := time.Now() mu.Lock() defer mu.Unlock() - marked[key(path)] = now - marked[key(filepath.Dir(path))] = now + marked[key(path)] = mark{at: now, removed: removed} + marked[key(filepath.Dir(path))] = mark{at: now} if len(marked) > 50000 { - for k, at := range marked { - if now.Sub(at) > window { + for k, m := range marked { + if now.Sub(m.at) > window { delete(marked, k) } } @@ -72,18 +86,23 @@ const slack = 2 * time.Second // no new version to hand to anyone. Whatever OpenSave leaves behind is dated // no later than its mark (a pulled file keeps the time it was written to its // temporary name, or is given the peer's; re-dating sets now), so a file -// dated later was written by someone else. A path that is gone was removed by -// OpenSave; a folder carries no save of its own, so its mark is enough. +// dated later was written by someone else. A path that is gone is OpenSave's +// only if OpenSave removed it (MarkRemoved): one it wrote and someone then +// deleted is theirs. A folder carries no save of its own, so its mark is +// enough. func Recent(path string) bool { mu.Lock() - at, ok := marked[key(path)] + m, ok := marked[key(path)] mu.Unlock() - if !ok || time.Since(at) > window { + if !ok || time.Since(m.at) > window { return false } fi, err := os.Lstat(path) - if err != nil || fi.IsDir() { + if err != nil { + return m.removed + } + if fi.IsDir() { return true } - return !fi.ModTime().After(at.Add(slack)) + return !fi.ModTime().After(m.at.Add(slack)) } diff --git a/internal/owntouch/owntouch_test.go b/internal/owntouch/owntouch_test.go index 94ad5edf..19d49ddb 100644 --- a/internal/owntouch/owntouch_test.go +++ b/internal/owntouch/owntouch_test.go @@ -14,6 +14,12 @@ func TestMarkAndRecent(t *testing.T) { t.Fatal("unmarked path reported as ours") } Mark(file) + if err := os.MkdirAll(filepath.Dir(file), 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("pulled"), 0o666); err != nil { + t.Fatal(err) + } if !Recent(file) { t.Error("marked path not reported as ours") } @@ -49,3 +55,33 @@ func TestRecent_AWriteAfterTheMarkIsNotOurs(t *testing.T) { t.Error("a save written after OpenSave's was taken for OpenSave's") } } + +// A file OpenSave has just written, then deleted by a game or a person, is +// their deletion: it has to sync and be snapshotted. Only a path OpenSave +// removed itself is its own when found gone. +func TestRecent_AFileDeletedAfterOurWriteIsNotOurs(t *testing.T) { + dir := t.TempDir() + pulled := filepath.Join(dir, "pulled.sav") + Mark(pulled) + if err := os.WriteFile(pulled, []byte("from the peer"), 0o666); err != nil { + t.Fatal(err) + } + if err := os.Remove(pulled); err != nil { + t.Fatal(err) + } + if Recent(pulled) { + t.Error("a file deleted by someone else after OpenSave wrote it was taken for OpenSave's") + } + + removed := filepath.Join(dir, "removed.sav") + if err := os.WriteFile(removed, []byte("old"), 0o666); err != nil { + t.Fatal(err) + } + MarkRemoved(removed) + if err := os.Remove(removed); err != nil { + t.Fatal(err) + } + if !Recent(removed) { + t.Error("a file OpenSave removed was not reported as its own") + } +} diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index 68fd542e..84c7ecab 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -1035,7 +1035,7 @@ func (e *Engine) handleDeleteFile(w http.ResponseWriter, r *http.Request) { } // Empty dirs only, for a folder, like rmdirSync. // A change made at a peer's request, not by the game (owntouch). - owntouch.Mark(full) + owntouch.MarkRemoved(full) if os.Remove(full) == nil && (info.IsDir() || entry.Hash != "") { e.Sync.NotePeerDeletion(game.ID, body.Root, body.RelPath, entry, info.IsDir()) } diff --git a/internal/p2p/syncengine/conflict.go b/internal/p2p/syncengine/conflict.go index 67a98e7f..3020fe85 100644 --- a/internal/p2p/syncengine/conflict.go +++ b/internal/p2p/syncengine/conflict.go @@ -378,7 +378,7 @@ func (e *Engine) overwriteLocalWithRemote(ctx context.Context, gameID string, pe } full := filepath.Join(game.SavePath, filepath.FromSlash(relPath)) _ = os.Chmod(full, 0o666) - owntouch.Mark(full) + owntouch.MarkRemoved(full) _ = os.Remove(full) } diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index d3233dfb..9664ba3f 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -1536,7 +1536,7 @@ func (e *Engine) applyLocalDeletions(gameID string, root syncRoot, d Decision) { // spelling this disk actually holds. full := delta.LocalNameFor(root.Path, relPath) _ = os.Chmod(full, 0o666) - owntouch.Mark(full) + owntouch.MarkRemoved(full) if err := os.Remove(full); err == nil { e.Log("info", "deleted locally (peer deleted): "+relPath) } @@ -1551,7 +1551,7 @@ func (e *Engine) applyLocalDeletions(gameID string, root syncRoot, d Decision) { } full := delta.LocalNameFor(root.Path, relDir) if info, err := os.Stat(full); err == nil && info.IsDir() { - owntouch.Mark(full) + owntouch.MarkRemoved(full) if err := os.Remove(full); err == nil { // only removes empty dirs, matching rmdirSync e.Log("info", "deleted directory locally (peer deleted): "+relDir) } From fe31e931df3a4f81e4f3f1dd0c7816388046f2bf Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:18:01 +0200 Subject: [PATCH 55/75] A save written straight after a pull is the game's, however soon owntouch took a file for OpenSave's own while it was dated no later than OpenSave's mark plus two seconds of slack. A game saving over a file a pull had just written, inside those two seconds, was therefore taken for the pull: no snapshot of it, no change raised. Once OpenSave is done with a file - renamed into place, re-dated to the peer's time, or re-dated by "keep mine" - it records the time and size it left it with (Settled), and from then on the file is OpenSave's only while it has exactly those. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/delta/patch.go | 1 + internal/owntouch/owntouch.go | 33 +++++++++++++++++++++++++++-- internal/owntouch/owntouch_test.go | 22 +++++++++++++++++++ internal/p2p/syncengine/batch.go | 3 +++ internal/p2p/syncengine/conflict.go | 2 ++ internal/p2p/syncengine/engine.go | 1 + 6 files changed, 60 insertions(+), 2 deletions(-) diff --git a/internal/delta/patch.go b/internal/delta/patch.go index 18ef589f..98e0104b 100644 --- a/internal/delta/patch.go +++ b/internal/delta/patch.go @@ -82,6 +82,7 @@ func replaceWithRetry(tmpPath, filePath string) error { // known as a change OpenSave made (owntouch). owntouch.Mark(filePath) if err = renameFile(tmpPath, filePath); err == nil { + owntouch.Settled(filePath) return nil } } diff --git a/internal/owntouch/owntouch.go b/internal/owntouch/owntouch.go index f32f259c..e63987a5 100644 --- a/internal/owntouch/owntouch.go +++ b/internal/owntouch/owntouch.go @@ -29,10 +29,14 @@ var ( marked = map[string]mark{} ) -// mark is when OpenSave touched a path, and whether it removed it. +// mark is when OpenSave touched a path, whether it removed it, and - once +// OpenSave is done with a file (Settled) - the time and size it left it with. type mark struct { at time.Time removed bool + settled bool + mod time.Time + size int64 } func key(path string) string { @@ -54,6 +58,25 @@ func Mark(path string) { record(path, false) } // change, and has to sync and be snapshotted as one. func MarkRemoved(path string) { record(path, true) } +// Settled records the time and size OpenSave has left a file it marked with, +// once it is done with it (written, renamed into place, re-dated). From then on +// the file is OpenSave's only while it still has exactly those: a game saving +// over it a moment later - inside any slack a time comparison would need - is +// the game's change. +func Settled(path string) { + fi, err := os.Lstat(path) + if err != nil || fi.IsDir() { + return + } + k := key(path) + mu.Lock() + defer mu.Unlock() + if m, ok := marked[k]; ok { + m.settled, m.mod, m.size = true, fi.ModTime(), fi.Size() + marked[k] = m + } +} + func record(path string, removed bool) { if path == "" { return @@ -86,7 +109,10 @@ const slack = 2 * time.Second // no new version to hand to anyone. Whatever OpenSave leaves behind is dated // no later than its mark (a pulled file keeps the time it was written to its // temporary name, or is given the peer's; re-dating sets now), so a file -// dated later was written by someone else. A path that is gone is OpenSave's +// dated later was written by someone else. Once OpenSave is done with a file +// (Settled) the test is exact: the time and size it left, or not ours - the +// slack would take a game's save within two seconds of a pull for the pull's. +// A path that is gone is OpenSave's // only if OpenSave removed it (MarkRemoved): one it wrote and someone then // deleted is theirs. A folder carries no save of its own, so its mark is // enough. @@ -104,5 +130,8 @@ func Recent(path string) bool { if fi.IsDir() { return true } + if m.settled { + return fi.ModTime().Equal(m.mod) && fi.Size() == m.size + } return !fi.ModTime().After(m.at.Add(slack)) } diff --git a/internal/owntouch/owntouch_test.go b/internal/owntouch/owntouch_test.go index 19d49ddb..aaa0001b 100644 --- a/internal/owntouch/owntouch_test.go +++ b/internal/owntouch/owntouch_test.go @@ -85,3 +85,25 @@ func TestRecent_AFileDeletedAfterOurWriteIsNotOurs(t *testing.T) { t.Error("a file OpenSave removed was not reported as its own") } } + +// A game saving over a file OpenSave has just pulled is the game's change +// however soon it comes - inside the slack a time comparison allows too. +func TestRecent_AWriteRightAfterASettledPullIsNotOurs(t *testing.T) { + file := filepath.Join(t.TempDir(), "progress.sav") + Mark(file) + if err := os.WriteFile(file, []byte("from A"), 0o666); err != nil { + t.Fatal(err) + } + Settled(file) + if !Recent(file) { + t.Fatal("the file OpenSave pulled is not reported as ours") + } + // Same size, a moment later: only the time tells them apart. + time.Sleep(20 * time.Millisecond) + if err := os.WriteFile(file, []byte("from B"), 0o666); err != nil { + t.Fatal(err) + } + if Recent(file) { + t.Error("a save written straight after the pull was taken for OpenSave's") + } +} diff --git a/internal/p2p/syncengine/batch.go b/internal/p2p/syncengine/batch.go index 7a15d94e..0799c625 100644 --- a/internal/p2p/syncengine/batch.go +++ b/internal/p2p/syncengine/batch.go @@ -10,6 +10,7 @@ import ( "time" "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/owntouch" ) // Pulling many small files, many to a request. @@ -271,6 +272,7 @@ func writeBatchedFile(j batchJob, blocks []BlockData) error { if j.remote.MtimeMs > 0 { mtime := time.UnixMilli(int64(j.remote.MtimeMs)) _ = os.Chtimes(j.localPath, mtime, mtime) + owntouch.Settled(j.localPath) } return nil } @@ -289,6 +291,7 @@ func (e *Engine) pullBatchOneByOne(ctx context.Context, peer Peer, gameID, root if j.remote.MtimeMs > 0 { mtime := time.UnixMilli(int64(j.remote.MtimeMs)) _ = os.Chtimes(j.localPath, mtime, mtime) + owntouch.Settled(j.localPath) } written = append(written, j.relPath) } diff --git a/internal/p2p/syncengine/conflict.go b/internal/p2p/syncengine/conflict.go index 3020fe85..742c35f8 100644 --- a/internal/p2p/syncengine/conflict.go +++ b/internal/p2p/syncengine/conflict.go @@ -238,6 +238,7 @@ func (e *Engine) touchSaveMtimes(gameID, root string) { if !info.IsDir() { owntouch.Mark(root) _ = os.Chtimes(root, now, now) + owntouch.Settled(root) return } _ = filepath.Walk(root, func(path string, fi os.FileInfo, walkErr error) error { @@ -246,6 +247,7 @@ func (e *Engine) touchSaveMtimes(gameID, root string) { } owntouch.Mark(path) _ = os.Chtimes(path, now, now) + owntouch.Settled(path) return nil }) } diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 9664ba3f..4d601a32 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -1816,6 +1816,7 @@ func (e *Engine) pullFiles(ctx context.Context, peer Peer, gameID string, game s if remoteFile.MtimeMs > 0 { mtime := time.UnixMilli(int64(remoteFile.MtimeMs)) _ = os.Chtimes(localFilePath, mtime, mtime) + owntouch.Settled(localFilePath) } pulled = append(pulled, relPath) e.Log("info", "file updated: "+relPath) From 825a7a213ac9b894b7763de0e2ccaf8fc4a40f9f Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:20:02 +0200 Subject: [PATCH 56/75] A save made straight after a pull is a new version, not the pull unfinished Taking a peer's version checks, once its files are written, that the save is now exactly the peer's; if not, the pull counts as stopped part-way and is resumed on the next sync. A game saving in that moment - after the pull wrote its file, before the check - made the save differ, and the resumed pull then replaced the new save with the peer's. Only the snapshot taken before replacing it still held it, and both devices called themselves in sync. (CI: TestSwitch_TheSameGameUnderDifferentIDsSyncs, about one run in fifteen locally.) When every difference is a file the pull wrote or removed and someone other than OpenSave has written it since (owntouch), the pull did finish: the device takes the peer's version, records the change as a version of its own made from it, and asks the peer to take that. Anything else is still a pull stopped part-way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/syncengine/changedsince_test.go | 59 +++++++++++++++++++ internal/p2p/syncengine/version.go | 62 +++++++++++++++++++- 2 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 internal/p2p/syncengine/changedsince_test.go diff --git a/internal/p2p/syncengine/changedsince_test.go b/internal/p2p/syncengine/changedsince_test.go new file mode 100644 index 00000000..974cb7a4 --- /dev/null +++ b/internal/p2p/syncengine/changedsince_test.go @@ -0,0 +1,59 @@ +package syncengine + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/owntouch" +) + +// A pull that finished and whose file the game then saved over is not a +// pull that stopped part-way: resuming it would replace the new save with +// the peer's. +func TestChangedSincePulled(t *testing.T) { + dir := t.TempDir() + pulled := filepath.Join(dir, "progress.sav") + pull := func(content string) { + owntouch.Mark(pulled) + if err := os.WriteFile(pulled, []byte(content), 0o666); err != nil { + t.Fatal(err) + } + owntouch.Settled(pulled) + } + build := func() delta.Manifest { + m, err := delta.BuildManifest(dir) + if err != nil { + t.Fatal(err) + } + return m + } + d := Decision{FilesToPull: []string{"progress.sav"}} + + pull("from A") + remote := build() + + // The game saves straight after the pull. + time.Sleep(20 * time.Millisecond) + if err := os.WriteFile(pulled, []byte("from B"), 0o666); err != nil { + t.Fatal(err) + } + delta.InvalidateRoot(dir) + if !changedSincePulled(dir, build(), remote, d) { + t.Error("a save written over a finished pull was taken for the pull stopping part-way") + } + + // The pull wrote an old copy and stopped: still as OpenSave left it. + pull("old") + delta.InvalidateRoot(dir) + if changedSincePulled(dir, build(), remote, d) { + t.Error("a pull that stopped part-way was taken for a change made here") + } + + // A file the pull never touched differs: not this pull's to judge. + if changedSincePulled(dir, build(), remote, Decision{}) { + t.Error("a difference in a file the pull did not touch was taken for a change after it") + } +} diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index 3655fac1..153ade3e 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -14,6 +14,7 @@ import ( "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/ignore" + "github.com/opensave/opensave/internal/owntouch" "github.com/opensave/opensave/internal/store" ) @@ -848,8 +849,12 @@ func (e *Engine) pullVersion(ctx context.Context, game store.Game, peer Peer, } freshHash := e.versionHashOf(gameID, fresh) fresh = filterManifest(fresh, e.rulesFor(gameID)) + changedSince := false if !sameFiles(fresh, remote.Manifest) { - return Result{}, true, fmt.Errorf("taking %s's version of %q did not finish: the save still differs; it is resumed on the next sync", peer.Name, game.Name) + if !changedSincePulled(primaryRootOf(game).Path, fresh, remote.Manifest, d) { + return Result{}, true, fmt.Errorf("taking %s's version of %q did not finish: the save still differs; it is resumed on the next sync", peer.Name, game.Name) + } + changedSince = true } e.versionMu.Lock() @@ -861,10 +866,19 @@ func (e *Engine) pullVersion(ctx context.Context, game store.Game, peer Peer, gv.adopt(adopt, theirs) gv.rec.Hash = freshHash gv.rec.Pulling = false - _ = e.saveVersionLocked(gv) e.Log("success", fmt.Sprintf("%q now holds version %s from %s", game.Name, gv.vec, peer.Name)) + if changedSince { + // The peer's version arrived whole, and was then changed here - + // the game saving straight after the pull. That is a version of + // this device's own, made from theirs, and goes back to them. + e.bumpLocked(&gv, game.Name, freshHash) + } + _ = e.saveVersionLocked(gv) } e.versionMu.Unlock() + if changedSince { + e.Transport.TriggerPeerPull(peer, gameID) + } // The file comparison's own records, so it agrees if it is ever asked. e.persistLineage(gameID, peer.ID, fresh, remote.Manifest) @@ -1233,3 +1247,47 @@ func (e *Engine) UseSaveEverywhere(gameID string) (VersionVector, error) { e.Log("info", fmt.Sprintf("%q: this device's save is to be used everywhere — version %s", game.Name, vec)) return vec, nil } + +// changedSincePulled reports a pull that finished and whose save was then +// changed here: every way the save now differs from the peer's is a file the +// pull wrote or removed, and someone other than OpenSave has written it, or +// put it back, since (owntouch). A game saving the moment a pull is done +// leaves exactly that. +// +// Anything else - a file the pull did not touch, or one it touched that is +// still as OpenSave left it - is a pull that stopped part-way, resumed on the +// next sync as before. Without this, the save the game had just written was +// taken for the unfinished part of the pull and replaced with the peer's on +// the next sync; only the snapshot taken before replacing it still held it. +func changedSincePulled(root string, fresh, remote delta.Manifest, d Decision) bool { + touched := make(map[string]bool, len(d.FilesToPull)+len(d.FilesToDeleteLocally)) + for _, p := range d.FilesToPull { + touched[p] = true + } + for _, p := range d.FilesToDeleteLocally { + touched[p] = true + } + changedByOthers := func(p string) bool { + return touched[p] && !owntouch.Recent(delta.LocalNameFor(root, p)) + } + differs := 0 + for p, lf := range fresh.Files { + if rf, ok := remote.Files[p]; ok && rf.Hash == lf.Hash { + continue + } + differs++ + if !changedByOthers(p) { + return false + } + } + for p := range remote.Files { + if _, ok := fresh.Files[p]; ok { + continue + } + differs++ + if !changedByOthers(p) { + return false + } + } + return differs > 0 +} From fd93691276f212f6240cb530a4fae8f5b26e4bbc Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:42:38 +0200 Subject: [PATCH 57/75] A pulled file is marked again before it is re-dated to the peer's time A pull renames a file into place (Settled), then re-dates it to the peer's time and settles it again. A watcher event looked at in between compared the file's new time with the one the rename had left, and took OpenSave's own pull for someone else's change: an automatic snapshot, a sync sent back out, and on a save without versions a file the other device had just deleted pulled back (CI: TestWatchAutoSyncChain). Marking the file again before it is re-dated puts it back on the plain time comparison until it settles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/owntouch/owntouch_test.go | 23 +++++++++++++++++++++++ internal/p2p/syncengine/batch.go | 2 ++ internal/p2p/syncengine/engine.go | 3 +++ 3 files changed, 28 insertions(+) diff --git a/internal/owntouch/owntouch_test.go b/internal/owntouch/owntouch_test.go index aaa0001b..824f04a4 100644 --- a/internal/owntouch/owntouch_test.go +++ b/internal/owntouch/owntouch_test.go @@ -107,3 +107,26 @@ func TestRecent_AWriteRightAfterASettledPullIsNotOurs(t *testing.T) { t.Error("a save written straight after the pull was taken for OpenSave's") } } + +// A pull renames a file into place, then re-dates it to the peer's time. In +// between, and before it is Settled again, the file is still OpenSave's. +func TestRecent_ARedatedPullBeforeItSettlesAgainIsOurs(t *testing.T) { + file := filepath.Join(t.TempDir(), "auto.sav") + Mark(file) + if err := os.WriteFile(file, []byte("pulled"), 0o666); err != nil { + t.Fatal(err) + } + Settled(file) + Mark(file) + peers := time.Now().Add(-time.Hour) + if err := os.Chtimes(file, peers, peers); err != nil { + t.Fatal(err) + } + if !Recent(file) { + t.Error("a pulled file re-dated to the peer's time was taken for someone else's change") + } + Settled(file) + if !Recent(file) { + t.Error("a pulled file settled at the peer's time was taken for someone else's change") + } +} diff --git a/internal/p2p/syncengine/batch.go b/internal/p2p/syncengine/batch.go index 0799c625..c1b1ff23 100644 --- a/internal/p2p/syncengine/batch.go +++ b/internal/p2p/syncengine/batch.go @@ -271,6 +271,7 @@ func writeBatchedFile(j batchJob, blocks []BlockData) error { committed = true if j.remote.MtimeMs > 0 { mtime := time.UnixMilli(int64(j.remote.MtimeMs)) + owntouch.Mark(j.localPath) // see pullFiles: re-dated, then Settled _ = os.Chtimes(j.localPath, mtime, mtime) owntouch.Settled(j.localPath) } @@ -290,6 +291,7 @@ func (e *Engine) pullBatchOneByOne(ctx context.Context, peer Peer, gameID, root } if j.remote.MtimeMs > 0 { mtime := time.UnixMilli(int64(j.remote.MtimeMs)) + owntouch.Mark(j.localPath) _ = os.Chtimes(j.localPath, mtime, mtime) owntouch.Settled(j.localPath) } diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 4d601a32..c94a710e 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -1815,6 +1815,9 @@ func (e *Engine) pullFiles(ctx context.Context, peer Peer, gameID string, game s } if remoteFile.MtimeMs > 0 { mtime := time.UnixMilli(int64(remoteFile.MtimeMs)) + // Marked again first: until Settled below, the time the rename left + // is not the one to compare (owntouch). + owntouch.Mark(localFilePath) _ = os.Chtimes(localFilePath, mtime, mtime) owntouch.Settled(localFilePath) } From 4bf604e81c5699cff29afb364647ea5c18a6d4ae Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:07:37 +0200 Subject: [PATCH 58/75] iopar: the drive-adaptation test gets three runs Its simulated drive is timed with sub-millisecond sleeps; on a slow CPU busy with the rest of the test run, one run's windows looked alike and it settled far off (seen: SATA at 2 of 12). One of three runs has to settle near the best now; a broken adaptation misses in all three. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/iopar/run_test.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/iopar/run_test.go b/internal/iopar/run_test.go index a0f8e21e..48f3b74d 100644 --- a/internal/iopar/run_test.go +++ b/internal/iopar/run_test.go @@ -26,17 +26,25 @@ func simulated(best int) func(i int) error { } } +// The simulated drive is timed with sub-millisecond sleeps, so a machine busy +// with other work (a full test run on a slow CPU) can make one run's windows +// look alike and leave it far off. Each kind gets three runs and one has to +// settle near the best; an adaptation that is actually broken misses in all. func TestRun_SettlesNearWhatTheDriveDoesBest(t *testing.T) { defer func(w time.Duration) { window = w }(window) window = 60 * time.Millisecond for _, kind := range []Kind{NVMe, SATA} { - st, err := RunKind(context.Background(), kind, 40000, simulated(12), nil) - if err != nil { - t.Fatal(err) + near := false + for attempt := 1; attempt <= 3 && !near; attempt++ { + st, err := RunKind(context.Background(), kind, 40000, simulated(12), nil) + if err != nil { + t.Fatal(err) + } + t.Logf("%s, run %d: started at %d, settled at %d, peak %.0f/s in %v", kind, attempt, st.Start, st.Settled, st.Peak, st.Duration) + near = st.Settled >= 5 && st.Settled <= 28 } - t.Logf("%s: started at %d, settled at %d, peak %.0f/s in %v", kind, st.Start, st.Settled, st.Peak, st.Duration) - if st.Settled < 5 || st.Settled > 28 { - t.Errorf("%s: settled at %d, far from the 12 the drive does best", kind, st.Settled) + if !near { + t.Errorf("%s: no run settled near the 12 the drive does best", kind) } } } From f9f8d517a9ae2451b97e2f4ed47433ce52a35cc1 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:30:12 +0200 Subject: [PATCH 59/75] release: fork builds are named and described as unofficial test builds On ChakraFusion/OpenSave only: a release made from this fork says plainly that it is not a Liquid-co release, what it contains and what was tested, instead of upstream's text and Discord invite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/release.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f9ced8f..43704489 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -562,7 +562,9 @@ jobs: - name: Publish release uses: softprops/action-gh-release@v2 with: - name: OpenSave ${{ github.ref_name }} + # Fork test builds (ChakraFusion/OpenSave): named and described as + # unofficial, so nobody takes one for a Liquid-co release. + name: Unofficial fork test build ${{ github.ref_name }} # Hyphenated tags (v2.1.0-beta.1) publish as pre-releases, so # /releases/latest ignores them. Only devices on the beta channel # are offered these: either the user ticked Settings -> Updates, or @@ -570,19 +572,20 @@ jobs: prerelease: ${{ contains(github.ref_name, '-') }} # Note: re-publishing the same tag appends another generated-notes # block; if a tag is ever re-cut, tidy the release body by hand. - generate_release_notes: true - # `body` is placed above the generated notes, so the invitation is - # the first thing in every release rather than buried under a - # commit list. + generate_release_notes: false body: | - ### 💬 Questions, bug reports, or just want to say hello? + > [!WARNING] + > **Unofficial test build** from the fork [ChakraFusion/OpenSave](https://github.com/ChakraFusion/OpenSave), branch `fixes-2.4.1`. It is **not** a release of [Liquid-co/OpenSave](https://github.com/Liquid-co/OpenSave). Please don't report problems with it to the OpenSave project or its Discord. - **[Join the OpenSave Discord →](https://discord.gg/hvBv92DZvn)** + OpenSave 2.4.1 with the fixes and features proposed upstream: data-safety fixes in sync, save versions, play sessions, per-device settings, snapshot integrity, network-aware sync, and faster sync and restore of large saves. The full list, with one pull request per topic, is in the overview issue on Liquid-co/OpenSave. - It is the fastest way to get help, and where new builds get - discussed before they ship. + **Tested:** Windows, on four PCs over LAN and VPN. Linux, macOS, Flatpak and the Decky plugin are built by the same workflow and pass the unit and end-to-end tests on Linux, but haven't been tried by hand. - --- + **Updates:** the built-in update check still points at Liquid-co/OpenSave. This build calls itself `2.4.1-fixes.1`, a pre-release, so an official release can be offered as an update, which returns you to the official build. + + **Before trying it:** OpenSave keeps a snapshot before replacing any save, but back up saves you care about all the same. Devices on this build and on official 2.4.1 can be paired; nothing is taken from a device without save versions, and its deletions are refused. + + Changes since v2.4.1: https://github.com/ChakraFusion/OpenSave/compare/v2.4.1...fixes-2.4.1 fail_on_unmatched_files: false files: | windows/**/*.exe From fd44b1ebc63f61706907ebbbea0d92e1e0ba98c2 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:59:27 +0200 Subject: [PATCH 60/75] A file deleted or changed right after a pull, untouched by it, is a change here The check after a pull counted only files the pull had written as possibly changed since. A file the pull did not touch - the same on both sides when the pull was planned - deleted here the moment the pull was done made the save differ from the peer's, the pull counted as stopped part-way, and the resumed pull fetched the deleted file back (CI: TestFullSyncFlow, Windows). A file the pull did not touch that differs now was changed here meanwhile: it counts like a file the pull wrote that someone else has written since. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/syncengine/changedsince_test.go | 40 ++++++++++++++++++-- internal/p2p/syncengine/version.go | 25 +++++++----- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/internal/p2p/syncengine/changedsince_test.go b/internal/p2p/syncengine/changedsince_test.go index 974cb7a4..59c57c10 100644 --- a/internal/p2p/syncengine/changedsince_test.go +++ b/internal/p2p/syncengine/changedsince_test.go @@ -52,8 +52,42 @@ func TestChangedSincePulled(t *testing.T) { t.Error("a pull that stopped part-way was taken for a change made here") } - // A file the pull never touched differs: not this pull's to judge. - if changedSincePulled(dir, build(), remote, Decision{}) { - t.Error("a difference in a file the pull did not touch was taken for a change after it") + // A file the pull did not touch was the same on both sides when it was + // planned; differing now, it was changed here meanwhile. + if !changedSincePulled(dir, build(), remote, Decision{}) { + t.Error("a file the pull did not touch, changed since, was taken for the pull stopping part-way") + } +} + +// A file the pull did not touch, deleted here the moment the pull is done +// (TestFullSyncFlow on CI): a deletion made here, not a part of the pull to +// fetch again. +func TestChangedSincePulled_AnUntouchedFileDeleted(t *testing.T) { + dir := t.TempDir() + for name, content := range map[string]string{"slot1.sav": "from B", "config/video.ini": "fullscreen=1"} { + full := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(full), 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o666); err != nil { + t.Fatal(err) + } + } + owntouch.Mark(filepath.Join(dir, "slot1.sav")) + owntouch.Settled(filepath.Join(dir, "slot1.sav")) + remote, err := delta.BuildManifest(dir) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(dir, "config", "video.ini")); err != nil { + t.Fatal(err) + } + delta.InvalidateRoot(dir) + fresh, err := delta.BuildManifest(dir) + if err != nil { + t.Fatal(err) + } + if !changedSincePulled(dir, fresh, remote, Decision{FilesToPull: []string{"slot1.sav"}}) { + t.Error("a file deleted here right after the pull was taken for the pull stopping part-way, and would be fetched back") } } diff --git a/internal/p2p/syncengine/version.go b/internal/p2p/syncengine/version.go index 153ade3e..3ae5855c 100644 --- a/internal/p2p/syncengine/version.go +++ b/internal/p2p/syncengine/version.go @@ -1249,16 +1249,21 @@ func (e *Engine) UseSaveEverywhere(gameID string) (VersionVector, error) { } // changedSincePulled reports a pull that finished and whose save was then -// changed here: every way the save now differs from the peer's is a file the -// pull wrote or removed, and someone other than OpenSave has written it, or -// put it back, since (owntouch). A game saving the moment a pull is done -// leaves exactly that. +// changed here: every way the save now differs from the peer's is a change +// someone other than OpenSave made since the pull was planned. A game saving, +// or a person deleting a file, the moment a pull is done leaves exactly that. // -// Anything else - a file the pull did not touch, or one it touched that is -// still as OpenSave left it - is a pull that stopped part-way, resumed on the -// next sync as before. Without this, the save the game had just written was -// taken for the unfinished part of the pull and replaced with the peer's on -// the next sync; only the snapshot taken before replacing it still held it. +// - A file the pull did not touch was the same on both sides when the pull +// was planned (anything that differed was to be fetched or removed). If +// it differs now, it was changed here meanwhile. +// - A file the pull wrote or removed differs because of someone else only +// if someone other than OpenSave has written it, or put it back, since +// (owntouch). Still as OpenSave left it, it is the pull's own unfinished +// part: a pull that stopped part-way, resumed on the next sync as before. +// +// Without this, a save the game had just written, or a file just deleted, +// was taken for the unfinished part of the pull and replaced with the peer's +// on the next sync; only the snapshot taken before replacing it still held it. func changedSincePulled(root string, fresh, remote delta.Manifest, d Decision) bool { touched := make(map[string]bool, len(d.FilesToPull)+len(d.FilesToDeleteLocally)) for _, p := range d.FilesToPull { @@ -1268,7 +1273,7 @@ func changedSincePulled(root string, fresh, remote delta.Manifest, d Decision) b touched[p] = true } changedByOthers := func(p string) bool { - return touched[p] && !owntouch.Recent(delta.LocalNameFor(root, p)) + return !touched[p] || !owntouch.Recent(delta.LocalNameFor(root, p)) } differs := 0 for p, lf := range fresh.Files { From c17cff074b0dafaea8bfade8ce863332009f564d Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:06:01 +0200 Subject: [PATCH 61/75] release: the fork build's text names its own version Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43704489..8cd18e02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -581,7 +581,7 @@ jobs: **Tested:** Windows, on four PCs over LAN and VPN. Linux, macOS, Flatpak and the Decky plugin are built by the same workflow and pass the unit and end-to-end tests on Linux, but haven't been tried by hand. - **Updates:** the built-in update check still points at Liquid-co/OpenSave. This build calls itself `2.4.1-fixes.1`, a pre-release, so an official release can be offered as an update, which returns you to the official build. + **Updates:** the built-in update check still points at Liquid-co/OpenSave. This build calls itself `${{ github.ref_name }}`, a pre-release, so an official release can be offered as an update, which returns you to the official build. **Before trying it:** OpenSave keeps a snapshot before replacing any save, but back up saves you care about all the same. Devices on this build and on official 2.4.1 can be paired; nothing is taken from a device without save versions, and its deletions are refused. From cd8f59c58f1088ee70502b8a9f27c393f37a1556 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:54:40 +0200 Subject: [PATCH 62/75] snapshots: an identical newer copy keeps the name of the one it replaces A session ended and its "After playing" snapshot was taken; the game's last write, settling in the watcher a moment later, made an "Auto backup" of the same save, and duplicate pruning then removed the older one - the session's name with it (CI: TestSession_EndingOneKeepsTheSaveAndSyncsIt, Windows). When a newer snapshot holding exactly the same files takes an older one's place, it takes over the older one's reason if it has only the default "Auto backup", and its note if it has none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/snapshot/content.go | 25 +++++++++++++++++++++ internal/snapshot/content_test.go | 36 +++++++++++++++++++++++++++++++ internal/snapshot/snapshot.go | 2 +- 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/internal/snapshot/content.go b/internal/snapshot/content.go index f3e7be31..ccb7fb73 100644 --- a/internal/snapshot/content.go +++ b/internal/snapshot/content.go @@ -208,6 +208,7 @@ func (m *Manager) pruneContainedPass(gameID string) (removed int, freed int64, e // while the row is there it still resolves to itself. _ = m.Store.RepointSnapshotAliases(older.ID, holder.ID) _ = m.Store.AddSnapshotAlias(older.ID, holder.ID) + m.carryName(older, &holder) } f, derr := m.DeleteSnapshot(gameID, older.ID) if derr != nil { @@ -266,6 +267,7 @@ func (m *Manager) PruneContainedBy(snap store.Snapshot, files []store.CapturedFi // Aliased before the delete (see PruneContained). _ = m.Store.RepointSnapshotAliases(older.ID, snap.ID) _ = m.Store.AddSnapshotAlias(older.ID, snap.ID) + m.carryName(older, &snap) } _, derr := m.DeleteSnapshot(snap.GameID, older.ID) if derr == nil && m.Log != nil { @@ -370,3 +372,26 @@ func (m *Manager) MergeDuplicates(gameID string) (merged int, freed int64, err e } return merged, freed, nil } + +// defaultAutoComment is what an automatic snapshot taken for no particular +// reason is called. +const defaultAutoComment = "Auto backup" + +// carryName keeps what an older snapshot is called when a newer one holding +// exactly the same files takes its place: its reason ("After playing (1 h)", +// "Put back") if the newer one has only the default an automatic snapshot +// gets, and a note someone wrote if the newer one has none. A session's +// named snapshot and the watcher's copy of the same save, taken a moment +// later as the game's last write settled, otherwise left only "Auto backup". +func (m *Manager) carryName(older store.Snapshot, newer *store.Snapshot) { + if newer.Comment == defaultAutoComment && older.Comment != "" && older.Comment != defaultAutoComment { + if m.Store.SetSnapshotComment(newer.ID, older.Comment) == nil { + newer.Comment = older.Comment + } + } + if newer.Note == "" && older.Note != "" { + if m.Store.SetSnapshotNote(newer.ID, older.Note) == nil { + newer.Note = older.Note + } + } +} diff --git a/internal/snapshot/content_test.go b/internal/snapshot/content_test.go index f8b2fc1f..ab4db8ee 100644 --- a/internal/snapshot/content_test.go +++ b/internal/snapshot/content_test.go @@ -221,3 +221,39 @@ func TestMergeDuplicates(t *testing.T) { t.Errorf("a second pass merged %d more", again) } } + +// A session's named snapshot, then the watcher's copy of the same save a +// moment later: the copy takes its place and keeps its name. +func TestPruneContainedBy_AnIdenticalCopyKeepsTheName(t *testing.T) { + env := setup(t) + env.mgr.PruneOnCreate = true + writeSave(t, env.saveDir, "slot1.sav", "after an evening") + named, err := env.mgr.Create("game1", "After playing (1 h)", true) + if err != nil { + t.Fatal(err) + } + if err := env.store.SetSnapshotNote(named.ID, "boss beaten"); err != nil { + t.Fatal(err) + } + copied, err := env.mgr.Create("game1", "", true) + if err != nil { + t.Fatal(err) + } + if copied.ID == named.ID { + t.Skip("the copy was not taken: nothing to replace") + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := env.store.GetSnapshot(named.ID); err != nil { + break + } + time.Sleep(50 * time.Millisecond) + } + kept, err := env.store.GetSnapshot(copied.ID) + if err != nil { + t.Fatal(err) + } + if kept.Comment != "After playing (1 h)" || kept.Note != "boss beaten" { + t.Errorf("the snapshot kept is called %q (note %q), want the name and note of the one it replaced", kept.Comment, kept.Note) + } +} diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index ae7dd171..f9ac955e 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -297,7 +297,7 @@ func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSys if comment == "" { if isSystemAuto { - comment = "Auto backup" + comment = defaultAutoComment } else { comment = "Manual snapshot" } From cbc54e9ec8c81c135251228fc6725b3bc17a9d0e Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:23:44 +0200 Subject: [PATCH 63/75] store: upstream's migration numbers, and a fork database renamed to them The migrations this fork added are numbered as in the upstream pull requests (0039_game_versions .. 0043_settings_files, after upstream's 0037_game_root_mapped and 0038_peer_links). A database made by an earlier build of the fork recorded them under the old numbers; at open, those are renamed once, in one transaction, so nothing is applied a second time and a later official build containing them finds them applied. Tried on a copy of a real database from one of the four PCs: 94 games, opened normally, names renamed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/store/db.go | 38 ++++++++++++++++ internal/store/fork_migrations_test.go | 45 +++++++++++++++++++ ...me_versions.sql => 0039_game_versions.sql} | 0 ..._content.sql => 0040_snapshot_content.sql} | 0 .../{0040_marks.sql => 0041_marks.sql} | 0 ...ings.sql => 0042_sync_device_settings.sql} | 0 ...ings_files.sql => 0043_settings_files.sql} | 0 7 files changed, 83 insertions(+) create mode 100644 internal/store/fork_migrations_test.go rename internal/store/migrations/{0037_game_versions.sql => 0039_game_versions.sql} (100%) rename internal/store/migrations/{0039_snapshot_content.sql => 0040_snapshot_content.sql} (100%) rename internal/store/migrations/{0040_marks.sql => 0041_marks.sql} (100%) rename internal/store/migrations/{0041_sync_device_settings.sql => 0042_sync_device_settings.sql} (100%) rename internal/store/migrations/{0042_settings_files.sql => 0043_settings_files.sql} (100%) diff --git a/internal/store/db.go b/internal/store/db.go index 44e17004..d4aa4c71 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -91,6 +91,10 @@ func (s *Store) migrate() error { return fmt.Errorf("create schema_migrations table: %w", err) } + if err := s.renameForkMigrations(); err != nil { + return err + } + entries, err := fs.ReadDir(migrationsFS, "migrations") if err != nil { return fmt.Errorf("read embedded migrations: %w", err) @@ -135,3 +139,37 @@ func (s *Store) migrate() error { } return nil } + +// forkMigrationNames maps the names earlier builds of this fork gave its +// migrations to the names they have upstream (Liquid-co/OpenSave PRs #27, +// #30, #31, #32, #33), where they follow 0037_game_root_mapped. The files +// carry the upstream names now; a database these builds created recorded +// them under the old ones. +var forkMigrationNames = map[string]string{ + "0037_game_versions.sql": "0039_game_versions.sql", + "0039_snapshot_content.sql": "0040_snapshot_content.sql", + "0040_marks.sql": "0041_marks.sql", + "0041_sync_device_settings.sql": "0042_sync_device_settings.sql", + "0042_settings_files.sql": "0043_settings_files.sql", +} + +// renameForkMigrations records migrations applied under the fork's old names +// under their upstream names, once, so they are not applied a second time - +// creating a table or adding a column that is already there fails, and the +// database would not open. In one transaction, so a database is renamed +// whole or not at all. +func (s *Store) renameForkMigrations() error { + tx, err := s.db.Beginx() + if err != nil { + return fmt.Errorf("begin renaming migrations: %w", err) + } + defer tx.Rollback() + for old, renamed := range forkMigrationNames { + if _, err := tx.Exec(`UPDATE schema_migrations SET name = ? WHERE name = ? + AND NOT EXISTS (SELECT 1 FROM schema_migrations WHERE name = ?)`, + renamed, old, renamed); err != nil { + return fmt.Errorf("rename migration %s: %w", old, err) + } + } + return tx.Commit() +} diff --git a/internal/store/fork_migrations_test.go b/internal/store/fork_migrations_test.go new file mode 100644 index 00000000..57ccab9a --- /dev/null +++ b/internal/store/fork_migrations_test.go @@ -0,0 +1,45 @@ +package store + +import ( + "path/filepath" + "testing" +) + +// A database made by an earlier build of the fork recorded its migrations +// under the fork's old names. Opened by this build, it is renamed, not +// migrated a second time - which would fail, tables and columns being there +// already - and its data stays. +func TestOpen_RenamesTheForksOldMigrationNames(t *testing.T) { + path := filepath.Join(t.TempDir(), "opensave.db") + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + if err := s.CreateGame(Game{ID: "g", Name: "g", SavePath: t.TempDir(), ActiveBranch: "main"}); err != nil { + t.Fatal(err) + } + for old, renamed := range forkMigrationNames { + if _, err := s.db.Exec(`UPDATE schema_migrations SET name = ? WHERE name = ?`, old, renamed); err != nil { + t.Fatal(err) + } + } + s.Close() + + s, err = Open(path) + if err != nil { + t.Fatalf("a database from an earlier fork build no longer opens: %v", err) + } + defer s.Close() + for old, renamed := range forkMigrationNames { + var n int + if err := s.db.Get(&n, `SELECT COUNT(*) FROM schema_migrations WHERE name = ?`, renamed); err != nil || n != 1 { + t.Errorf("%s recorded %d times, want once (%v)", renamed, n, err) + } + if err := s.db.Get(&n, `SELECT COUNT(*) FROM schema_migrations WHERE name = ?`, old); err != nil || n != 0 { + t.Errorf("the old name %s is still recorded", old) + } + } + if _, err := s.GetGame("g"); err != nil { + t.Errorf("the game is gone after renaming: %v", err) + } +} diff --git a/internal/store/migrations/0037_game_versions.sql b/internal/store/migrations/0039_game_versions.sql similarity index 100% rename from internal/store/migrations/0037_game_versions.sql rename to internal/store/migrations/0039_game_versions.sql diff --git a/internal/store/migrations/0039_snapshot_content.sql b/internal/store/migrations/0040_snapshot_content.sql similarity index 100% rename from internal/store/migrations/0039_snapshot_content.sql rename to internal/store/migrations/0040_snapshot_content.sql diff --git a/internal/store/migrations/0040_marks.sql b/internal/store/migrations/0041_marks.sql similarity index 100% rename from internal/store/migrations/0040_marks.sql rename to internal/store/migrations/0041_marks.sql diff --git a/internal/store/migrations/0041_sync_device_settings.sql b/internal/store/migrations/0042_sync_device_settings.sql similarity index 100% rename from internal/store/migrations/0041_sync_device_settings.sql rename to internal/store/migrations/0042_sync_device_settings.sql diff --git a/internal/store/migrations/0042_settings_files.sql b/internal/store/migrations/0043_settings_files.sql similarity index 100% rename from internal/store/migrations/0042_settings_files.sql rename to internal/store/migrations/0043_settings_files.sql From cb06b664ebecaf21c475efe897f80e975e9b30ca Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:13:46 +0200 Subject: [PATCH 64/75] sync: a deletion in an extra location survives a lost sync-complete report The report that a peer took a location is also what records which files both sides hold there. When it was lost - it times out on a loaded machine ("could not tell ... that ... is sync-complete: context deadline exceeded") - a file deleted here afterwards read as one the peer had and this device never did, and the next sync fetched it back: the deletion undone. Seen on CI as TestMultiRootScenario_DeletionInAnExtraLocationPropagates failing once on Windows (Liquid-co/OpenSave#23); reproduced deterministically by TestMultiRootScenario_ADeletionSurvivesALostReport. - The existing repair - a push is proven once the peer holds exactly what was handed over - now runs before anything is classified, and restores the lineage too: both held exactly the pushed state, so its files are what was shared. - Extra locations use the deletions this device recorded, as the main folder does (ComputeWithDeletions): a recorded deletion does not depend on the lineage, and is acted on only while the peer's copy is still exactly what was deleted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/multiroot_scenarios_test.go | 49 ++++++++++++++++++++++++++ internal/p2p/syncengine/multiroot.go | 51 ++++++++++++++++++++-------- 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/e2e/multiroot_scenarios_test.go b/e2e/multiroot_scenarios_test.go index 3ab77668..e255b6b3 100644 --- a/e2e/multiroot_scenarios_test.go +++ b/e2e/multiroot_scenarios_test.go @@ -935,3 +935,52 @@ func TestIgnore_ExcludedFilesInExtraLocationsAreStillSnapshotted(t *testing.T) { "rollback empties the folder first, so anything missing from the snapshot is destroyed", got) } } + +// The same deletion when the peer's report that it took the location never +// arrived: on a loaded machine it can time out ("could not tell ... that ... +// is sync-complete: context deadline exceeded"), and then this device has no +// record that the peer holds those files. It must still not take the deleted +// file back from the peer as one it never had. +func TestMultiRootScenario_ADeletionSurvivesALostReport(t *testing.T) { + a, b, gameID, aConfig, bConfig := twoLocationPair(t, "LocLostReport", map[string]string{ + "settings.ini": "fullscreen=1", + "keys.cfg": "jump=space", + }) + pauseAutoSync(t, a, b, gameID) + + // Where A stands when the report is lost: it handed the location over and + // recorded that, and nothing since says the peer took it. + pushed, err := delta.BuildManifest(aConfig) + if err != nil { + t.Fatal(err) + } + store := a.Daemon.Store + if err := store.SetSyncStateForRoot(gameID, b.NodeID(), "config", nil, nil); err != nil { + t.Fatal(err) + } + if err := store.SetAgreedHashForRoot(gameID, b.NodeID(), "config", ""); err != nil { + t.Fatal(err) + } + if err := store.SetPushedHashForRoot(gameID, b.NodeID(), "config", pushed.RootHash(delta.PrimaryRoot)); err != nil { + t.Fatal(err) + } + + if err := os.Remove(filepath.Join(aConfig, "keys.cfg")); err != nil { + t.Fatal(err) + } + time.Sleep(syncSettleWindow) + a.API(http.MethodPost, "/api/games/"+gameID+"/sync", nil, nil) + + if !testutil.WaitFor(45*time.Second, func() bool { + return readIn(bConfig, "keys.cfg") == "" + }) { + t.Fatalf("the deletion never reached the peer: keys.cfg there=%q, here=%q", + readIn(bConfig, "keys.cfg"), readIn(aConfig, "keys.cfg")) + } + if got := readIn(aConfig, "keys.cfg"); got != "" { + t.Errorf("the deleted file came back here: %q", got) + } + if got := readIn(bConfig, "settings.ini"); got != "fullscreen=1" { + t.Errorf("an untouched file on the peer changed: %q", got) + } +} diff --git a/internal/p2p/syncengine/multiroot.go b/internal/p2p/syncengine/multiroot.go index 544e7e53..6b247a45 100644 --- a/internal/p2p/syncengine/multiroot.go +++ b/internal/p2p/syncengine/multiroot.go @@ -117,6 +117,29 @@ func (e *Engine) syncOneRoot(ctx context.Context, gameID string, game store.Game local = filterManifest(local, rules) remote = filterManifest(remote, rules) + // A push is proven once the peer is seen holding exactly what was handed + // over, whatever became of its report — the repair the main folder makes + // (see SyncWithPeer), and for the same reason. The push was recorded below + // and nothing ever read it, so a lost report left this location's base + // behind both devices, and the next edit on one of them read as both + // having moved: a conflict over a change only one device made. + // + // The lineage is repaired with it, before anything is classified: the + // report is also what records which files both sides hold. Without that, + // a file deleted here after a lost report read as one the peer had and + // this device never did, and it was fetched back — the deletion undone + // (TestMultiRootScenario_ADeletionSurvivesALostReport). Both held exactly + // the pushed state, so its files are the lineage. + // + // Both hashes are of the filtered view, which is what the push recorded. + if pushed := e.Store.GetPushedHashForRoot(gameID, peer.ID, sr.root.Name); pushed != "" { + if remoteHash := remote.RootHash(delta.PrimaryRoot); remoteHash == pushed && + e.Store.GetAgreedHashForRoot(gameID, peer.ID, sr.root.Name) != remoteHash { + e.persistRootLineage(gameID, peer.ID, sr.root.Name, remote, remote) + _ = e.Store.SetAgreedHashForRoot(gameID, peer.ID, sr.root.Name, remoteHash) + } + } + fileList, dirList, err := e.Store.GetSyncStateForRoot(gameID, peer.ID, sr.root.Name) if err != nil { return err @@ -128,7 +151,18 @@ func (e *Engine) syncOneRoot(ctx context.Context, gameID string, game store.Game // written to protect. lineageFiles = filterLineage(lineageFiles, rules) lineageDirs = filterLineage(lineageDirs, rules) - decision := Compute(local, remote, lineageFiles, lineageDirs) + // And the deletions this device recorded in this location, as the main + // folder uses them (see SyncWithPeer): a recorded deletion does not depend + // on the lineage surviving, and is acted on only while the peer's copy is + // still exactly what was deleted. + deleted := e.recordedDeletions(gameID, sr.root.Name) + for path := range deleted { + if excluded(rules, path) { + delete(deleted, path) + } + } + decision := ComputeWithDeletions(local, remote, lineageFiles, lineageDirs, + e.Store.GetAgreedHashForRoot(gameID, peer.ID, sr.root.Name), deleted) if !decision.HasChanges() { e.persistRootLineage(gameID, peer.ID, sr.root.Name, local, remote) @@ -147,21 +181,8 @@ func (e *Engine) syncOneRoot(ctx context.Context, gameID string, game store.Game // point of detecting a conflict is that nobody's work disappears, and a // second detector that is nearly right is worse than none, because it // looks like the work has been done. + // (A push proven by the peer holding it was banked as the base above.) base := e.Store.GetAgreedHashForRoot(gameID, peer.ID, sr.root.Name) - // A push is proven once the peer is seen holding exactly what was handed - // over, whatever became of its report — the repair the main folder makes - // (see SyncWithPeer), and for the same reason. The push was recorded below - // and nothing ever read it, so a lost report left this location's base - // behind both devices, and the next edit on one of them read as both - // having moved: a conflict over a change only one device made. - // - // Both hashes are of the filtered view, which is what the push recorded. - if pushed := e.Store.GetPushedHashForRoot(gameID, peer.ID, sr.root.Name); pushed != "" { - if remoteHash := remote.RootHash(delta.PrimaryRoot); base != remoteHash && remoteHash == pushed { - _ = e.Store.SetAgreedHashForRoot(gameID, peer.ID, sr.root.Name, remoteHash) - base = remoteHash - } - } // A base recorded before the rules existed was hashed over everything, so // it can equal neither filtered side — and a base matching neither reads // as both having moved, which is a conflict on the first sync after anyone From c8ed0aff737204043d4d196fa2753ec751d228f2 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:04:25 +0200 Subject: [PATCH 65/75] sync: an extra location's record keeps deletions in flight, as the main folder's does The refresh a peer's sync-complete report starts runs in the background, and rebuilt the record of which files both sides hold in an extra location from this device's files as they were when it ran. A file deleted here in the moment before - a loaded machine is enough - dropped out of the record, read as one the peer had and this device never did, and the next sync fetched it back. The second way TestMultiRootScenario_DeletionInAnExtraLocationPropagates failed on Windows (Liquid-co/OpenSave#23, and again with the previous commit, without a lost report); reproduced deterministically by TestMultiRootScenario_ADeletionSurvivesALateRefresh. persistRootLineage now does what persistLineage does for the main folder: keeps previously recorded paths that one side still holds (deletions in flight, keepPendingDeletions), and keeps excluded paths out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/multiroot_scenarios_test.go | 50 ++++++++++++++++++++++++++++ internal/p2p/syncengine/multiroot.go | 16 +++++++++ 2 files changed, 66 insertions(+) diff --git a/e2e/multiroot_scenarios_test.go b/e2e/multiroot_scenarios_test.go index e255b6b3..dc76a716 100644 --- a/e2e/multiroot_scenarios_test.go +++ b/e2e/multiroot_scenarios_test.go @@ -984,3 +984,53 @@ func TestMultiRootScenario_ADeletionSurvivesALostReport(t *testing.T) { t.Errorf("an untouched file on the peer changed: %q", got) } } + +// The report that the peer took the location arrives, and the refresh it +// starts in the background runs only after a file has been deleted here — a +// moment's delay on a loaded machine. The refresh must not drop the deleted +// file from the record of what both hold, or the next sync fetches it back. +func TestMultiRootScenario_ADeletionSurvivesALateRefresh(t *testing.T) { + a, b, gameID, aConfig, bConfig := twoLocationPair(t, "LocLateRefresh", map[string]string{ + "settings.ini": "fullscreen=1", + "keys.cfg": "jump=space", + }) + pauseAutoSync(t, a, b, gameID) + if !testutil.WaitFor(45*time.Second, func() bool { + files, _, _ := a.Daemon.Store.GetSyncStateForRoot(gameID, b.NodeID(), "config") + for _, f := range files { + if f == "keys.cfg" { + return true + } + } + return false + }) { + t.Fatal("setup: A never recorded that B holds keys.cfg") + } + + if err := os.Remove(filepath.Join(aConfig, "keys.cfg")); err != nil { + t.Fatal(err) + } + var peer syncengine.Peer + for _, p := range a.Daemon.P2P.OnlinePeers() { + if p.ID == b.NodeID() { + peer = p + } + } + if peer.ID == "" { + t.Fatal("B is not online for A") + } + // The refresh the report started, running late. + a.Daemon.P2P.Sync.RefreshLineage(context.Background(), gameID, peer) + + time.Sleep(syncSettleWindow) + a.API(http.MethodPost, "/api/games/"+gameID+"/sync", nil, nil) + if !testutil.WaitFor(45*time.Second, func() bool { + return readIn(bConfig, "keys.cfg") == "" + }) { + t.Fatalf("the deletion never reached the peer: keys.cfg there=%q, here=%q", + readIn(bConfig, "keys.cfg"), readIn(aConfig, "keys.cfg")) + } + if got := readIn(aConfig, "keys.cfg"); got != "" { + t.Errorf("the deleted file came back here: %q", got) + } +} diff --git a/internal/p2p/syncengine/multiroot.go b/internal/p2p/syncengine/multiroot.go index 6b247a45..1b4127cf 100644 --- a/internal/p2p/syncengine/multiroot.go +++ b/internal/p2p/syncengine/multiroot.go @@ -285,6 +285,22 @@ func (e *Engine) syncOneRoot(ctx context.Context, gameID string, game store.Game func (e *Engine) persistRootLineage(gameID, peerID, root string, local, remote delta.Manifest) { files, dirs := IntersectLineage(local, remote) + // As persistLineage does for the main folder: a path both held before + // that one side holds no longer is a deletion in flight, and its record is + // what lets the next sync send it on rather than fetch the file back. The + // refresh after a peer's report runs in the background, so a file deleted + // here before it ran dropped out of the record — and came back + // (TestMultiRootScenario_ADeletionSurvivesALateRefresh). + if oldFiles, oldDirs, err := e.Store.GetSyncStateForRoot(gameID, peerID, root); err == nil { + files = keepPendingDeletions(files, oldFiles, local.Files, remote.Files) + dirs = keepPendingDirDeletions(dirs, oldDirs, local.Dirs, remote.Dirs) + } + // And excluded paths stay out of it, as in the main folder: the refresh + // rebuilds this from unfiltered manifests. Always filtered, not only with + // rules: launcher files (delta.NeverSynced) are left out the same way. + rules := e.rulesFor(gameID) + files = filterPathList(files, rules) + dirs = filterPathList(dirs, rules) if err := e.Store.SetSyncStateForRoot(gameID, peerID, root, files, dirs); err != nil { e.Log("warn", fmt.Sprintf("persist lineage for the %q location failed: %v", root, err)) } From 851c69e9f5409a7e8a7aee9b5145e4de765ebc92 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:50:25 +0200 Subject: [PATCH 66/75] relay: a reply that never comes fails after its timeout, not the sync's exchange waited for the larger of wanRequestTimeout and the caller's own deadline. Every sync runs under a 30-minute per-peer deadline, so a manifest request or reply the relay dropped (it drops frames for a client that can't keep up, relay/server.go) held the sync for up to half an hour instead of failing it to be retried. It also made FetchBlocks' payload-sized deadline dead code: it applied only to a context without a deadline, which a sync never is. A request now waits wanRequestTimeout, or longer only when its caller asks with withReplyWait: block fetches for their payload as before, manifest requests for ServeSettleWait more, the time a peer may hold its answer while a sync writes the save. The caller's context still ends it early. Found as an e2e shard hitting the 30-minute limit on Windows CI, with TestThreeDevices_PerPeerStateStaysSeparate waiting 7m47s in one manifest fetch over the relay. Also: presence is written when it changes, not on every frame. trackPresence wrote the peer row (a synced database commit) for every frame a paired peer sent, block replies and heartbeats alike, on the one goroutine that reads the relay. A slow disk held that reading up, and the relay drops frames for a client that falls behind. The last-seen time is now written when something changed and otherwise every 5 seconds, as the LAN path does every 10; peers expire after 20. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/transport_wan.go | 14 ++--- internal/p2p/wanclient.go | 42 ++++++++++---- internal/p2p/wanclient_handlers.go | 15 ++++- internal/p2p/wanclient_replywait_test.go | 73 ++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 internal/p2p/wanclient_replywait_test.go diff --git a/internal/p2p/transport_wan.go b/internal/p2p/transport_wan.go index b2b5d90d..e292c673 100644 --- a/internal/p2p/transport_wan.go +++ b/internal/p2p/transport_wan.go @@ -39,6 +39,9 @@ func (t *wanTransport) FetchManifest(ctx context.Context, peer syncengine.Peer, Name: q.Name, SavePath: q.SavePath, IsFile: q.IsFile, AppID: q.AppID, CoverURL: q.CoverURL, } + // The peer holds its answer while a sync is writing the save, for up to + // ServeSettleWait, before it has the usual time to send it. + ctx = withReplyWait(ctx, wanRequestTimeout+syncengine.ServeSettleWait) raw, err := t.wan.Request(ctx, peer.ID, "/manifest/"+gameID, "GET", body) if err != nil { return syncengine.ManifestResponse{}, err @@ -62,14 +65,11 @@ func (t *wanTransport) FetchManifest(ctx context.Context, peer syncengine.Peer, const slowLinkBytesPerSec = 128 << 10 func (t *wanTransport) FetchBlocks(ctx context.Context, peer syncengine.Peer, ref syncengine.FileRef, blockIndices []int, blockSize int) ([]syncengine.BlockData, error) { - // Give the request a deadline proportional to what it's actually moving, - // so a big batch on a poor connection isn't cut off mid-flight. - if _, hasDeadline := ctx.Deadline(); !hasDeadline && blockSize > 0 { + // Give the reply time proportional to what it's actually moving, so a big + // batch on a poor connection isn't cut off mid-flight. + if blockSize > 0 { expected := int64(len(blockIndices)) * int64(blockSize) - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, - wanRequestTimeout+time.Duration(expected/slowLinkBytesPerSec)*time.Second) - defer cancel() + ctx = withReplyWait(ctx, wanRequestTimeout+time.Duration(expected/slowLinkBytesPerSec)*time.Second) } raw, err := t.wan.Request(ctx, peer.ID, "/blocks/"+ref.GameID, "POST", map[string]any{ diff --git a/internal/p2p/wanclient.go b/internal/p2p/wanclient.go index 4c21f47c..da3df379 100644 --- a/internal/p2p/wanclient.go +++ b/internal/p2p/wanclient.go @@ -25,6 +25,11 @@ const ( wanRequestTimeout = 30 * time.Second wanPeerExpiry = 20 * time.Second + // wanPresenceWriteEvery is how often a peer that stays online has its + // last-seen time written. Well inside wanPeerExpiry, so a peer that keeps + // sending is never expired between two writes. + wanPresenceWriteEvery = 5 * time.Second + // wanReconnectMaxDelay caps the exponential backoff. A relay that is // genuinely down must not be hammered every 5s forever, and when it is // merely cycling, every client in every room would otherwise pile onto @@ -625,6 +630,32 @@ func (w *WanClient) Request(ctx context.Context, peerID, route, method string, b return nil, fmt.Errorf("%s", errBody.Error) } +// replyWaitKey carries how long a request may wait for its reply; see +// withReplyWait. +type replyWaitKey struct{} + +// withReplyWait lets the requests made with ctx wait up to d for their reply +// instead of wanRequestTimeout: for an answer that legitimately takes longer, +// such as a payload sized to the save data it moves, or a peer that holds its +// answer while a sync is writing the save. +func withReplyWait(ctx context.Context, d time.Duration) context.Context { + return context.WithValue(ctx, replyWaitKey{}, d) +} + +// replyWait is how long a request waits for its reply: wanRequestTimeout, or +// longer when the caller asked for it with withReplyWait — and never the +// caller's whole deadline. The relay drops frames for a client that can't keep +// up (relay/server.go), and a sync runs under a 30-minute deadline: a request +// or reply that was dropped then stalled the sync for half an hour instead of +// failing it after the timeout, to be retried. +func replyWait(ctx context.Context) time.Duration { + timeout := wanRequestTimeout + if d, ok := ctx.Value(replyWaitKey{}).(time.Duration); ok && d > timeout { + timeout = d + } + return timeout +} + // exchange sends one request and waits for its reply. err is for a reply // that never came — the relay offline, a timeout — and a reply of any status // is returned as one, so a caller can tell "refused" from "not delivered". @@ -657,16 +688,7 @@ func (w *WanClient) exchange(ctx context.Context, peerID, route, method string, w.send(w.buildRequest(peerID, msgID, route, method, rawBody, authKey)) - // wanRequestTimeout is a floor, not a ceiling: a caller moving several - // megabytes of save data sets a deadline sized to the payload, and a flat - // 30s would abandon a transfer that was making perfectly good progress on - // a slow link. - timeout := wanRequestTimeout - if deadline, ok := ctx.Deadline(); ok { - if until := time.Until(deadline); until > timeout { - timeout = until - } - } + timeout := replyWait(ctx) select { case <-ctx.Done(): diff --git a/internal/p2p/wanclient_handlers.go b/internal/p2p/wanclient_handlers.go index 766dbcdb..b272682c 100644 --- a/internal/p2p/wanclient_handlers.go +++ b/internal/p2p/wanclient_handlers.go @@ -225,13 +225,24 @@ func (w *WanClient) trackPresence(msg RelayMessage) { if err == nil { wasOffline := peer.Status != "online" changed := wasOffline || peer.Address != "relay" + now := time.Now().UnixMilli() + // Written when something changed, and otherwise only every few + // seconds, as the LAN does (routes.go). Every frame from the peer + // lands here — each block of a transfer, each heartbeat — on the one + // goroutine that reads the relay, and a synced write per frame held + // that reading up behind the disk. The relay drops frames for a + // client that falls behind, so a slow disk turned into lost replies. + due := changed || (msg.Port > 0 && msg.Port != peer.Port) || + now-peer.LastSeenMs >= wanPresenceWriteEvery.Milliseconds() peer.Status = "online" peer.Address = "relay" - peer.LastSeenMs = time.Now().UnixMilli() + peer.LastSeenMs = now if msg.Port > 0 { peer.Port = msg.Port } - _ = w.engine.Store.UpdatePeer(peer) + if due { + _ = w.engine.Store.UpdatePeer(peer) + } if wasOffline { w.engine.Log("info", fmt.Sprintf("peer %q came online via WAN; auto-syncing", peer.Name)) w.engine.GoSync(func(ctx context.Context) { w.engine.SyncAllGames(ctx) }) diff --git a/internal/p2p/wanclient_replywait_test.go b/internal/p2p/wanclient_replywait_test.go new file mode 100644 index 00000000..d2b0785f --- /dev/null +++ b/internal/p2p/wanclient_replywait_test.go @@ -0,0 +1,73 @@ +package p2p + +import ( + "context" + "testing" + "time" +) + +// A request waits wanRequestTimeout for its reply, however long the caller's +// own deadline: a sync runs under 30 minutes, and a request or reply the relay +// dropped kept the sync waiting all of that, where it should have failed and +// been retried. +func TestReplyWait_NotTheCallersWholeDeadline(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + if got := replyWait(ctx); got != wanRequestTimeout { + t.Errorf("under a 30-minute deadline a reply is waited for %v, want %v", got, wanRequestTimeout) + } + if got := replyWait(context.Background()); got != wanRequestTimeout { + t.Errorf("with no deadline a reply is waited for %v, want %v", got, wanRequestTimeout) + } +} + +// A request whose answer legitimately takes longer asks for it, and gets it; +// asking for less than the floor changes nothing. +func TestReplyWait_LongerWhenAskedFor(t *testing.T) { + if got := replyWait(withReplyWait(context.Background(), 3*time.Minute)); got != 3*time.Minute { + t.Errorf("asked for 3m, waits %v", got) + } + if got := replyWait(withReplyWait(context.Background(), time.Second)); got != wanRequestTimeout { + t.Errorf("asked for 1s, waits %v; want the floor %v", got, wanRequestTimeout) + } +} + +// Every frame from a paired peer is presence; only a change, or a last-seen +// time going stale, is written. A write per frame held up the one goroutine +// that reads the relay behind the disk. +func TestTrackPresence_WritesOnlyWhenDue(t *testing.T) { + f := newRaceFixture(t, false) + peer, err := f.s.GetPeer(f.peer.ID) + if err != nil { + t.Fatal(err) + } + peer.Status = "online" + peer.Address = "relay" + seen := time.Now().Add(-time.Second).UnixMilli() + peer.LastSeenMs = seen + if err := f.s.UpdatePeer(peer); err != nil { + t.Fatal(err) + } + w := &WanClient{engine: f.e, discovered: map[string]WanPeer{}} + ping := RelayMessage{Type: "ping", From: f.peer.ID, Port: peer.Port} + + w.trackPresence(ping) + if got, _ := f.s.GetPeer(f.peer.ID); got.LastSeenMs != seen { + t.Errorf("a heartbeat right after the last write was written again (last seen %d → %d)", seen, got.LastSeenMs) + } + + stale := time.Now().Add(-wanPresenceWriteEvery - time.Second).UnixMilli() + peer.LastSeenMs = stale + if err := f.s.UpdatePeer(peer); err != nil { + t.Fatal(err) + } + w.trackPresence(ping) + if got, _ := f.s.GetPeer(f.peer.ID); got.LastSeenMs <= stale { + t.Errorf("a stale last-seen time was not refreshed (%d)", got.LastSeenMs) + } + + w.trackPresence(RelayMessage{Type: "ping", From: f.peer.ID, Port: peer.Port + 1}) + if got, _ := f.s.GetPeer(f.peer.ID); got.Port != peer.Port+1 { + t.Errorf("a new port was not written at once: %d, want %d", got.Port, peer.Port+1) + } +} From b8cae017b23e6702c4942d7ac34532f5c793a490 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:36:24 +0200 Subject: [PATCH 67/75] snapshots: no second copy of a save already kept (#56), and versions follow an exclusion change at once Port of upstream PR #56: the initial snapshot and snapshots taken by hand record the save as it was just before them (daemon.SnapshotWithBaseline), and an exclusion change restates the recorded hash under the new rules when that provably claims nothing new (daemon.LastSnapshotHashUnder). Here in the terms the watcher hashes in: the game's rules with the device settings kept out of sync (IgnoreText). That exposed a gap in save versions. A change to a game's own exclusions re-took the version's hash only at the next start (the device-settings check), never when it was made. Until then the version kept a hash under the old rules, which any later hash reads as the same save measured differently (sameSaveAs): a change made after the rule change was taken into the version's hash by the next sync instead of becoming a new version, and never reached the other device. The automatic snapshot that followed every rule change used to hide it by recording a version first. TestJourney_DeviceSpecificConfigNeverTravels failed 5/5 that way once that snapshot was gone. Now the app and `opensave exclude` re-take it at once (daemon.ExclusionsChanged, via AdoptExclusionView), when the save is unchanged under the old rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/baseline_test.go | 78 ++++++++++++++ internal/api/routes.go | 17 ++- internal/cliapp/cliapp.go | 2 +- internal/cliapp/commands_exclude_game.go | 4 +- internal/daemon/autotrack.go | 2 +- internal/daemon/baseline.go | 126 +++++++++++++++++++++++ internal/daemon/baseline_test.go | 76 ++++++++++++++ internal/daemon/daemon.go | 2 +- internal/daemon/devicesettings.go | 34 ++++++ internal/daemon/snapshot_all.go | 2 +- 10 files changed, 329 insertions(+), 14 deletions(-) create mode 100644 e2e/baseline_test.go create mode 100644 internal/daemon/baseline.go create mode 100644 internal/daemon/baseline_test.go diff --git a/e2e/baseline_test.go b/e2e/baseline_test.go new file mode 100644 index 00000000..9da72d65 --- /dev/null +++ b/e2e/baseline_test.go @@ -0,0 +1,78 @@ +package e2e + +import ( + "net/http" + "testing" + "time" + + "github.com/opensave/opensave/testutil" +) + +// A newly tracked game's save is recorded as its baseline, so a file event +// that changes nothing synced takes no second snapshot of it. +// +// It had none: the first event of any kind after tracking snapshotted the +// unchanged save again, and so did the first after the game's exclusions +// changed. A game writing a log into a folder excluded from syncing did that +// straight away. With cloud sync on, the copy was then the device's newest +// save, and a newer one another device had put in the cloud was no longer +// offered — TestIgnored_ASaveFromTheCloudLeavesAnIgnoredFileAlone failed that +// way whenever the extra snapshot came after the other device's. + +func TestBaseline_AFileInAnExcludedFolderTakesNoSnapshot(t *testing.T) { + d := testutil.NewTestDaemon(t, "BaselineExcluded") + d.WriteSave("settings.ini", "graphics") + d.WriteSave("slot1.sav", "act 1") + game := d.TrackGame("Baseline Game") + waitForWatch(t, d, game) + if !testutil.WaitFor(30*time.Second, func() bool { return countSnapshots(d, game) == 1 }) { + t.Fatalf("the initial snapshot was not taken: %d snapshots", countSnapshots(d, game)) + } + d.API(http.MethodPatch, "/api/games/"+game, map[string]any{"syncIgnore": "settings.ini\nlogs/"}, nil) + + d.WriteSave("logs/session.log", "started") + d.WriteSave("settings.ini", "other graphics") + time.Sleep(8 * time.Second) // well past the watcher's debounce + if n := countSnapshots(d, game); n != 1 { + t.Errorf("%d snapshots after only excluded files changed, want the initial one alone", n) + } + + // A real change is still taken. + d.WriteSave("slot1.sav", "act 2") + if !testutil.WaitFor(30*time.Second, func() bool { return countSnapshots(d, game) == 2 }) { + t.Errorf("a change to the save was not snapshotted: %d snapshots", countSnapshots(d, game)) + } +} + +// A snapshot taken by hand is the baseline from then on: the change it kept is +// not snapshotted a second time when the watcher gets to it. +func TestBaseline_ASnapshotByHandIsNotTakenAgain(t *testing.T) { + d := testutil.NewTestDaemon(t, "BaselineManual") + d.WriteSave("slot1.sav", "act 1") + game := d.TrackGame("Baseline Manual Game") + waitForWatch(t, d, game) + if !testutil.WaitFor(30*time.Second, func() bool { return countSnapshots(d, game) == 1 }) { + t.Fatalf("the initial snapshot was not taken: %d snapshots", countSnapshots(d, game)) + } + d.WriteSave("slot1.sav", "act 2") + d.API(http.MethodPost, "/api/games/"+game+"/snapshot", map[string]string{"comment": "before the boss"}, nil) + time.Sleep(8 * time.Second) // well past the watcher's debounce + if n := countSnapshots(d, game); n != 2 { + t.Errorf("%d snapshots, want the initial one and the one taken by hand", n) + } +} + +// The first real change after tracking is snapshotted as before. +func TestBaseline_TheFirstChangeAfterTrackingIsSnapshotted(t *testing.T) { + d := testutil.NewTestDaemon(t, "BaselineFirst") + d.WriteSave("slot1.sav", "act 1") + game := d.TrackGame("Baseline First Game") + waitForWatch(t, d, game) + if !testutil.WaitFor(30*time.Second, func() bool { return countSnapshots(d, game) == 1 }) { + t.Fatalf("the initial snapshot was not taken: %d snapshots", countSnapshots(d, game)) + } + d.WriteSave("slot1.sav", "act 2") + if !testutil.WaitFor(30*time.Second, func() bool { return countSnapshots(d, game) == 2 }) { + t.Errorf("the first change after tracking was not snapshotted: %d snapshots", countSnapshots(d, game)) + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 6ccc81e0..4e80d09d 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -449,23 +449,22 @@ func (s *Server) handleUpdateGame(w http.ResponseWriter, r *http.Request) { s.Daemon.Log.Log("warn", fmt.Sprintf( "could not reset sync agreement for %q after its exclusions changed: %v", game.Name, err)) } - // The last-snapshot hash goes too, for exactly the same reason: it was + // The last-snapshot hash too, for exactly the same reason: it was // recorded over the old view, and the sync engine compares against it // to decide whether the save holds changes a pull would overwrite. A // value that can never match again makes that check fire on every - // pull. Clearing it disables the check until the next snapshot - // re-records it, which is the safe direction — it can cost one - // unnecessary prompt, never a silent overwrite. - game.LastManifestHash = "" + // pull. Restated in the new view when the save provably has not + // changed since that snapshot, and cleared otherwise, which disables + // the check until the next snapshot re-records it — the safe + // direction (daemon.LastSnapshotHashUnder). + game.LastManifestHash = s.Daemon.LastSnapshotHashUnder(game, oldIgnore) } if err := s.Daemon.Store.UpdateGame(game); err != nil { writeError(w, notFoundToStatus(err), err.Error()) return } - if s.Daemon.IgnoreText(game) != oldIgnore { - s.Daemon.NoteExclusions(game) - } + s.Daemon.ExclusionsChanged(game, oldIgnore) // Re-watch if the save location or autoSync flag changed. if game.SavePath != oldSavePath || game.AutoSync != oldAutoSync { @@ -619,7 +618,7 @@ func (s *Server) handleCreateSnapshot(w http.ResponseWriter, r *http.Request) { } _ = readJSON(r, &body) // empty body is fine - snap, err := s.Daemon.Snapshots.Create(gameID, body.Comment, false) + snap, err := s.Daemon.SnapshotWithBaseline(gameID, body.Comment, false) if err != nil { writeError(w, notFoundToStatus(err), err.Error()) return diff --git a/internal/cliapp/cliapp.go b/internal/cliapp/cliapp.go index dacaa2ac..0483ef63 100644 --- a/internal/cliapp/cliapp.go +++ b/internal/cliapp/cliapp.go @@ -841,7 +841,7 @@ func cmdSnapshot(d *daemon.Daemon, args []string) int { } comment := strings.Join(words, " ") - snap, err := d.Snapshots.Create(args[0], comment, false) + snap, err := d.SnapshotWithBaseline(args[0], comment, false) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) return 1 diff --git a/internal/cliapp/commands_exclude_game.go b/internal/cliapp/commands_exclude_game.go index 69d24a8b..759500a8 100644 --- a/internal/cliapp/commands_exclude_game.go +++ b/internal/cliapp/commands_exclude_game.go @@ -119,6 +119,7 @@ func saveIgnore(d *daemon.Daemon, asJSON bool, gameID, gameName string, lines [] if err != nil { return fail(asJSON, err) } + oldIgnore := game.SyncIgnore game.SyncIgnore = strings.Join(lines, "\n") // Same reset the API performs: the recorded agreement was measured over a @@ -129,11 +130,12 @@ func saveIgnore(d *daemon.Daemon, asJSON bool, gameID, gameName string, lines [] d.P2P.Sync.FilteredContentHash(gameID, game)); err != nil { d.Log.Log("warn", "could not reset sync agreement after an exclusion change: "+err.Error()) } - game.LastManifestHash = "" + game.LastManifestHash = d.LastSnapshotHashUnder(game, oldIgnore) if err := d.Store.UpdateGame(game); err != nil { return fail(asJSON, err) } + d.ExclusionsChanged(game, oldIgnore) if asJSON { if lines == nil { lines = []string{} diff --git a/internal/daemon/autotrack.go b/internal/daemon/autotrack.go index 039e1326..dc92aa01 100644 --- a/internal/daemon/autotrack.go +++ b/internal/daemon/autotrack.go @@ -41,7 +41,7 @@ func (d *Daemon) adoptAutoTracked(game store.Game) { readDone, err := d.P2P.Sync.Reading(ctx, game.ID) cancel() if err == nil { - _, err = d.Snapshots.Create(game.ID, "Initial snapshot", true) + _, err = d.SnapshotWithBaseline(game.ID, "Initial snapshot", true) readDone() } if err != nil { diff --git a/internal/daemon/baseline.go b/internal/daemon/baseline.go new file mode 100644 index 00000000..6c512d51 --- /dev/null +++ b/internal/daemon/baseline.go @@ -0,0 +1,126 @@ +package daemon + +import ( + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/ignore" + "github.com/opensave/opensave/internal/store" + "github.com/opensave/opensave/internal/watcher" +) + +// SnapshotWithBaseline takes a snapshot of a game's save and records, as the +// hash the watcher compares against, the save as it was just before it — for +// the snapshots taken outside the watcher: a newly tracked game's first one, +// and those taken by hand. +// +// Without it, a newly tracked game had no baseline, and one snapshotted by +// hand kept the one from before. Either way the next file event of any kind — +// a log written into an excluded folder — or the catch-up a watch runs when it +// starts read as the save changing, and an automatic snapshot was taken of a +// save already kept. With cloud sync on, that copy was then this device's +// newest save, and a newer one another device had put in the cloud meanwhile +// was no longer offered. +// +// Taken before the snapshot, not after: a change made while it is being +// written then differs from the baseline, and the watcher snapshots it. The +// worst case is one snapshot too many, as before; never a change taken for the +// baseline. (The watcher itself never writes a baseline for that reason — see +// catchUpOne.) +func (d *Daemon) SnapshotWithBaseline(gameID, comment string, isSystemAuto bool) (store.Snapshot, error) { + before, hashErr := "", error(nil) + if game, err := d.Store.GetGame(gameID); err == nil { + before, hashErr = d.currentContentHash(game) + } else { + hashErr = err + } + snap, err := d.Snapshots.Create(gameID, comment, isSystemAuto) + if err != nil { + return snap, err + } + if hashErr == nil { + _ = d.Store.SetLastManifestHash(snap.GameID, before) + } + return snap, nil +} + +// LastSnapshotHashUnder restates a game's last-snapshot hash under the +// exclusion rules it has just been given (game.SyncIgnore), or returns "" when +// that cannot be done safely. oldIgnore is the rules the hash was recorded +// under. +// +// The recorded value was taken over the old rules, so it can never match +// again. Cleared, the next file event of any kind — a log written into a +// folder that was just excluded — read as the save changing, and an automatic +// snapshot was taken of a save nobody had changed. With cloud sync on, that +// copy was then this device's newest save, and a newer one from another +// device was no longer offered. +// +// Restated only when that claims nothing new: the save is exactly as it was at +// the last snapshot under the old rules, and the new rules bring no file back +// into view. Otherwise "", as before: the safe direction, which can cost one +// unnecessary snapshot but never leaves a change unsnapshotted. +func (d *Daemon) LastSnapshotHashUnder(game store.Game, oldIgnore string) string { + if game.LastManifestHash == "" { + return "" + } + extra, err := d.Store.GameRootPaths(game.ID) + if err != nil { + return "" + } + // As the save is now, not as a cache last saw it: a change it has not + // noticed yet must count as one. + delta.InvalidateRoot(game.SavePath) + for _, p := range extra { + delta.InvalidateRoot(p) + } + m, failures, err := delta.BuildMultiManifest(game.SavePath, extra) + if err != nil || len(failures) > 0 { + return "" + } + // In the terms the watcher hashes in: the game's rules together with the + // device settings kept out of sync (IgnoreText). + old := game + old.SyncIgnore = oldIgnore + oldText, newText := d.IgnoreText(old), d.IgnoreText(game) + if watcher.ContentHash(m, oldText) != game.LastManifestHash { + return "" // changed since the last snapshot: the next one takes it + } + if bringsBack(m, ignore.Parse(oldText), ignore.Parse(newText)) { + return "" + } + return watcher.ContentHash(m, newText) +} + +// bringsBack reports whether any path in m was excluded by the old rules and +// is not by the new ones. Such a file was never part of the hash recorded, so +// nothing says it is unchanged since the last snapshot. +func bringsBack(m delta.Manifest, oldRules, newRules ignore.Rules) bool { + if oldRules.Empty() { + return false + } + back := func(p string) bool { + return oldRules.Match(p) && (newRules.Empty() || !newRules.Match(p)) + } + for p := range m.Files { + if back(p) { + return true + } + } + for _, dir := range m.Dirs { + if back(dir) { + return true + } + } + for _, root := range m.Extra { + for p := range root.Files { + if back(p) { + return true + } + } + for _, dir := range root.Dirs { + if back(dir) { + return true + } + } + } + return false +} diff --git a/internal/daemon/baseline_test.go b/internal/daemon/baseline_test.go new file mode 100644 index 00000000..5b476ac7 --- /dev/null +++ b/internal/daemon/baseline_test.go @@ -0,0 +1,76 @@ +package daemon + +import ( + "os" + "path/filepath" + "testing" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/store" + "github.com/opensave/opensave/internal/watcher" +) + +// A game's last-snapshot hash restated under new exclusion rules: only when +// the save is provably as it was at that snapshot, and the new rules bring no +// file back; "" otherwise, which takes the next snapshot as before. +func TestLastSnapshotHashUnder(t *testing.T) { + d := newTestDaemon(t) + write := func(dir, rel, content string) { + t.Helper() + p := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + delta.InvalidateRoot(dir) + } + hashOf := func(dir, rules string) string { + t.Helper() + m, _, err := delta.BuildMultiManifest(dir, nil) + if err != nil { + t.Fatal(err) + } + return watcher.ContentHash(m, rules) + } + setUp := func(oldRules string) (store.Game, string) { + dir := t.TempDir() + write(dir, "slot1.sav", "act 1") + write(dir, "settings.ini", "graphics") + write(dir, "logs/a.log", "log") + return store.Game{ID: "g-" + filepath.Base(dir), SavePath: dir, SyncIgnore: oldRules, + LastManifestHash: hashOf(dir, oldRules)}, dir + } + + t.Run("unchanged save, narrower view: restated", func(t *testing.T) { + game, dir := setUp("") + game.SyncIgnore = "logs/\nsettings.ini" + if got, want := d.LastSnapshotHashUnder(game, ""), hashOf(dir, game.SyncIgnore); got != want { + t.Errorf("got %q, want the save's hash under the new rules %q", got, want) + } + }) + t.Run("changed since the snapshot: cleared", func(t *testing.T) { + game, dir := setUp("") + write(dir, "slot1.sav", "act 2") + game.SyncIgnore = "logs/" + if got := d.LastSnapshotHashUnder(game, ""); got != "" { + t.Errorf("got %q for a save changed since its snapshot; that change would go unsnapshotted", got) + } + }) + t.Run("a file brought back into view: cleared", func(t *testing.T) { + game, _ := setUp("logs/\nsettings.ini") + game.SyncIgnore = "logs/" + if got := d.LastSnapshotHashUnder(game, "logs/\nsettings.ini"); got != "" { + t.Errorf("got %q, though settings.ini was never part of the recorded hash", got) + } + }) + t.Run("nothing recorded: nothing to restate", func(t *testing.T) { + game, _ := setUp("") + game.LastManifestHash = "" + game.SyncIgnore = "logs/" + if got := d.LastSnapshotHashUnder(game, ""); got != "" { + t.Errorf("got %q with no recorded hash", got) + } + }) +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index e3f41d4a..c2cd93ff 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -887,7 +887,7 @@ func (d *Daemon) TrackGame(game store.Game) (store.Game, error) { d.initialSnapshots.Add() go func() { defer d.initialSnapshots.Done() - if _, err := d.Snapshots.Create(game.ID, "Initial snapshot", true); err != nil { + if _, err := d.SnapshotWithBaseline(game.ID, "Initial snapshot", true); err != nil { d.Log.Log("warn", fmt.Sprintf("initial snapshot for %q failed: %v", game.Name, err)) } if _, err := d.Store.GetGame(game.ID); err != nil { diff --git a/internal/daemon/devicesettings.go b/internal/daemon/devicesettings.go index 6ef76c1c..dd02e2f1 100644 --- a/internal/daemon/devicesettings.go +++ b/internal/daemon/devicesettings.go @@ -131,6 +131,40 @@ func exclusionsValue(text string) string { return delta.NeverSyncedList + "\n--\n" + text } +// ExclusionsChanged brings a game's version up to date after its own +// exclusion rules were changed (oldIgnore is what game.SyncIgnore was), and +// records that its hashes are now taken under the new list. +// +// The version's hash is re-taken in the new view when the save is unchanged +// under the old one, as at a start after the device-settings list changed. +// Without that, the version kept a hash under the old rules, which any later +// hash reads as "the same save, measured differently" (sameSaveAs): a change +// made after the rules changed was then taken into the version's hash by the +// next sync instead of becoming a new version, and never reached another +// device. An automatic snapshot taken straight after the rule change used to +// hide this, by recording a version before the change came. +func (d *Daemon) ExclusionsChanged(game store.Game, oldIgnore string) { + old := game + old.SyncIgnore = oldIgnore + oldText, newText := d.IgnoreText(old), d.IgnoreText(game) + if oldText == newText { + return + } + if d.P2P != nil && d.P2P.Sync != nil { + extra, err := d.Store.GameRootPaths(game.ID) + if err != nil { + extra = nil + } + delta.InvalidateRoot(game.SavePath) + if m, failures, err := delta.BuildMultiManifest(game.SavePath, extra); err == nil && len(failures) == 0 { + primary := m + primary.Extra = nil + d.P2P.Sync.AdoptExclusionView(game.ID, primary, oldText, false) + } + } + d.NoteExclusions(game) +} + // NoteExclusions records that a game's hashes are now taken under its // current exclusion list — after the rules were changed and the hashes // rebased by whoever changed them. diff --git a/internal/daemon/snapshot_all.go b/internal/daemon/snapshot_all.go index 051700cc..6c984052 100644 --- a/internal/daemon/snapshot_all.go +++ b/internal/daemon/snapshot_all.go @@ -28,7 +28,7 @@ func (d *Daemon) SnapshotAll(comment string) SnapshotAllResult { return res } for _, g := range games { - if _, err := d.Snapshots.Create(g.ID, comment, false); err != nil { + if _, err := d.SnapshotWithBaseline(g.ID, comment, false); err != nil { res.Failed = append(res.Failed, struct { GameID string `json:"gameId"` Name string `json:"name"` From a378006a2c097651d4d77e7902090eed3c29c828 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:39:41 +0200 Subject: [PATCH 68/75] snapshots: a save location change takes no snapshot unless it brings files; Stop waits for a catch-up; a baseline never replaces a newer one Port of upstream #56's second and third commits: - Adding or removing a save location restates the last-snapshot hash when the save is unchanged and an added location holds no file yet (daemon.LocationsChanged), here in the watcher's terms (IgnoreText). - The watcher's Stop waits, bounded, for a catch-up already writing a snapshot. - A hash taken before a snapshot is recorded only while no newer one was recorded meanwhile (Store.SetLastManifestHashIf). Without it, a snapshot taken by hand while a newly tracked game's first one ran could have its value replaced by the older one, and the watcher snapshotted the save again. Tests: as upstream; here the location test looks for a new snapshot rather than counting, since an older one a newer holds entirely is removed. And TestCloud_SyncLocalRepairsATruncatedRemoteCopy changes the save only after tracking's snapshot: made before it, the change was in that one too, which was then removed for the identical newer one, leaving the cloud copy the test truncates without a local snapshot to repair it (failed 4-8 in 10). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/baseline_test.go | 53 ++++++++++++++++++++++ e2e/cloud_and_peers_test.go | 6 ++- internal/api/routes_roots.go | 4 ++ internal/cliapp/commands_locations.go | 4 ++ internal/daemon/baseline.go | 55 ++++++++++++++++++++++- internal/store/games.go | 13 ++++++ internal/store/last_manifest_hash_test.go | 32 +++++++++++++ internal/watcher/stop_catchup_test.go | 55 +++++++++++++++++++++++ internal/watcher/watcher.go | 28 ++++++++++-- 9 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 internal/store/last_manifest_hash_test.go create mode 100644 internal/watcher/stop_catchup_test.go diff --git a/e2e/baseline_test.go b/e2e/baseline_test.go index 9da72d65..e9e8d508 100644 --- a/e2e/baseline_test.go +++ b/e2e/baseline_test.go @@ -2,6 +2,8 @@ package e2e import ( "net/http" + "os" + "path/filepath" "testing" "time" @@ -76,3 +78,54 @@ func TestBaseline_TheFirstChangeAfterTrackingIsSnapshotted(t *testing.T) { t.Errorf("the first change after tracking was not snapshotted: %d snapshots", countSnapshots(d, game)) } } + +// Adding a save location changes no file, so it takes no snapshot; one that +// brings files with it is snapshotted, as the backup should hold them. +// +// The hash the watcher compares against covers every location, so adding one +// changed it, and the catch-up a watch runs when it starts snapshotted a save +// already kept. +func TestBaseline_AddingASaveLocationTakesNoSnapshotUnlessItBringsFiles(t *testing.T) { + d := testutil.NewTestDaemon(t, "BaselineLocation") + d.WriteSave("slot1.sav", "act 1") + game := d.TrackGame("Baseline Location Game") + waitForWatch(t, d, game) + if !testutil.WaitFor(30*time.Second, func() bool { return countSnapshots(d, game) == 1 }) { + t.Fatalf("the initial snapshot was not taken: %d snapshots", countSnapshots(d, game)) + } + + empty := filepath.Join(testutil.TempDir(t), "Config") + if err := os.MkdirAll(empty, 0o777); err != nil { + t.Fatal(err) + } + d.API(http.MethodPost, "/api/games/"+game+"/roots", map[string]string{"name": "config", "path": empty}, nil) + time.Sleep(8 * time.Second) // past the debounce and the catch-up + if n := countSnapshots(d, game); n != 1 { + t.Errorf("%d snapshots after adding an empty location, want the initial one alone", n) + } + + full := filepath.Join(testutil.TempDir(t), "Mods") + if err := os.MkdirAll(full, 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(full, "mod.cfg"), []byte("on"), 0o666); err != nil { + t.Fatal(err) + } + // A new snapshot, not a second one: here an older snapshot that a newer + // one holds entirely is removed as the newer is taken. + before := map[string]bool{} + for _, s := range snapshotsOn(d, game, "main") { + before[s.ID] = true + } + d.API(http.MethodPost, "/api/games/"+game+"/roots", map[string]string{"name": "mods", "path": full}, nil) + if !testutil.WaitFor(30*time.Second, func() bool { + for _, s := range snapshotsOn(d, game, "main") { + if !before[s.ID] { + return true + } + } + return false + }) { + t.Errorf("a location that brought files was not snapshotted: %d snapshots", countSnapshots(d, game)) + } +} diff --git a/e2e/cloud_and_peers_test.go b/e2e/cloud_and_peers_test.go index 5fdb30a5..f2776225 100644 --- a/e2e/cloud_and_peers_test.go +++ b/e2e/cloud_and_peers_test.go @@ -198,7 +198,11 @@ func TestCloud_SyncLocalRepairsATruncatedRemoteCopy(t *testing.T) { a.WriteSave("slot1.sav", strings.Repeat("save data ", 500)) gameID := a.TrackGame("Repair Game") // A change first: a snapshot of the same files as the one tracking took - // would be that one (snapshot/content.go). + // would be that one (snapshot/content.go). After tracking's snapshot, which + // runs in the background: made before it, the change is in that one too, + // the two hold the same files, and the older is removed for the newer — + // whose cloud copy, picked below, no local snapshot then repairs. + waitForWatch(t, a, gameID) a.WriteSave("slot1.sav", strings.Repeat("more save data ", 500)) a.API(http.MethodPost, "/api/games/"+gameID+"/snapshot", map[string]any{"comment": "full"}, nil) diff --git a/internal/api/routes_roots.go b/internal/api/routes_roots.go index 77915bf3..7718b5de 100644 --- a/internal/api/routes_roots.go +++ b/internal/api/routes_roots.go @@ -72,10 +72,12 @@ func (s *Server) handleAddGameRoot(w http.ResponseWriter, r *http.Request) { // Rejections here are the overlap and naming rules, which are the user's // to hear about rather than a server fault: "that folder is already // covered by the main save" is a sentence, not a 500. + oldRoots, _ := s.Daemon.Store.GameRootPaths(gameID) if err := s.Daemon.Store.AddGameRoot(gameID, body.Name, path); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } + s.Daemon.LocationsChanged(gameID, oldRoots) // Start watching it now, not at the next restart. s.Daemon.RewatchGame(gameID) s.BroadcastGamesUpdate() @@ -90,10 +92,12 @@ func (s *Server) handleRemoveGameRoot(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid location name") return } + oldRoots, _ := s.Daemon.Store.GameRootPaths(gameID) if err := s.Daemon.Store.RemoveGameRoot(gameID, name); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } + s.Daemon.LocationsChanged(gameID, oldRoots) // Stop watching it now, or it keeps waking the game up over a folder // nobody is covering any more. s.Daemon.RewatchGame(gameID) diff --git a/internal/cliapp/commands_locations.go b/internal/cliapp/commands_locations.go index e052ab6d..671cae3a 100644 --- a/internal/cliapp/commands_locations.go +++ b/internal/cliapp/commands_locations.go @@ -29,9 +29,11 @@ func cmdLocations(d *daemon.Daemon, args []string) int { if err != nil { return fail(asJSON, err) } + oldRoots, _ := d.Store.GameRootPaths(gameID) if err := d.Store.AddGameRoot(gameID, name, abs); err != nil { return fail(asJSON, err) } + d.LocationsChanged(gameID, oldRoots) if asJSON { return emitJSON(map[string]any{"game": gameID, "location": name, "path": abs}) } @@ -44,9 +46,11 @@ func cmdLocations(d *daemon.Daemon, args []string) int { fmt.Fprintln(os.Stderr, locationsUsage) return 1 } + oldRoots, _ := d.Store.GameRootPaths(args[1]) if err := d.Store.RemoveGameRoot(args[1], args[2]); err != nil { return fail(asJSON, err) } + d.LocationsChanged(args[1], oldRoots) if asJSON { return emitJSON(map[string]any{"removed": args[2]}) } diff --git a/internal/daemon/baseline.go b/internal/daemon/baseline.go index 6c512d51..7fed3e09 100644 --- a/internal/daemon/baseline.go +++ b/internal/daemon/baseline.go @@ -25,9 +25,15 @@ import ( // worst case is one snapshot too many, as before; never a change taken for the // baseline. (The watcher itself never writes a baseline for that reason — see // catchUpOne.) +// +// Recorded only if no other snapshot recorded one in the meantime. A newly +// tracked game's first snapshot runs in the background; a snapshot taken by +// hand meanwhile finished first, and the first one's older value then replaced +// its newer one, and the watcher snapshotted the save again. func (d *Daemon) SnapshotWithBaseline(gameID, comment string, isSystemAuto bool) (store.Snapshot, error) { - before, hashErr := "", error(nil) + before, was, hashErr := "", "", error(nil) if game, err := d.Store.GetGame(gameID); err == nil { + was = game.LastManifestHash before, hashErr = d.currentContentHash(game) } else { hashErr = err @@ -37,7 +43,7 @@ func (d *Daemon) SnapshotWithBaseline(gameID, comment string, isSystemAuto bool) return snap, err } if hashErr == nil { - _ = d.Store.SetLastManifestHash(snap.GameID, before) + _, _ = d.Store.SetLastManifestHashIf(snap.GameID, was, before) } return snap, nil } @@ -90,6 +96,51 @@ func (d *Daemon) LastSnapshotHashUnder(game store.Game, oldIgnore string) string return watcher.ContentHash(m, newText) } +// LocationsChanged restates a game's last-snapshot hash after one of its save +// locations was added or removed (oldRoots is GameRootPaths from before), when +// that claims nothing new. Called before the game is watched again. +// +// The hash covers every location, so adding or removing one changes it while +// no file changed: the catch-up a watch runs when it starts then took a +// snapshot of a save already kept. The test that adds a location ended while +// that snapshot was still being written, which failed its cleanup on Linux CI. +// +// Restated only when the save is as it was at the last snapshot over the old +// locations, and a location added holds no file yet. A location that brings +// files with it is left to be snapshotted: the backup should hold it. +func (d *Daemon) LocationsChanged(gameID string, oldRoots map[string]string) { + game, err := d.Store.GetGame(gameID) + if err != nil || game.LastManifestHash == "" { + return + } + newRoots, err := d.Store.GameRootPaths(gameID) + if err != nil { + return + } + build := func(roots map[string]string) (delta.Manifest, bool) { + delta.InvalidateRoot(game.SavePath) + for _, p := range roots { + delta.InvalidateRoot(p) + } + m, failures, err := delta.BuildMultiManifest(game.SavePath, roots) + return m, err == nil && len(failures) == 0 + } + before, ok := build(oldRoots) + if !ok || watcher.ContentHash(before, d.IgnoreText(game)) != game.LastManifestHash { + return // changed since the last snapshot: the next one takes it + } + after, ok := build(newRoots) + if !ok { + return + } + for name := range newRoots { + if _, had := oldRoots[name]; !had && len(after.Extra[name].Files) > 0 { + return // a location that brings files: snapshotted, as it should be + } + } + _ = d.Store.SetLastManifestHash(gameID, watcher.ContentHash(after, d.IgnoreText(game))) +} + // bringsBack reports whether any path in m was excluded by the old rules and // is not by the new ones. Such a file was never part of the hash recorded, so // nothing says it is unchanged since the last snapshot. diff --git a/internal/store/games.go b/internal/store/games.go index 18b23ee6..ebbafbcd 100644 --- a/internal/store/games.go +++ b/internal/store/games.go @@ -182,6 +182,19 @@ func (s *Store) SetLastManifestHash(gameID, hash string) error { return checkRowAffected(res) } +// SetLastManifestHashIf records hash only while the recorded value is still +// was: a hash taken before a snapshot must not replace one a later snapshot +// recorded in the meantime. Reports whether it was recorded. +func (s *Store) SetLastManifestHashIf(gameID, was, hash string) (bool, error) { + res, err := s.db.Exec(`UPDATE games SET last_manifest_hash = ? WHERE id = ? AND COALESCE(last_manifest_hash, '') = ?`, + hash, gameID, was) + if err != nil { + return false, fmt.Errorf("set last manifest hash %s: %w", gameID, err) + } + n, err := res.RowsAffected() + return err == nil && n > 0, err +} + // RenameGameIf renames a game only while it still has the name from: a name // given automatically must never replace one the person typed in between. // Reports whether it was renamed. diff --git a/internal/store/last_manifest_hash_test.go b/internal/store/last_manifest_hash_test.go new file mode 100644 index 00000000..39aabb5f --- /dev/null +++ b/internal/store/last_manifest_hash_test.go @@ -0,0 +1,32 @@ +package store + +import "testing" + +// A hash taken before a snapshot is recorded only while nothing newer was +// recorded meanwhile: a newly tracked game's first snapshot, running in the +// background, finished after one taken by hand and replaced its newer value. +func TestSetLastManifestHashIf_DoesNotReplaceANewerValue(t *testing.T) { + s := openTestStore(t) + if err := s.CreateGame(Game{ID: "g", Name: "G", SavePath: t.TempDir()}); err != nil { + t.Fatal(err) + } + + if ok, err := s.SetLastManifestHashIf("g", "", "first"); err != nil || !ok { + t.Fatalf("recording over nothing: ok=%v err=%v", ok, err) + } + // Another snapshot records a newer value… + if err := s.SetLastManifestHash("g", "newer"); err != nil { + t.Fatal(err) + } + // …and the one that started before it must not replace it. + if ok, err := s.SetLastManifestHashIf("g", "first", "older"); err != nil || ok { + t.Fatalf("an older value replaced a newer one: ok=%v err=%v", ok, err) + } + g, err := s.GetGame("g") + if err != nil { + t.Fatal(err) + } + if g.LastManifestHash != "newer" { + t.Errorf("recorded %q, want %q", g.LastManifestHash, "newer") + } +} diff --git a/internal/watcher/stop_catchup_test.go b/internal/watcher/stop_catchup_test.go new file mode 100644 index 00000000..16a25c78 --- /dev/null +++ b/internal/watcher/stop_catchup_test.go @@ -0,0 +1,55 @@ +package watcher + +import ( + "testing" + "time" +) + +// Stop waits for a catch-up already taking a snapshot, so whatever stopping +// is for — closing the database, quitting, removing the folder — does not +// happen under it. It returned at once, and the snapshot went on writing into +// a folder being removed (TestAddingAnOrdinarySaveLocationStillWorks on Linux +// CI: "directory not empty"). +func TestStopWaitsForACatchUpUnderWay(t *testing.T) { + saveDir := t.TempDir() + col := newCollector() + col.manifestHashes["game1"] = "a save from before" // so the catch-up snapshots + cb := col.callbacks() + inSnapshot := make(chan struct{}, 1) + release := make(chan struct{}) + take := cb.CreateSnapshot + cb.CreateSnapshot = func(gameID string) error { + select { + case inSnapshot <- struct{}{}: + default: + } + <-release + return take(gameID) + } + eng := New(cb) + if err := eng.Watch("game1", saveDir); err != nil { + t.Fatal(err) + } + select { + case <-inSnapshot: + case <-time.After(15 * time.Second): + t.Fatal("the catch-up never took its snapshot") + } + + stopped := make(chan struct{}) + go func() { + eng.Stop() + close(stopped) + }() + select { + case <-stopped: + t.Fatal("Stop returned while the catch-up was still taking its snapshot") + case <-time.After(500 * time.Millisecond): + } + close(release) + select { + case <-stopped: + case <-time.After(10 * time.Second): + t.Fatal("Stop did not return once the catch-up had finished") + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 786abb34..449c8dae 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -158,6 +158,10 @@ type Engine struct { catchUp chan catchUpJob catchUpCtx context.Context stopCatch context.CancelFunc + // catchUpDone is closed when the catch-up worker has returned; Stop + // waits for it (bounded), so a check already taking a snapshot finishes + // before whatever Stop is for goes on. + catchUpDone chan struct{} // reconcileEvery is this engine's copy of reconcileInterval, read once // when it is built. @@ -253,13 +257,17 @@ func New(cb Callbacks) *Engine { // Dropping a catch-up when the queue is full is safe: the next change // to that game snapshots it anyway, and the periodic watch reconcile // re-queues it. - catchUp: make(chan catchUpJob, 256), - catchUpCtx: ctx, - stopCatch: cancel, + catchUp: make(chan catchUpJob, 256), + catchUpCtx: ctx, + stopCatch: cancel, + catchUpDone: make(chan struct{}), // Read here, on the caller's goroutine, not inside the worker. reconcileEvery: reconcileInterval, } - go e.catchUpWorker() + go func() { + defer close(e.catchUpDone) + e.catchUpWorker() + }() go e.reconcileWorker() return e } @@ -576,6 +584,18 @@ func (e *Engine) Stop() { for _, gw := range stopping { gw.stop() } + // A catch-up already under way may be writing a snapshot. Stop returned + // while it did, and the daemon went on to close its database and the + // app to quit, or a test to remove the folder it was writing into + // (TestAddingAnOrdinarySaveLocationStillWorks on Linux CI: "directory not + // empty"). Bounded, as stopping a watch is. + if e.catchUpDone != nil { + select { + case <-e.catchUpDone: + case <-time.After(watchStopTimeout): + e.log("warn", fmt.Sprintf("a catch-up check did not finish within %s of stopping; left running", watchStopTimeout)) + } + } } // ErrSaveFolderMissing is a watch refused because the save folder is not From 9b0c9cf2d2ab07fdcfe2c4eb51ae065ac3b0150b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 14 Oct 2026 18:27:40 +0000 Subject: [PATCH 69/75] store: apply a new database's migrations in one transaction A new database applied each of its 37 migrations in a transaction of its own, and every commit flushes the rollback journal and the database to disk: 156 fsyncs before store.Open returned. On a busy Windows CI runner that took over thirty seconds, so a freshly started daemon could miss the 30s the CLI e2e harness gives it. CI failed with "daemon never became ready" and empty output; a goroutine dump of the daemon at the deadline shows it still in store.Open -> migrate -> tx.Commit -> FlushFileBuffers, and its log says it started 1.5s after the test gave up. Starts of 5 to 20 seconds, all spent before the address was published, were common on the same runners. Pending migrations now go in one transaction: one commit, 8 fsyncs for a new database (counted with strace), none for one already up to date. A failed step now leaves the schema as it was instead of half upgraded. TestOpeningANewDatabaseCommitsOnce counts commits with the driver's commit hook: 38 before this change, 1 after. Fork adaptation: the fork's openDB keeps WAL with synchronous NORMAL, where a commit does not flush, so the start-up time is mostly saved already here; the gain is 45 commits down to 1 and all-or-nothing migration. The fork's renaming of its old migration names now runs inside the same transaction, and only writes when there is a name to rename (an up-to-date database made one commit on every open before). compactOnOpen is kept. TestOpen_MigratesADatabaseFromAnEarlierForkBuild opens a database as an earlier fork build left it. On fixes-2.4.1 the commit tests fail: 45 commits for a new database, 1 for an up-to-date one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/store/db.go | 160 +++++++++++++++---------- internal/store/fork_migrations_test.go | 71 +++++++++++ internal/store/migrate_test.go | 132 ++++++++++++++++++++ 3 files changed, 300 insertions(+), 63 deletions(-) create mode 100644 internal/store/migrate_test.go diff --git a/internal/store/db.go b/internal/store/db.go index d4aa4c71..f2fdbb0d 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -29,6 +29,30 @@ type Store struct { // Open creates (if needed) and opens the SQLite database at path, applying // any migrations that haven't run yet. func Open(path string) (*Store, error) { + db, err := openDB(path) + if err != nil { + return nil, err + } + + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + s.compactOnOpen() + // The file holds the device's private key, the Google tokens and the + // vault keys. SQLite creates it with the process umask, which is usually + // world-readable; the directory around it is private now, but the file + // should be too, as an ssh key is — the directory is the first wall and + // this is the second. Best-effort, and a no-op in practice on Windows. + restrictToOwner(path) + restrictToOwner(path + "-wal") + restrictToOwner(path + "-shm") + return s, nil +} + +// openDB opens the SQLite connection Open builds a Store on. +func openDB(path string) (*sqlx.DB, error) { // _pragma params ensure foreign keys are enforced (SQLite defaults them // off per-connection) and busy_timeout avoids spurious SQLITE_BUSY // errors from the watcher/api/p2p goroutines all touching the DB. @@ -51,23 +75,7 @@ func Open(path string) (*Store, error) { // an older one ("missing destination name <col>") and the app won't start // at all — a downgrade, or just testing a beta and going back, bricks it. // Ignoring unmapped columns makes the schema forward-compatible instead. - db = db.Unsafe() - - s := &Store{db: db} - if err := s.migrate(); err != nil { - db.Close() - return nil, err - } - s.compactOnOpen() - // The file holds the device's private key, the Google tokens and the - // vault keys. SQLite creates it with the process umask, which is usually - // world-readable; the directory around it is private now, but the file - // should be too, as an ssh key is — the directory is the first wall and - // this is the second. Best-effort, and a no-op in practice on Windows. - restrictToOwner(path) - restrictToOwner(path + "-wal") - restrictToOwner(path + "-shm") - return s, nil + return db.Unsafe(), nil } // restrictToOwner sets a file to 0600 where mode bits are the access control. @@ -86,57 +94,78 @@ func (s *Store) Close() error { return s.db.Close() } -func (s *Store) migrate() error { - if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY)`); err != nil { - return fmt.Errorf("create schema_migrations table: %w", err) - } - - if err := s.renameForkMigrations(); err != nil { - return err - } +// migration is one embedded schema step, applied in name order. +type migration struct { + name string + sql string +} +func (s *Store) migrate() error { entries, err := fs.ReadDir(migrationsFS, "migrations") if err != nil { return fmt.Errorf("read embedded migrations: %w", err) } - var names []string + var migrations []migration for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { - names = append(names, e.Name()) - } - } - sort.Strings(names) - - for _, name := range names { - var applied int - if err := s.db.Get(&applied, `SELECT COUNT(*) FROM schema_migrations WHERE name = ?`, name); err != nil { - return fmt.Errorf("check migration %s: %w", name, err) - } - if applied > 0 { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { continue } - - sqlBytes, err := migrationsFS.ReadFile("migrations/" + name) + sqlBytes, err := migrationsFS.ReadFile("migrations/" + e.Name()) if err != nil { - return fmt.Errorf("read migration %s: %w", name, err) + return fmt.Errorf("read migration %s: %w", e.Name(), err) } + migrations = append(migrations, migration{name: e.Name(), sql: string(sqlBytes)}) + } + sort.Slice(migrations, func(i, j int) bool { return migrations[i].name < migrations[j].name }) + return s.applyMigrations(migrations) +} - tx, err := s.db.Beginx() - if err != nil { - return fmt.Errorf("begin migration tx %s: %w", name, err) - } - if _, err := tx.Exec(string(sqlBytes)); err != nil { - tx.Rollback() - return fmt.Errorf("apply migration %s: %w", name, err) +// applyMigrations applies the migrations not yet recorded, all in one +// transaction, after recording under their upstream names any the fork's +// earlier builds applied under their own (renameForkMigrations). +// +// One, not one per migration: a new database has every migration to apply, +// and committed one by one that was a commit each — with a rollback journal +// some 150 flushes before the daemon could start, which a busy Windows disk +// took over thirty seconds to do, past the time the CLI and the tests give a +// daemon to come up. In one transaction it is a single commit, and a failed +// step leaves the schema as it was rather than half upgraded. +func (s *Store) applyMigrations(migrations []migration) error { + tx, err := s.db.Beginx() + if err != nil { + return fmt.Errorf("begin migrations: %w", err) + } + defer tx.Rollback() // a no-op once committed + + if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY)`); err != nil { + return fmt.Errorf("create schema_migrations table: %w", err) + } + if err := renameForkMigrations(tx); err != nil { + return err + } + var names []string + if err := tx.Select(&names, `SELECT name FROM schema_migrations`); err != nil { + return fmt.Errorf("read applied migrations: %w", err) + } + applied := make(map[string]bool, len(names)) + for _, name := range names { + applied[name] = true + } + + for _, m := range migrations { + if applied[m.name] { + continue } - if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, name); err != nil { - tx.Rollback() - return fmt.Errorf("record migration %s: %w", name, err) + if _, err := tx.Exec(m.sql); err != nil { + return fmt.Errorf("apply migration %s: %w", m.name, err) } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit migration %s: %w", name, err) + if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, m.name); err != nil { + return fmt.Errorf("record migration %s: %w", m.name, err) } } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migrations: %w", err) + } return nil } @@ -154,22 +183,27 @@ var forkMigrationNames = map[string]string{ } // renameForkMigrations records migrations applied under the fork's old names -// under their upstream names, once, so they are not applied a second time - +// under their upstream names, so they are not applied a second time - // creating a table or adding a column that is already there fails, and the -// database would not open. In one transaction, so a database is renamed -// whole or not at all. -func (s *Store) renameForkMigrations() error { - tx, err := s.db.Beginx() - if err != nil { - return fmt.Errorf("begin renaming migrations: %w", err) - } - defer tx.Rollback() +// database would not open. Inside the migrations' transaction, so a database +// is renamed and migrated whole or not at all. +func renameForkMigrations(tx *sqlx.Tx) error { for old, renamed := range forkMigrationNames { + // Looked for first: an UPDATE that changes nothing still makes the + // transaction a write, and an up-to-date database should open + // without one. + var found int + if err := tx.Get(&found, `SELECT COUNT(*) FROM schema_migrations WHERE name = ?`, old); err != nil { + return fmt.Errorf("look for migration %s: %w", old, err) + } + if found == 0 { + continue + } if _, err := tx.Exec(`UPDATE schema_migrations SET name = ? WHERE name = ? AND NOT EXISTS (SELECT 1 FROM schema_migrations WHERE name = ?)`, renamed, old, renamed); err != nil { return fmt.Errorf("rename migration %s: %w", old, err) } } - return tx.Commit() + return nil } diff --git a/internal/store/fork_migrations_test.go b/internal/store/fork_migrations_test.go index 57ccab9a..12da7255 100644 --- a/internal/store/fork_migrations_test.go +++ b/internal/store/fork_migrations_test.go @@ -43,3 +43,74 @@ func TestOpen_RenamesTheForksOldMigrationNames(t *testing.T) { t.Errorf("the game is gone after renaming: %v", err) } } + +// A database an earlier fork build made, as it made it: the fork's own +// migrations under their old names, and not yet the two upstream ones that +// came before them since (0037_game_root_mapped, 0038_peer_links). Opened by +// this build, it is renamed and brought up to date in the one transaction, +// and keeps its data. +func TestOpen_MigratesADatabaseFromAnEarlierForkBuild(t *testing.T) { + path := filepath.Join(t.TempDir(), "opensave.db") + db, err := openDB(path) + if err != nil { + t.Fatal(err) + } + oldNames := map[string]string{} + for old, renamed := range forkMigrationNames { + oldNames[renamed] = old + } + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + t.Fatal(err) + } + var earlier []migration + for _, e := range entries { + name := e.Name() + if name == "0037_game_root_mapped.sql" || name == "0038_peer_links.sql" { + continue + } + sql, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + t.Fatal(err) + } + if old, ok := oldNames[name]; ok { + name = old + } + earlier = append(earlier, migration{name: name, sql: string(sql)}) + } + old := &Store{db: db} + if err := old.applyMigrations(earlier); err != nil { + t.Fatalf("setting up the earlier build's database: %v", err) + } + if err := old.CreateGame(Game{ID: "g", Name: "g", SavePath: t.TempDir(), ActiveBranch: "main"}); err != nil { + t.Fatal(err) + } + old.Close() + + s, err := Open(path) + if err != nil { + t.Fatalf("a database from an earlier fork build no longer opens: %v", err) + } + defer s.Close() + var recorded []string + if err := s.db.Select(&recorded, `SELECT name FROM schema_migrations ORDER BY name`); err != nil { + t.Fatal(err) + } + if len(recorded) != len(entries) { + t.Errorf("%d migrations recorded, want all %d: %v", len(recorded), len(entries), recorded) + } + for i, e := range entries { + if i < len(recorded) && recorded[i] != e.Name() { + t.Errorf("migration %d recorded as %s, want %s", i, recorded[i], e.Name()) + } + } + for _, table := range []string{"game_root_mapped", "peer_links"} { + var n int + if err := s.db.Get(&n, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table); err != nil || n != 1 { + t.Errorf("table %s missing after migrating (%v)", table, err) + } + } + if _, err := s.GetGame("g"); err != nil { + t.Errorf("the game is gone after migrating: %v", err) + } +} diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go new file mode 100644 index 00000000..6df45b38 --- /dev/null +++ b/internal/store/migrate_test.go @@ -0,0 +1,132 @@ +package store + +import ( + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + + "modernc.org/sqlite" +) + +// commitCounts counts the commits made on each database file, by the path in +// its connection string. The hook is the driver's and so global: registered +// once, it watches every store any test in this package opens. +var commitCounts = struct { + once sync.Once + mu sync.Mutex + n map[string]*atomic.Int64 +}{n: map[string]*atomic.Int64{}} + +func countCommits(t *testing.T, dbPath string) *atomic.Int64 { + t.Helper() + commitCounts.once.Do(func() { + sqlite.RegisterConnectionHook(func(conn sqlite.ExecQuerierContext, dsn string) error { + hooks, ok := conn.(sqlite.HookRegisterer) + if !ok { + return nil + } + commitCounts.mu.Lock() + defer commitCounts.mu.Unlock() + for path, n := range commitCounts.n { + if strings.Contains(dsn, path) { + hooks.RegisterCommitHook(func() int32 { n.Add(1); return 0 }) + } + } + return nil + }) + }) + n := new(atomic.Int64) + commitCounts.mu.Lock() + commitCounts.n[dbPath] = n + commitCounts.mu.Unlock() + return n +} + +// A new database is set up in one commit, not one per migration. +// +// With a rollback journal every commit flushes the journal and the database +// to disk, and a new database used to take one per migration: some 150 +// flushes before the daemon could start. On a busy Windows disk that took over +// thirty seconds, so a freshly installed daemon could miss the 30 s its +// clients give it to come up — CI saw `daemon never became ready` with the +// daemon still inside store.Open. This fork runs WAL with synchronous NORMAL, +// where a commit does not flush, so the time is mostly saved already; one +// commit still means a new database is set up whole or not at all. +func TestOpeningANewDatabaseCommitsOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "opensave.db") + commits := countCommits(t, path) + + s, err := Open(path) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + defer s.Close() + + if got := commits.Load(); got != 1 { + t.Errorf("setting up a new database took %d commits, want 1", got) + } + + // Every migration is recorded as applied, so the next open does nothing. + var applied int + if err := s.db.Get(&applied, `SELECT COUNT(*) FROM schema_migrations`); err != nil { + t.Fatal(err) + } + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + t.Fatal(err) + } + if applied != len(entries) { + t.Errorf("%d migrations recorded, want all %d", applied, len(entries)) + } +} + +// Opening a database that is already up to date writes nothing. +func TestOpeningAnUpToDateDatabaseDoesNotCommit(t *testing.T) { + path := filepath.Join(t.TempDir(), "opensave.db") + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + s.Close() + + commits := countCommits(t, path) + s, err = Open(path) + if err != nil { + t.Fatalf("reopening: %v", err) + } + defer s.Close() + if got := commits.Load(); got != 0 { + t.Errorf("reopening an up-to-date database made %d commits, want 0", got) + } +} + +// A migration that fails leaves the database as it was: none of the ones +// before it in the same run are recorded either, so the next start retries +// the whole step instead of resuming from a half-applied schema. +func TestAFailedMigrationAppliesNothing(t *testing.T) { + path := filepath.Join(t.TempDir(), "opensave.db") + s := &Store{} + db, err := openDB(path) + if err != nil { + t.Fatal(err) + } + s.db = db + defer s.Close() + + err = s.applyMigrations([]migration{ + {name: "0001_ok.sql", sql: `CREATE TABLE fine (id INTEGER)`}, + {name: "0002_broken.sql", sql: `CREATE TABLE broken (`}, + }) + if err == nil || !strings.Contains(err.Error(), "0002_broken.sql") { + t.Fatalf("applyMigrations error = %v, want one naming the broken migration", err) + } + var tables int + if err := s.db.Get(&tables, `SELECT COUNT(*) FROM sqlite_master WHERE name IN ('fine', 'schema_migrations')`); err != nil { + t.Fatal(err) + } + if tables != 0 { + t.Errorf("%d tables left behind by a failed migration run, want none", tables) + } +} From 8d481714f3e8a9d111c1bc01f0c74700e08bd56c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 15 Oct 2026 00:51:19 +0000 Subject: [PATCH 70/75] e2e: make temp folders before the daemons that use them Five tests made their t.TempDir after starting their daemons. Cleanups run last first, so the folder was deleted while the daemons still watched or scanned it, and on Windows the removal fails the test: CI saw TestSwitch_TheSameGameUnderDifferentIDsSyncs fail with "TempDir RemoveAll cleanup: ... The directory is not empty", its daemon logging in the same second that every save file had been deleted. The harness already avoids t.TempDir for the daemons' own homes for this reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/autotrack_first_snapshot_test.go | 5 ++++- e2e/scan_exclude_test.go | 5 ++++- e2e/switch_title_test.go | 10 ++++++++-- e2e/watch_reconcile_test.go | 5 ++++- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/e2e/autotrack_first_snapshot_test.go b/e2e/autotrack_first_snapshot_test.go index 30b0bf48..fe13be59 100644 --- a/e2e/autotrack_first_snapshot_test.go +++ b/e2e/autotrack_first_snapshot_test.go @@ -20,10 +20,13 @@ import ( // was not watched either until the periodic check came round, so a change // made there in the meantime was not sent on. func TestAutoTrack_TakesAFirstSnapshotAndWatches(t *testing.T) { + // Before the daemons: cleanups run last first, so the folder outlives them. + // Made after, it was deleted while they were still using it, and on Windows + // that fails the test ("The directory is not empty"). + rootA, rootB := t.TempDir(), t.TempDir() a := testutil.NewTestDaemon(t, "AutoSnap-A") b := testutil.NewTestDaemon(t, "AutoSnap-B") a.PairWith(b) - rootA, rootB := t.TempDir(), t.TempDir() saveA, saveB := filepath.Join(rootA, "Detroit"), filepath.Join(rootB, "Detroit") writeAt(t, filepath.Join(saveA, "slot1.sav"), "chapter 12") writeAt(t, filepath.Join(saveB, "slot1.sav"), "chapter 12") diff --git a/e2e/scan_exclude_test.go b/e2e/scan_exclude_test.go index b19be8e6..0e2090a8 100644 --- a/e2e/scan_exclude_test.go +++ b/e2e/scan_exclude_test.go @@ -35,10 +35,13 @@ func scanFor(td *testutil.TestDaemon, wantPath string) *scanHit { // trip the UI actually performs: save the path into settings, rescan, and // find it gone. func TestScanExclude_DismissedLocationStaysGone(t *testing.T) { + // Before the daemons: cleanups run last first, so the folder outlives them. + // Made after, it was deleted while they were still using it, and on Windows + // that fails the test ("The directory is not empty"). + root := t.TempDir() td := testutil.NewTestDaemon(t, "ScanExclude") // A custom scan root with two candidate saves in it. - root := t.TempDir() keep := filepath.Join(root, "KeepMe") drop := filepath.Join(root, "DropMe") for _, d := range []string{keep, drop} { diff --git a/e2e/switch_title_test.go b/e2e/switch_title_test.go index 544bdf5c..b7b13f25 100644 --- a/e2e/switch_title_test.go +++ b/e2e/switch_title_test.go @@ -53,10 +53,13 @@ func gamesOf(td *testutil.TestDaemon) map[string]gameRow { // which names the other install's profile id — a folder this emulator never // reads, beside the one it does, which then went unsynced. func TestSwitch_AnArrivingSaveGoesUnderThisDevicesProfile(t *testing.T) { + // Before the daemons: cleanups run last first, so the folder outlives them. + // Made after, it was deleted while they were still using it, and on Windows + // that fails the test ("The directory is not empty"). + rootA, rootB := t.TempDir(), t.TempDir() a := testutil.NewTestDaemon(t, "SwitchA") b := testutil.NewTestDaemon(t, "SwitchB") a.PairWith(b) - rootA, rootB := t.TempDir(), t.TempDir() onA := nandTitle(rootA, "eden", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") writeAt(t, filepath.Join(onA, "progress.sav"), "ninety hours in") @@ -95,10 +98,13 @@ func TestSwitch_AnArrivingSaveGoesUnderThisDevicesProfile(t *testing.T) { // the emulator) and one tracked since (its title id), in different emulators. // Both directions, and without B taking A's game as a second, new one. func TestSwitch_TheSameGameUnderDifferentIDsSyncs(t *testing.T) { + // Before the daemons: cleanups run last first, so the folder outlives them. + // Made after, it was deleted while they were still using it, and on Windows + // that fails the test ("The directory is not empty"). + root := t.TempDir() a := testutil.NewTestDaemon(t, "SwitchIdA") b := testutil.NewTestDaemon(t, "SwitchIdB") a.PairWith(b) - root := t.TempDir() onA := nandTitle(root, "citron", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") writeAt(t, filepath.Join(onA, "progress.sav"), "from A") diff --git a/e2e/watch_reconcile_test.go b/e2e/watch_reconcile_test.go index f9d14405..e11364b5 100644 --- a/e2e/watch_reconcile_test.go +++ b/e2e/watch_reconcile_test.go @@ -154,12 +154,15 @@ func TestResyncWatchers_StopsWatchingAnUntrackedGame(t *testing.T) { // the branch that compares paths, and getting it wrong means watching an empty // folder forever while the real saves change unobserved. func TestResyncWatchers_FollowsARelocatedSave(t *testing.T) { + // Before the daemons: cleanups run last first, so the folder outlives them. + // Made after, it was deleted while they were still using it, and on Windows + // that fails the test ("The directory is not empty"). + moved := filepath.Join(t.TempDir(), "new-location") d := testutil.NewTestDaemon(t, "ReconcileMove") d.WriteSave("slot1.sav", "start") gameID := d.TrackGame("Relocated Game") waitForWatch(t, d, gameID) - moved := filepath.Join(t.TempDir(), "new-location") if err := os.MkdirAll(moved, 0o777); err != nil { t.Fatal(err) } From d431b29678c2c7e01be2a266de2aa545f2056ee0 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:21:46 +0200 Subject: [PATCH 71/75] sync: a report stamps the devices synced as of when it compared them RefreshLineage, run when a peer reports it finished pulling or that the two are in sync, stamped "last synced" once it had read both sides and written the lineage. A location with no merge base yet counts only edits made after that stamp (plus the 2 s clock-skew allowance) as new, so an edit made while the refresh ran - seconds on a slow disk - could count as made before the devices were compared. The stamp is now the time the comparison began, before either side was read. A stamp that ends up earlier than another written meanwhile only makes more edits count as new, which is the safe direction. Hardening, not the cause of the CI failure of TestMultiRootScenario_ALateReportDoesNotHideADivergence: that was the test editing inside the skew allowance, fixed in the next commit. TestRefreshLineage_StampsWhenTheComparisonBegan: fails 3/3 without this (stamp ~100 ms after the peer's side was read), passes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/p2p/syncengine/engine.go | 18 ++++++++-- internal/p2p/syncengine/engine_test.go | 6 ++++ internal/p2p/syncengine/stamp_test.go | 50 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 internal/p2p/syncengine/stamp_test.go diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index c94a710e..84f6ee72 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -465,7 +465,16 @@ func syncedNow() string { return time.Now().UTC().Format("2006-01-02T15:04:05.00 // up to date with this save?" needs. The two are written together so they // cannot disagree about when. func (e *Engine) recordSynced(gameID, peerID string) { - ts := syncedNow() + e.recordSyncedAt(gameID, peerID, syncedNow()) +} + +// recordSyncedAt is recordSynced for a comparison that began at ts. The stamp +// says the two devices were the same as of that time, and a location with no +// merge base yet counts only the edits made after it as new. Stamped with the +// time the comparison ended instead, it reached past edits made while it ran +// — on a slow disk that is seconds — and an edit made on both devices then +// read as neither having changed: one quietly replaced the other. +func (e *Engine) recordSyncedAt(gameID, peerID, ts string) { _ = e.Store.UpdatePeerLastSynced(peerID, ts) _ = e.Store.UpdateGamePeerLastSynced(gameID, peerID, ts) } @@ -1318,6 +1327,11 @@ func (e *Engine) ConfirmInSync(ctx context.Context, gameID string, peer Peer, cl // the lineage (making future local deletions of them propagate instead of // the file being pulled back). func (e *Engine) RefreshLineage(ctx context.Context, gameID string, peer Peer) { + // Taken before either side is read: whatever the comparison below finds, + // it found as of no earlier than this, and an edit made while it runs — + // after the peer's manifest was fetched, or after ours was read — must + // still count as made after the time it stamps (recordSyncedAt). + checkedAt := syncedNow() game, err := e.Store.GetGame(gameID) if err != nil { return @@ -1355,7 +1369,7 @@ func (e *Engine) RefreshLineage(ctx context.Context, gameID string, peer Peer) { if local.ManifestHash() == remoteData.Manifest.ManifestHash() { _ = e.Store.SetAgreedHash(gameID, peer.ID, local.ManifestHash()) if rootsAgree { - e.recordSynced(gameID, peer.ID) + e.recordSyncedAt(gameID, peer.ID, checkedAt) e.notifySyncConfirmed(gameID) } } diff --git a/internal/p2p/syncengine/engine_test.go b/internal/p2p/syncengine/engine_test.go index 22a2be33..5827f107 100644 --- a/internal/p2p/syncengine/engine_test.go +++ b/internal/p2p/syncengine/engine_test.go @@ -47,6 +47,9 @@ type fakeTransport struct { // "sync-complete" once a pull's files are all written, before the sync // that pulled them has finished. onSyncEvent func(eventType string) + // onFetchManifest runs as the peer's manifest is served, before it is + // read: a test's way to stand in a comparison that is part-way through. + onFetchManifest func() } func (f *fakeTransport) FetchManifest(ctx context.Context, peer Peer, gameID string, q ManifestQuery) (ManifestResponse, error) { @@ -63,6 +66,9 @@ func (f *fakeTransport) FetchManifest(ctx context.Context, peer Peer, gameID str if f.manifestErr != nil { return ManifestResponse{}, f.manifestErr } + if f.onFetchManifest != nil { + f.onFetchManifest() + } m, err := delta.BuildManifest(f.remoteDir) if err != nil { return ManifestResponse{}, err diff --git a/internal/p2p/syncengine/stamp_test.go b/internal/p2p/syncengine/stamp_test.go new file mode 100644 index 00000000..2300c4d4 --- /dev/null +++ b/internal/p2p/syncengine/stamp_test.go @@ -0,0 +1,50 @@ +package syncengine + +import ( + "context" + "testing" + "time" +) + +// The time a peer's report confirms the two devices the same is the time the +// comparison began, not the time it finished. +// +// A location with no merge base yet counts only the edits made after that +// time as new. RefreshLineage stamped it once it had read both sides and +// written the lineage — seconds later on a slow disk — so an edit made on both +// devices while it ran read as neither having changed, and one replaced the +// other with no conflict raised. CI: TestMultiRootScenario_ALateReportDoesNotHideADivergence +// on Windows, "A=\"B-version\" B=\"B-version\"". +func TestRefreshLineage_StampsWhenTheComparisonBegan(t *testing.T) { + env := setupEngine(t) + write(t, env.localDir, "save.dat", "same") + write(t, env.remoteDir, "save.dat", "same") + + var fetchedAt time.Time + env.transport.onFetchManifest = func() { + fetchedAt = time.Now() + // The rest of the comparison taking a while, as it does on a busy + // machine: an edit made now is after the peer's side was read. + time.Sleep(100 * time.Millisecond) + } + env.engine.RefreshLineage(context.Background(), "game1", env.peer) + + p, err := env.store.GetPeer(env.peer.ID) + if err != nil { + t.Fatal(err) + } + if !p.LastSynced.Valid { + t.Fatal("identical sides were not stamped as synced") + } + stamped, err := time.Parse("2006-01-02T15:04:05.000Z", p.LastSynced.String) + if err != nil { + t.Fatal(err) + } + if fetchedAt.IsZero() { + t.Fatal("the peer's manifest was never fetched") + } + if stamped.After(fetchedAt) { + t.Errorf("stamped synced at %s, %v after the peer's side was read: an edit made in between would not count as new", + stamped.Format(time.RFC3339Nano), stamped.Sub(fetchedAt)) + } +} From a69a306f7baa605981298c6aa45e92080d2d37a3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 14 Oct 2026 23:36:44 +0000 Subject: [PATCH 72/75] e2e: the late-report test edits after the skew allowance, not inside it TestMultiRootScenario_ALateReportDoesNotHideADivergence clears the location's merge base and has both devices edit it. With no base, edits are judged against when the two were last confirmed the same, and only one made more than DetectConflict's 2 s clock-skew allowance after it counts as new. The refresh started by the setup's own reports can stamp that time after SettleSync returns, and the edits then came within it: neither side read as changed, B's version replaced A's, and the test reported "no conflict was raised: A=\"B-version\" B=\"B-version\"" - on Windows CI about one shard run in ten. Instrumented runs show the stamp 1.7-1.9 s before the edits each time; a stamp 1.5 s before them reproduces the failure every time, and 3 s does not. The test now waits until that time has stopped moving and is more than the allowance in the past. It still fails as it should when a report stamps the two as synced while the location differs (the bug it was written for). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/multiroot_scenarios_test.go | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/e2e/multiroot_scenarios_test.go b/e2e/multiroot_scenarios_test.go index dc76a716..cfef5988 100644 --- a/e2e/multiroot_scenarios_test.go +++ b/e2e/multiroot_scenarios_test.go @@ -483,6 +483,14 @@ func TestMultiRootScenario_ALateReportDoesNotHideADivergence(t *testing.T) { if err := a.Daemon.Store.SetAgreedHashForRoot(gameID, b.NodeID(), "config", ""); err != nil { t.Fatal(err) } + // With no base, the edits are judged against when the two were + // last confirmed the same, and only one made more than the 2 s + // clock-skew allowance after it counts as new (DetectConflict). A + // refresh the setup's own reports started can stamp that time + // after SettleSync returns; edits made in the next two seconds + // then read as neither side having changed, and the test failed on + // its own timing (Windows CI: stamped 1.8 s before the edits). + waitPastLastSynced(t, a, b.NodeID()) writeIn(t, aConfig, "settings.ini", "A-version") writeIn(t, bConfig, "settings.ini", "B-version") @@ -517,6 +525,32 @@ func TestMultiRootScenario_ALateReportDoesNotHideADivergence(t *testing.T) { } } +// waitPastLastSynced waits until a's "last synced" time for the peer has +// stopped moving and lies further back than the clock-skew allowance a +// location without a merge base is judged with (syncengine's +// clockSkewToleranceMs, 2 s), so an edit made after it counts as new. +func waitPastLastSynced(t *testing.T, a *testutil.TestDaemon, peerID string) { + t.Helper() + const past = 3 * time.Second // the 2 s allowance, and a margin + stamp := func() string { + p, err := a.Daemon.Store.GetPeer(peerID) + if err != nil || !p.LastSynced.Valid { + return "" + } + return p.LastSynced.String + } + deadline := time.Now().Add(45 * time.Second) + for time.Now().Before(deadline) { + s := stamp() + at, err := time.Parse("2006-01-02T15:04:05.000Z", s) + if s == "" || (err == nil && time.Since(at) > past) { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("the last-synced time kept moving: %s", stamp()) +} + // The location's own TestStaleBase_OneSidedEditDoesNotConflict. A report that // never arrives leaves the base behind both devices, with the push it was // about still on record — B holds exactly what A sent. An edit on A alone must From 9343dae43cd0af9bd71427fbed14c2ad88c6b812 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 15 Oct 2026 02:21:31 +0000 Subject: [PATCH 73/75] sync: an emptied save started syncing mid-write is still held back A sync this device starts checks first whether every save file was deleted here, and if so holds the game back until someone says whether it was meant. That check decides nothing while anything is writing the save, and for a sync "nothing decided" read as "not held": the sync went on, waited for the write to end, read the folder empty and sent every deletion to the other device - the one thing the hold exists to stop. Something writes the save right after it is emptied as a matter of course: the automatic snapshot of that very change takes the write side of the gate. Windows CI, TestEmptiedFolder_PutBackRestoresThem: "the other device, while the emptied save is held back: slot1.sav is \"\"" for all three files, then the two devices pushing at each other for minutes. A sync now waits for a write in progress to end (up to 30 s) and judges the folder as it then stands; still being written after that, it does not run (ErrSettling) and is tried again. The watcher's check keeps not waiting. TestEmptiedFolder_ASyncStartedDuringAWriteIsStillHeld holds the gate as such a write does, empties the folder and syncs: without this the sync answers 200 and the other device loses all three files, every run; with it the sync is refused (409) and the other device keeps them. Fork adaptation: applied as is; the test file kept the fork's own emptied tests alongside the new one. On fixes-2.4.1 without the fix the test fails 3/3 (the sync runs, 200, instead of being held back, 409); with it, it passes. There the other device kept its files even without the fix - the fork does not take an emptying as a version until it is meant - so on the fork this restores the hold's behaviour rather than stopping a data loss. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/emptied_folder_test.go | 29 ++++++++++++++++++++++++++ internal/p2p/syncengine/engine.go | 6 +++++- internal/p2p/syncengine/hold.go | 34 +++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/e2e/emptied_folder_test.go b/e2e/emptied_folder_test.go index 456d1daf..bc1fcb09 100644 --- a/e2e/emptied_folder_test.go +++ b/e2e/emptied_folder_test.go @@ -331,3 +331,32 @@ func TestEmptiedFolder_NoSnapshotOfTheEmptyOrHalfSave(t *testing.T) { } } } + +// The emptied device's own sync, started while something there is writing +// the save — the automatic snapshot of the very change that emptied it, or +// another sync — must still be held back. +// +// Whether the folder was emptied is only judged while nothing writes it, and +// a sync used to read "nothing decided yet" as "not held": it went on, waited +// for the write itself, read the folder empty and sent every deletion to the +// other device. Windows CI, TestEmptiedFolder_PutBackRestoresThem: the other +// device lost all three files while the save was supposedly held back. +func TestEmptiedFolder_ASyncStartedDuringAWriteIsStillHeld(t *testing.T) { + a, b, gameID := pairAndTrack(t, "Emptied Mid-Write Game", emptiedSave) + testutil.SettleSync(t, gameID, a, b) + + release := a.Daemon.P2P.Sync.Writing(gameID) + emptyFolder(t, a.SaveDir) + go func() { + time.Sleep(500 * time.Millisecond) + release() + }() + if code := a.APIStatus(http.MethodPost, "/api/games/"+gameID+"/sync", nil, nil); code != http.StatusConflict { + t.Errorf("syncing the emptied game while a write was in progress = %d, want it held back (409)", code) + } + syncBothWays(t, gameID, a, b) + stillHasIt(t, b, "the other device, after the emptied device synced mid-write") + if e := emptiedState(t, a, gameID); e == nil || e.Files != 3 { + t.Errorf("the emptied device says %+v; want the game held back over 3 files", e) + } +} diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 84f6ee72..486b8c09 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -297,7 +297,11 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer } // A save emptied here is not synced until someone says whether that was // meant (hold.go). - if held, err := e.CheckHold(gameID, false); err == nil && held { + held, err := e.checkHoldSettled(ctx, gameID) + if err != nil && errors.Is(err, ErrSettling) { + return nil, err + } + if err == nil && held { return nil, ErrHeld } e.mu.Lock() diff --git a/internal/p2p/syncengine/hold.go b/internal/p2p/syncengine/hold.go index 1e794a8d..fd04ac5b 100644 --- a/internal/p2p/syncengine/hold.go +++ b/internal/p2p/syncengine/hold.go @@ -267,6 +267,40 @@ func (e *Engine) CheckHold(gameID string, serving bool) (held bool, err error) { // takes holdMu (noteEmptiedByPeer). readDone, settled := e.TryReading(gameID) defer readDone() + return e.checkHold(gameID, serving, settled) +} + +// holdSettleWait bounds how long a sync waits for a write in progress here to +// end before judging whether the folder was emptied. +const holdSettleWait = 30 * time.Second + +// checkHoldSettled is CheckHold for a sync this device is about to start. A +// write in progress here — an automatic snapshot of the change that emptied +// the folder, another sync — is waited out, and the folder judged as it then +// stands. +// +// CheckHold decides nothing while anything is writing, and for a sync that +// answer was "not held": the sync went on, waited for the write itself, read +// the folder empty, and sent every file's deletion to the other device — the +// one thing the hold exists to stop. Windows CI: in +// TestEmptiedFolder_PutBackRestoresThem the other device lost every file while +// the save was held back. +// A write that does not end in time is ErrSettling: the sync does not run on +// a folder nobody could judge, and is tried again later. +func (e *Engine) checkHoldSettled(ctx context.Context, gameID string) (held bool, err error) { + ctx, cancel := context.WithTimeout(ctx, holdSettleWait) + defer cancel() + readDone, err := e.Reading(ctx, gameID) + if err != nil { + return false, err + } + defer readDone() + return e.checkHold(gameID, false, true) +} + +// checkHold is CheckHold's judgement, for a caller that has taken the read +// side of the gate (settled) or tried to and could not. +func (e *Engine) checkHold(gameID string, serving, settled bool) (held bool, err error) { e.holdMu.Lock() defer e.holdMu.Unlock() game, err := e.Store.GetGame(gameID) From c95b5e3aea3a0196f4491a4729d5e0c1e810b54a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 15 Oct 2026 14:54:57 +0000 Subject: [PATCH 74/75] sync: a lost report no longer brings back a file deleted right after When a sync-complete report is lost, the push-proof repair banks the pushed state as the shared base, but the record of which files both devices hold stayed as it was. A file handed over in that push was missing from it, so if it was deleted before the watcher noted the deletion, the next sync read it as a file the peer had and this device never did, and fetched it back. The repair now records the pushed state's files as shared too, as extra locations already do. Not while the game is held as emptied: that record is then kept on purpose. "Put them back" with no snapshot takes the files out of it so the next sync fetches them from the peer; written back by the repair, they read as deleted here and the deletions went to the peer, and both devices lost the save (TestEmptiedFolder_PutBackWithoutASnapshotFetchesThem on Linux CI, every run together with the emptied-hold fix). Seen on Windows CI as TestWatchAutoSyncChain's "deletion never auto-synced to B"; TestDeletion_SurvivesALostReport reproduces it deterministically, and TestEmptiedFolder_PutBackSurvivesALostReport the put-back case (fails 3/3 without the hold check). Fork adaptation: code applied as is. On this fork an automatic sync is decided by versions, where the device that deleted the file holds the newer one, so there the deletion went through without the fix too. A game synced by hand is still decided file by file: TestDeletion_SurvivesALostReport gains a by-hand case, which fails 3/3 on fixes-2.4.1 (the file comes back) and passes with the fix. TestEmptiedFolder_PutBackSurvivesALostReport passes on fixes-2.4.1 and fails 3/3 with the repair but without its held-as-emptied exception, so the exception is needed here as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/deletion_record_test.go | 88 +++++++++++++++++++++++++++++++ e2e/emptied_folder_test.go | 45 ++++++++++++++++ internal/p2p/syncengine/engine.go | 19 +++++++ 3 files changed, 152 insertions(+) diff --git a/e2e/deletion_record_test.go b/e2e/deletion_record_test.go index 5133fe1d..16665d7e 100644 --- a/e2e/deletion_record_test.go +++ b/e2e/deletion_record_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/testutil" ) @@ -135,3 +136,90 @@ func TestDeletion_NeverRemovesAnEditThePeerMadeAfterwards(t *testing.T) { "that was done after the file was deleted elsewhere", got) } } + +// A file handed over just before a sync-complete report went missing must +// still be deletable. +// +// The report is what moves the shared base forward and records the files both +// devices hold. Lost, it leaves the old base and a record without the new file; +// only the push stays on record. A later sync proves the push from it and +// banks the base, but the record of shared files stayed behind. Deleted before +// the watcher noticed, the file then read as one the peer had and this device +// never did, and was fetched straight back. On Windows CI this was +// TestWatchAutoSyncChain's "deletion never auto-synced to B". The lost report +// is set up directly here rather than raced for. +// +// On this fork the versions decide an automatic sync (version.go), and the +// device that deleted the file holds the newer one, so there the deletion +// goes through either way. A game synced by hand is still decided file by +// file, and that is where the lost report bit. +func TestDeletion_SurvivesALostReport(t *testing.T) { + for _, tc := range []struct { + name string + autoSync bool + }{ + {"synced automatically", true}, + {"synced by hand", false}, + } { + t.Run(tc.name, func(t *testing.T) { deletionAfterALostReport(t, tc.autoSync) }) + } +} + +func deletionAfterALostReport(t *testing.T, autoSync bool) { + a, b, gameID := pairAndTrack(t, "Lost Report Deletion", map[string]string{"seed.sav": "seed"}) + testutil.SettleSync(t, gameID, a, b) + st := a.Daemon.Store + var oldBase string + if !testutil.WaitFor(30*time.Second, func() bool { + oldBase = st.GetAgreedHash(gameID, b.NodeID()) + return oldBase != "" + }) { + t.Fatal("setup: the devices never recorded that they agree") + } + + a.WriteSave("auto.sav", "v1") + time.Sleep(syncSettleWindow) + a.API(http.MethodPost, "/api/games/"+gameID+"/sync", nil, nil) + if !testutil.WaitFor(30*time.Second, func() bool { return b.ReadSave("auto.sav") == "v1" }) { + t.Fatal("setup: auto.sav never reached B") + } + testutil.SettleSync(t, gameID, a, b) + + // The report is let through and then undone, rather than dropped: written + // over while it is still being recorded, it would leave a mix of before + // and after that no lost report produces. + handedOver, err := delta.BuildManifest(a.SaveDir) + if err != nil { + t.Fatal(err) + } + if !testutil.WaitFor(30*time.Second, func() bool { + return st.GetAgreedHash(gameID, b.NodeID()) == handedOver.ManifestHash() + }) { + t.Fatal("setup: the report never arrived") + } + if err := st.SetAgreedHash(gameID, b.NodeID(), oldBase); err != nil { + t.Fatal(err) + } + if err := st.SetPushedHash(gameID, b.NodeID(), handedOver.ManifestHash()); err != nil { + t.Fatal(err) + } + if err := st.SetSyncState(gameID, b.NodeID(), []string{"seed.sav"}, nil); err != nil { + t.Fatal(err) + } + + if !autoSync { + a.API(http.MethodPatch, "/api/games/"+gameID, map[string]any{"autoSync": false}, nil) + } + if err := os.Remove(filepath.Join(a.SaveDir, "auto.sav")); err != nil { + t.Fatal(err) + } + a.API(http.MethodPost, "/api/games/"+gameID+"/sync", nil, nil) // before the watcher records the deletion + time.Sleep(5 * time.Second) + + if got := a.ReadSave("auto.sav"); got != "" { + t.Errorf("the deleted file came back: %q", got) + } + if got := b.ReadSave("auto.sav"); got != "" { + t.Errorf("the deletion never reached B: %q", got) + } +} diff --git a/e2e/emptied_folder_test.go b/e2e/emptied_folder_test.go index bc1fcb09..26bb26a8 100644 --- a/e2e/emptied_folder_test.go +++ b/e2e/emptied_folder_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/testutil" ) @@ -360,3 +361,47 @@ func TestEmptiedFolder_ASyncStartedDuringAWriteIsStillHeld(t *testing.T) { t.Errorf("the emptied device says %+v; want the game held back over 3 files", e) } } + +// "Put them back" with no snapshot fetches the files even when a lost report +// has left the base behind. The push-proof repair records what both devices +// held; while the game is held as emptied it must leave alone the record put +// back takes the files out of, or they read as deleted here and the other +// device loses them too. +func TestEmptiedFolder_PutBackSurvivesALostReport(t *testing.T) { + a, b, gameID := emptiedAndHeld(t, "Emptied Lost Report Game") + var snaps map[string]struct { + Branches map[string]struct { + Snapshots []struct { + ID string `json:"id"` + } `json:"snapshots"` + } `json:"branches"` + } + a.API(http.MethodGet, "/api/games", nil, &snaps) + for _, br := range snaps[gameID].Branches { + for _, s := range br.Snapshots { + if err := a.Daemon.Store.SetSnapshotCheck(s.ID, time.Now().UnixMilli(), "damaged for this test"); err != nil { + t.Fatal(err) + } + } + } + a.API(http.MethodPost, "/api/games/"+gameID+"/emptied", map[string]string{"answer": "restore"}, nil) + // What a lost report leaves: the push still on record, and the base + // behind what the peer holds. + held, err := delta.BuildManifest(b.SaveDir) + if err != nil { + t.Fatal(err) + } + if err := a.Daemon.Store.SetAgreedHash(gameID, b.NodeID(), "a base from before"); err != nil { + t.Fatal(err) + } + if err := a.Daemon.Store.SetPushedHash(gameID, b.NodeID(), held.ManifestHash()); err != nil { + t.Fatal(err) + } + if a.Daemon.Store.GetPushedHash(gameID, b.NodeID()) == "" { + t.Fatal("setup: the push record did not stick") + } + a.APIStatus(http.MethodPost, "/api/games/"+gameID+"/sync", nil, nil) + syncBothWays(t, gameID, a, b) + stillHasIt(t, b, "the other device") + stillHasIt(t, a, "the emptied device, from the other") +} diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 486b8c09..18fdf7ba 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -714,8 +714,27 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // that, a record left over from an earlier push would match again if the // peer ever returned to that state, dragging the base backwards onto it // and skipping a conflict that a later agreement had earned. + // + // The record of which files both sides hold is repaired with it: the + // report is also what records that, so a lost one left a file this device + // had just handed over out of it. Deleted here before the watcher noted + // the deletion, that file read as one the peer had and this device never + // did, and it was fetched straight back — the deletion undone, on Windows + // CI as TestWatchAutoSyncChain's "deletion never auto-synced to B". Both + // held exactly the pushed state, so its files are what was shared — the + // repair extra locations make too (multiroot.go). + // + // Not while the game is held as emptied (hold.go). That record is then + // kept on purpose: "put them back" with no snapshot to restore takes the + // files out of it, so that the next sync fetches them from the peer. + // Written back here, they read as deleted on this device instead, and the + // deletions went to the peer — both devices lost the save + // (TestEmptiedFolder_PutBackWithoutASnapshotFetchesThem on Linux CI). if pushed := e.Store.GetPushedHash(gameID, peer.ID); pushed != "" { if remoteHash := remoteData.Manifest.ManifestHash(); agreedHash != remoteHash && remoteHash == pushed { + if _, held, err := e.Store.GetDeletionHold(gameID); err == nil && !held { + e.persistLineage(gameID, peer.ID, remoteData.Manifest, remoteData.Manifest) + } _ = e.Store.SetAgreedHash(gameID, peer.ID, remoteHash) agreedHash = remoteHash } From c8ad6124c7eed24adf2a86ad5da67e0352b14cfa Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 15 Oct 2026 14:56:42 +0000 Subject: [PATCH 75/75] sync: an emptied location's put-back survives the push-proof repair The push-proof repair for extra locations (first commit here) records the pushed state's files as shared. While the game is held as emptied that record is kept on purpose: "put them back" with no snapshot takes the location's files out of it so the next sync fetches them from the peer. Written back by the repair, they read as deleted on this device instead, the deletions went to the peer, and both devices lost the location's files. The repair now leaves the lineage alone while the game is held as emptied. Found while checking the same repair for the main folder, which had the same flaw. Test: TestMultiRootScenario_PutBackOfAnEmptiedLocationSurvivesALostReport (fails 3/3 without this: the location empty on both devices). Fork adaptation: applied as is; the fork already has the location repair from #49's first two commits. On fixes-2.4.1 the test fails 3/3: both devices lose the location's files. With the fix it passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/multiroot_scenarios_test.go | 56 ++++++++++++++++++++++++++++ internal/p2p/syncengine/multiroot.go | 11 +++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/e2e/multiroot_scenarios_test.go b/e2e/multiroot_scenarios_test.go index cfef5988..a172f40d 100644 --- a/e2e/multiroot_scenarios_test.go +++ b/e2e/multiroot_scenarios_test.go @@ -1068,3 +1068,59 @@ func TestMultiRootScenario_ADeletionSurvivesALateRefresh(t *testing.T) { t.Errorf("the deleted file came back here: %q", got) } } + +// "Put them back" for an emptied extra location, with no snapshot to restore, +// fetches its files even when a lost report has left the location's base +// behind. The push-proof repair records what both devices held; while the +// game is held as emptied it must leave alone the lineage put back takes the +// files out of, or they read as deleted here and the peer loses them too. +func TestMultiRootScenario_PutBackOfAnEmptiedLocationSurvivesALostReport(t *testing.T) { + files := map[string]string{"settings.ini": "shared v1", "keys.cfg": "jump=space"} + a, b, gameID, aConfig, bConfig := twoLocationPair(t, "LocPutBack", files) + testutil.SettleSync(t, gameID, a, b) + + emptyFolder(t, aConfig) + syncBothWays(t, gameID, a, b) + if e := emptiedState(t, a, gameID); e == nil { + t.Fatal("setup: emptying the location did not hold the game back") + } + var snaps map[string]struct { + Branches map[string]struct { + Snapshots []struct { + ID string `json:"id"` + } `json:"snapshots"` + } `json:"branches"` + } + a.API(http.MethodGet, "/api/games", nil, &snaps) + for _, br := range snaps[gameID].Branches { + for _, s := range br.Snapshots { + if err := a.Daemon.Store.SetSnapshotCheck(s.ID, time.Now().UnixMilli(), "damaged for this test"); err != nil { + t.Fatal(err) + } + } + } + a.API(http.MethodPost, "/api/games/"+gameID+"/emptied", map[string]string{"answer": "restore"}, nil) + + // What a lost report leaves for the location: the base behind what the + // peer holds, and the push still on record. + m, err := delta.BuildManifest(bConfig) + if err != nil { + t.Fatal(err) + } + if err := a.Daemon.Store.SetAgreedHashForRoot(gameID, b.NodeID(), "config", "a base from before"); err != nil { + t.Fatal(err) + } + if err := a.Daemon.Store.SetPushedHashForRoot(gameID, b.NodeID(), "config", m.RootHash(delta.PrimaryRoot)); err != nil { + t.Fatal(err) + } + + syncBothWays(t, gameID, a, b) + for rel, want := range files { + if got := readIn(bConfig, rel); got != want { + t.Errorf("the other device: config/%s is %q, want %q", rel, got, want) + } + if got := readIn(aConfig, rel); got != want { + t.Errorf("the emptied device, from the other: config/%s is %q, want %q", rel, got, want) + } + } +} diff --git a/internal/p2p/syncengine/multiroot.go b/internal/p2p/syncengine/multiroot.go index 1b4127cf..e336929b 100644 --- a/internal/p2p/syncengine/multiroot.go +++ b/internal/p2p/syncengine/multiroot.go @@ -131,11 +131,20 @@ func (e *Engine) syncOneRoot(ctx context.Context, gameID string, game store.Game // (TestMultiRootScenario_ADeletionSurvivesALostReport). Both held exactly // the pushed state, so its files are the lineage. // + // Not while the game is held as emptied (hold.go): the lineage is then + // kept on purpose. "Put them back" with no snapshot takes the files out of + // it so that the next sync fetches them; written back here, they read as + // deleted on this device, and the deletions went to the peer — both + // devices lost the location's files + // (TestMultiRootScenario_PutBackOfAnEmptiedLocationSurvivesALostReport). + // // Both hashes are of the filtered view, which is what the push recorded. if pushed := e.Store.GetPushedHashForRoot(gameID, peer.ID, sr.root.Name); pushed != "" { if remoteHash := remote.RootHash(delta.PrimaryRoot); remoteHash == pushed && e.Store.GetAgreedHashForRoot(gameID, peer.ID, sr.root.Name) != remoteHash { - e.persistRootLineage(gameID, peer.ID, sr.root.Name, remote, remote) + if _, held, err := e.Store.GetDeletionHold(gameID); err == nil && !held { + e.persistRootLineage(gameID, peer.ID, sr.root.Name, remote, remote) + } _ = e.Store.SetAgreedHashForRoot(gameID, peer.ID, sr.root.Name, remoteHash) } }