fix(sync): fix stuck-on-Connecting (wiped sync code) + device-role classification#10
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 flow —
connect()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.Root cause #1 —
connect()bails, stuck on "Connecting…"The sync code (
notes-sync-code) is vault-encrypted; its plaintext only ever lives in the in-memorycachedSyncCode. On enabling sync:enableSync(code)setscachedSyncCode = codeand firessecureSet(code)without awaiting — the encrypt →localStorage.setItemis deferred behind acrypto.subtle.encryptawait.startSync()then immediately callsloadSyncCode(), whose first synchronous act islocalStorage.getItem('notes-sync-code')— which runs before step 1's write lands. On a fresh device that read isnull.loadSyncCodedidcachedSyncCode = val ?? '', overwriting the good code with''.connect()readgetSyncCode()→''→ hitif (!syncCode) returnand 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.loadSyncCodewas swallowed bystartSync's.catch(), soconnect()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(): returncachedSyncCode ?? ''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.cursoronly advances when a device pushes/acks ops — but the device that generated the code and owns the notes hascursor === 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) onjoin; 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— robustloadSyncCode/getSyncCode; self-healingconnect();buildJoinPayload()carryinghasData(used at both join sites).server/src/index.ts— readmsg.hasData; factor it intoisNewDevice; protocol doc comment.Where the approval prompt appears (for the user)
On the device that already holds your notes, in two places:
Both devices must be open at the same time for the initial copy (the relay only bridges two live devices).
Verification
enableSync→startSync/loadSyncCode(no longer clobbers) →connect()(opens socket) →join(hasData) → serverwelcome(isNewDevicecorrect) → new-device picker →request-transfer-from→transfer-requested→pendingTransfer→ modal/card.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