Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- fixed: Recover WIP login stashes if login-server network requests fails.

## 2.36.0 (2025-11-04)

- added: Added `EdgeSubscribedAddress` type for `onSubscribeAddresses` and `startEngine`.
Expand Down
15 changes: 14 additions & 1 deletion src/core/account/account-pixie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { makePeriodicTask } from '../../util/periodic-task'
import { snooze } from '../../util/snooze'
import { syncLogin } from '../login/login'
import { getStashById } from '../login/login-selectors'
import { waitForPlugins } from '../plugins/plugins-selectors'
import { RootProps, toApiInput } from '../root-pixie'
import {
Expand Down Expand Up @@ -160,9 +161,21 @@ const accountPixie: TamePixie<AccountProps> = combinePixies({
update() {
if (input.props.accountOutput?.accountApi == null) return

const { accountId } = input.props
const { sessionKey } = input.props.state.accounts[accountId]
const { stashTree } = getStashById(
toApiInput(input),
sessionKey.loginId
)
const loginInterval =
stashTree.wipChange != null ? 5000 : SYNC_INTERVAL

dataTask.setDelay(SYNC_INTERVAL)
loginTask.setDelay(loginInterval)

// Start once the EdgeAccount API exists:
dataTask.start({ wait: SYNC_INTERVAL * (1 + Math.random()) })
loginTask.start({ wait: SYNC_INTERVAL * (1 + Math.random()) })
loginTask.start({ wait: loginInterval * (1 + Math.random()) })
},

destroy() {
Expand Down
14 changes: 14 additions & 0 deletions src/core/context/context-pixie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { close, update } from 'yaob'
import { EdgeContext, EdgeLogSettings, EdgeUserInfo } from '../../types/types'
import { makePeriodicTask } from '../../util/periodic-task'
import { shuffle } from '../../util/shuffle'
import { healLogins } from '../login/login'
import { ApiInput, RootProps } from '../root-pixie'
import { makeContextApi } from './context-api'
import {
Expand Down Expand Up @@ -55,6 +56,19 @@ export const context: TamePixie<RootProps> = combinePixies({
}
},

loginHealer(ai: ApiInput) {
let done = false
return {
update() {
if (done) return stopUpdates
done = true
healLogins(ai).catch(error => ai.props.onError(error))
return stopUpdates
},
destroy() {}
}
},

infoFetcher: filterPixie(
(input: ApiInput) => {
async function doInfoSync(): Promise<void> {
Expand Down
3 changes: 2 additions & 1 deletion src/core/login/login-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export const login = buildReducer<LoginState, RootAction, RootState>({
(next: RootState) => next.login.stashes,
(next: RootState) => next.clientInfo,
(appId, stashes, clientInfo): EdgeUserInfo[] => {
function processStash(stashTree: LoginStash): EdgeUserInfo {
function processStash(rootStash: LoginStash): EdgeUserInfo {
const stashTree = rootStash.wipChange ?? rootStash
const { lastLogin, loginId, recovery2Key, username } = stashTree

const stash = searchTree(stashTree, stash => stash.appId === appId)
Expand Down
6 changes: 4 additions & 2 deletions src/core/login/login-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ export function getStashByUsername(
username: string
): LoginStash | undefined {
const { stashes } = ai.props.state.login
for (const stash of stashes) {
for (const rootStash of stashes) {
const stash = rootStash.wipChange ?? rootStash
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong, but it was there to figure out why it wasn't working during sanity test

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@swansontec Should I keep this or should I go with another approach to solve the problem where pin-login will no longer work on the light-account when there is a wipChange and that the login server has?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what you are asking. How is this line wrong?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should have said "I don't know if this is right". I don't know if it's wrong, but it solved the issue with pin-login, but I wasn't sure if this is the correct solution. I felt uncomfortable always assuming wipChange is the correct stash to use.

Now that I think about why I felt uncomfortable, there is a case where pin-login will break if the login-server doesn't have the changes that wipChange has. In this case, pin-login will break because wipChange is the wrong stash according to the login server. So perhaps the correct solution is to pick wipChange depending on if the login-server has the changes or not. This would require that we query the login-server to determine this and then drill this information down to the selector.

if (stash.username === username) return stash
}
}

export function getStashById(ai: ApiInput, loginId: Uint8Array): StashLeaf {
const { stashes } = ai.props.state.login
for (const stashTree of stashes) {
for (const rootStash of stashes) {
const stashTree = rootStash.wipChange ?? rootStash
const stash = searchTree(stashTree, stash =>
verifyData(stash.loginId, loginId)
)
Expand Down
4 changes: 3 additions & 1 deletion src/core/login/login-stash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface LoginStash {
mnemonicBox?: EdgeBox
rootKeyBox?: EdgeBox
syncKeyBox?: EdgeBox
wipChange?: LoginStash
}

/**
Expand Down Expand Up @@ -193,7 +194,8 @@ export const asLoginStash: Cleaner<LoginStash> = asObject({
keyBoxes: asOptional(asArray(asEdgeKeyBox)),
mnemonicBox: asOptional(asEdgeBox),
rootKeyBox: asOptional(asEdgeBox),
syncKeyBox: asOptional(asEdgeBox)
syncKeyBox: asOptional(asEdgeBox),
wipChange: asOptional(raw => asLoginStash(raw))
})

export const wasLoginStash = uncleaner(asLoginStash)
Expand Down
77 changes: 65 additions & 12 deletions src/core/login/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,13 @@ export function applyLoginPayload(
stashTree,
stash => stash.appId === loginReply.appId,
stash => applyLoginPayloadInner(stash, loginKey, loginReply),
(stash, children) => ({ ...stash, children })
(stash, children) => ({
...stash,
children,
// If we hear back from the server, that is authoritative,
// so we can discard our local work-in-progress change:
wipChange: undefined
})
)
}

Expand Down Expand Up @@ -417,26 +423,40 @@ export async function applyKit(
const { stashTree } = getStashById(ai, kit.loginId)
const { deviceDescription } = ai.props.state.login

// Don't make server-side changes if the server path is faked:
if (serverPath !== '') {
const childKey = decryptChildKey(stashTree, sessionKey, kit.loginId)
const request = makeAuthJson(stashTree, childKey)
if (deviceDescription != null) request.deviceDescription = deviceDescription
request.data = kit.server
await loginFetch(ai, serverMethod, serverPath, request)
}

const newStashTree = updateTree<LoginStash, LoginStash>(
stashTree,
stash => verifyData(stash.loginId, kit.loginId),
stash => ({
...stash,
...kit.stash,
children: softCat(stash.children, kit.stash.children),
keyBoxes: softCat(stash.keyBoxes, kit.stash.keyBoxes)
keyBoxes: softCat(stash.keyBoxes, kit.stash.keyBoxes),
wipChange: undefined
}),
(stash, children) => ({ ...stash, children })
(stash, children) => ({ ...stash, children, wipChange: undefined })
)

// Save the WIP change to disk:
if (serverPath !== '') {
stashTree.wipChange = newStashTree
await saveStash(ai, stashTree)
}

// Don't make server-side changes if the server path is faked:
if (serverPath !== '') {
const childKey = decryptChildKey(stashTree, sessionKey, kit.loginId)
const request = makeAuthJson(stashTree, childKey)
if (deviceDescription != null) request.deviceDescription = deviceDescription
request.data = kit.server
try {
await loginFetch(ai, serverMethod, serverPath, request)
} catch (error) {
// If we fail, try to sync to see if the server got it:
await syncLogin(ai, sessionKey).catch(() => {})
throw error
}
}

await saveStash(ai, newStashTree)

return newStashTree
Expand Down Expand Up @@ -586,3 +606,36 @@ export function makeAuthJson(
}
throw new Error('No server authentication methods available')
}

export async function healLogins(ai: ApiInput): Promise<void> {
const { stashes } = ai.props.state.login
for (const stashTree of stashes) {
if (stashTree.wipChange == null) continue

const { syncToken } = stashTree
let isSyncedWithServer = false
if (syncToken != null) {
try {
const reply = await loginFetch(ai, 'POST', '/v2/sync', {
loginId: stashTree.loginId,
syncToken
})
// true means we are current (our syncToken matches the server)
// false means we are stale (server has changes we don't have)
isSyncedWithServer = asBoolean(reply)
} catch (error) {
continue
}
}

if (isSyncedWithServer) {
// Server never got our change, so apply wipChange locally:
const newStash = { ...stashTree.wipChange, wipChange: undefined }
await saveStash(ai, newStash)
} else {
// Server has changes (possibly our wipChange), discard local wipChange:
const newStash = { ...stashTree, wipChange: undefined }
await saveStash(ai, newStash)
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Inverted logic in WIP stash recovery condition

The healLogins function determines whether the server accepted a pending change by inverting the sync response with !asBoolean(reply). However, the /v2/sync endpoint returns true when the client is current/synced, meaning the server has the change. The inversion causes the logic to be backwards: when the server has the change (true), serverHasIt becomes false, and when the server doesn't have it (false), serverHasIt becomes true. This causes pending changes to be discarded when they succeeded and committed when they failed.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed this issue in a fixup! commit

Loading