Skip to content

fix(sync): fix stuck-on-Connecting (wiped sync code) + device-role classification#10

Merged
khokonm merged 2 commits into
mainfrom
fix/sync-connect-stuck-and-roles
Jun 25, 2026
Merged

fix(sync): fix stuck-on-Connecting (wiped sync code) + device-role classification#10
khokonm merged 2 commits into
mainfrom
fix/sync-connect-stuck-and-roles

Conversation

@khokonm

@khokonm khokonm commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Multi-device sync onboarding never completed: the new device sat on "Connecting…" forever and the old device showed nothing. The real blocker is upstream of the approval flowconnect() silently bailed before ever opening the WebSocket because the in-memory sync code got wiped by a race. This PR fixes that, plus the device-role misclassification that would have deadlocked the approval even once connected. One merge makes onboarding work end to end.

Supersedes #9 (its role-classification change is included here). #9 can be closed.

Root cause #1connect() bails, stuck on "Connecting…"

The sync code (notes-sync-code) is vault-encrypted; its plaintext only ever lives in the in-memory cachedSyncCode. On enabling sync:

  1. enableSync(code) sets cachedSyncCode = code and fires secureSet(code) without awaiting — the encrypt → localStorage.setItem is deferred behind a crypto.subtle.encrypt await.
  2. startSync() then immediately calls loadSyncCode(), whose first synchronous act is localStorage.getItem('notes-sync-code') — which runs before step 1's write lands. On a fresh device that read is null.
  3. loadSyncCode did cachedSyncCode = val ?? '', overwriting the good code with ''.
  4. connect() read getSyncCode()'' → hit if (!syncCode) return and returned before opening the socket. Status stayed 'connecting', no socket, no retry, no join.

Result: the new device never connects → never requests a transfer → the old device sees nothing. (It sometimes "worked after a reload" because by then the encrypted code had persisted — classic flakiness.)

Two related latent bugs in the same code:

  • getSyncCode()'s fallback returned the ciphertext (v1:…) as if it were the code.
  • A vault-decrypt throw inside loadSyncCode was swallowed by startSync's .catch(), so connect() never ran.

Fix

  • loadSyncCode(): wrap in try/catch and only adopt a value actually decrypted — never clobber an in-memory code with an empty/failed read.
  • getSyncCode(): return cachedSyncCode ?? '' only; never hand back encrypted ciphertext.
  • connect(): set status, then ensure the code is present (memory → vault reload), and if still missing schedule a retry instead of dead-ending on "Connecting…".

Root cause #2 — both devices think they're "new" (role misclassification)

The server decided "needs a transfer" with isNewDevice = device.cursor === 0 && otherDevices.length > 0. cursor only advances when a device pushes/acks ops — but the device that generated the code and owns the notes has cursor === 0 (new devices get data via transfer, not ops). So once a second device joined, both devices were flagged new, both showed "Choose a source device," and the approve/decline prompt appeared nowhere.

Fix

The client now sends hasData (true when it holds local notes) on join; the server only treats a device as new when !hasData && cursor === 0 && otherDevices.length > 0. The notes-holder is always the source/approver and is eligible for the pending-transfer replay added in #8.

Files changed

  • src/utils/sync.ts — robust loadSyncCode/getSyncCode; self-healing connect(); buildJoinPayload() carrying hasData (used at both join sites).
  • server/src/index.ts — read msg.hasData; factor it into isNewDevice; protocol doc comment.

Where the approval prompt appears (for the user)

On the device that already holds your notes, in two places:

  • A full-screen modal — "Allow sync to ?" (Deny / Approve), shows on any screen.
  • A "Transfer Request" card in Settings → Sync (Approve / Deny).

Both devices must be open at the same time for the initial copy (the relay only bridges two live devices).

Verification

  • Traced the full path: enableSyncstartSync/loadSyncCode (no longer clobbers) → connect() (opens socket) → join (hasData) → server welcome (isNewDevice correct) → new-device picker → request-transfer-fromtransfer-requestedpendingTransfer → modal/card.
  • Not run locally: tsc / build / lint — frontend deps aren't installed in the sandbox and installs are blocked (registry 403); the server has no installed toolchain either. Changes use only existing in-scope symbols.

Manual test plan

  1. Fresh Device A: enable sync, generate a code. Confirm status reaches Connected (not stuck on "Connecting…").
  2. Fresh Device B: enter the code. Confirm B reaches the "Choose a source device" screen (not stuck on "Connecting…").
  3. With both apps open, on B pick A → A shows the approve/decline prompt (full-screen modal + Settings → Sync card).
  4. Approve on A → B receives the notes; Deny → B sees "Transfer denied".
  5. Reload both apps and confirm sync reconnects (Connected) automatically.

khokonm added 2 commits June 25, 2026 14:58
…ecting) + fix device-role classification

Root cause of the perpetual 'Connecting...': the vault-encrypted sync code only
lives in memory (cachedSyncCode). enableSync() sets it but persists async; the
immediately-following loadSyncCode() read localStorage before that write landed
and overwrote the good code with '', so connect() hit 'if (!syncCode) return'
and never opened the WebSocket — no socket, no retry, no join. Hardened:
loadSyncCode never clobbers an in-memory code (and swallows no errors silently),
getSyncCode never returns ciphertext, and connect() reloads the code from the
vault and retries instead of dead-ending.

Also folds in the role fix (was PR #9, now superseded): clients send hasData on
join so the notes-holding device is classified as source/approver instead of a
transfer requester — otherwise both devices showed 'Choose a source device' and
the approval prompt appeared nowhere.
@khokonm khokonm merged commit b7db828 into main Jun 25, 2026
3 checks passed
@khokonm khokonm deleted the fix/sync-connect-stuck-and-roles branch June 25, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant