Skip to content

Data loss: a stale second tab overwrites the server copy (logged workout deleted) instead of triggering a conflict #283

Description

@danielkleinert

What did you do?

Signed in with a profile, with openGym open in two tabs of the same browser.

  1. Open openGym in Tab A. Leave it open.
  2. Open openGym in Tab B in the same browser, same profile.
  3. In Tab B (or on another device, so that Tab B pulls it), make a change that syncs, e.g. finish a workout. The server revision advances.
  4. Switch back to Tab A without reloading it. Wait past the 30 s poll. Tab A still shows the old data; the workout is not there.
  5. In Tab A, make any change: toggle a setting, edit the plan, or send the Coach a message.
  6. Reload any tab or open another device: the workout from step 3 is gone from the server. Any plan edits made after Tab A last loaded are reverted too.

What did you expect to happen?

Tab A's push should be refused with a 409, because Tab A's data is based on an older revision. The client should then merge, which keeps both sides' workouts. That is what happens when the stale copy is on a second device instead of a second tab.

What happened instead?

Tab A's push is accepted (200), and the server document is replaced wholesale with Tab A's stale in-memory state. A completed, already-synced workout was permanently deleted from the server, and a plan change was reverted. There was no conflict, no merge and no warning.

Why it happens

All references are to frontend/src/store/useStore.js and api/server.js at v1.3.8 (f91cde1).

  1. The conflict marker is shared between tabs, but the state is per tab.
  • Each tab holds its own in-memory state S, which is what it renders and pushes.
  • The sync marker gym_sync ({rev, ts}) lives in localStorage and is shared by every tab of the origin (readSync/writeSync, lines 121–122).
  • When Tab B pulls or pushes, it advances the shared marker to the current server revision. Tab A's S does not change.
  1. A stale tab never notices it is stale.
  • The 30 s poll (checkRev, ~line 185) compares the server's revision with the shared marker: if (rev !== sync.rev) return get().pullState().
  • Tab B keeps that marker current, so Tab A's check always matches and Tab A never pulls.
  • The only cross-tab storage listener (line 292) handles gym_owner only. Changes to gym_state_v1 / gym_sync made by other tabs are ignored.
  1. A stale tab's push passes the server's conflict check.
  • doPush (line 217) sends body.baseRev = sync.rev (line 222), again from the shared marker, together with Tab A's stale S.
  • The server's check (api/server.js line 936, body.baseRev !== curRev) passes because the borrowed revision is current.
  • The document is written as-is: no 409, so the client-side merge (sync-merge.js) never runs.
  1. The stale tab also overwrites the shared local copy.
  • persist (line 145) writes Tab A's S to gym_state_v1.
  • The shared local copy is now stale as well: it contains "_rev":36 with gym_sync.rev 58.

How are you running openGym?

Self-hosted (docker compose)

Browser & OS

Safari 27

Is this about login / passkeys?

  • Yes, this is a login/passkey issue and I've already checked RP_ID/ORIGIN

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions