Page MenuHomePhabricator

Refactor olm accounts lifecycle on web
ClosedPublic

Authored by marcin on Oct 19 2023, 3:34 AM.
Tags
None
Referenced Files
Unknown Object (File)
Sat, Nov 23, 4:24 PM
Unknown Object (File)
Wed, Nov 20, 10:39 PM
Unknown Object (File)
Tue, Nov 19, 9:16 PM
Unknown Object (File)
Sun, Nov 17, 5:14 PM
Unknown Object (File)
Sun, Nov 17, 4:54 PM
Unknown Object (File)
Wed, Nov 6, 9:52 PM
Unknown Object (File)
Sat, Nov 2, 9:04 AM
Unknown Object (File)
Tue, Oct 29, 10:29 AM
Subscribers

Details

Summary

This differential does not introduce new functionality but refactors the code so that we have utilities to ensure that olm data (accounts, keys and sessions) are always there and we don't have to make olm-related function arguments optional. In particular this differential addresses the issue I pointed out in this comment: https://linear.app/comm/issue/ENG-5236/olm-accounts-on-web-not-created-anywhere-outside-of-loginform#comment-7a85dfed.

On one hand this differential indeed introduces significant changes to the code. On the other I am not sure how severe is the issue pointed in the link above and how important this differential actually is. That said if there is noticeable objection from reviewers I am willing to drop this differential as introducing e2e notifs for web is technically possible without it. The only issue is that we might not be able to initialize olm sessions for users that have never logged out from web since February/March 2023 (at that time we introduced olm data in web).

Test Plan
  1. Log out and log in on web. Ensure (using Redux in Developer Tools) that new olm accounts are dispatched in redux.
  2. Using mariadb console set signed_identity_keys field in cookies table to NULL for the cookie associated with currently logged-in user on you r browser.
  3. Reload web app. Using mariadb console ensure that signed_identity_keys field in cookies table to NULL for the cookie associated with currently logged-in user on you r browser is set to a proper JSON again.

Steps above ensure that already existing functionality relying on olm data wasn't broken by current changes.

Diff Detail

Repository
rCOMM Comm
Lint
No Lint Coverage
Unit
No Test Coverage

Event Timeline

Rebase to update commit messages

marcin edited the test plan for this revision. (Show Details)
lib/types/crypto-types.js
35 ↗(On Diff #32184)

Is this $ObjMap basically just $Shape? If not, what's different?

web/account/account-hooks.js
23 ↗(On Diff #32184)

It seems like there's a risk of two of these running simultaneously.

To address this, I think we'll likely need to move this to a context, and set a ref before entering the async block that would prevent the second call from entering its async block.

Separately, we should ask if the same risk occurs in native, and address it if so

web/account/log-in-form.react.js
27–29 ↗(On Diff #32184)

This affect does nothing. You're just declaring a function that is never executed

27–29 ↗(On Diff #32184)

*effect

lib/types/crypto-types.js
35 ↗(On Diff #32184)

It is not. Applying $Shape to CompleteCryptoStore type results in the following type:

export type CompleteCryptoStore = {
  +primaryAccount?: PickledOLMAccount,
  +primaryIdentityKeys?: OLMIdentityKeys,
  +notificationAccount?: PickledOLMAccount,
  +notificationIdentityKeys?: OLMIdentityKeys,
};

Which is different to the original CryptoStore definition. In other words it makes fields optional but what we want it to keep fields required but make them of optional type. Applying $Shape results in many flow errors in some files (redux-setup and crypto-store-reducer).

However I understand the concern since this construct does not look obvious at the first glance. We might consider creating a follow-up diff that introduces some utility that can apply the operation above to any type we want but has more meaningful name.

web/account/account-hooks.js
23 ↗(On Diff #32184)

It seems like there's a risk of two of these running simultaneously.

To address this, I think we'll likely need to move this to a context, and set a ref before entering the async block that would prevent the second call from entering its async block.

I will try to introduce this approach.

Separately, we should ask if the same risk occurs in native, and address it if so

We don't have any similar hook on native since this data are not kept in redux there. We have a JSI call implemented in C++ that idempotently creates olm data and persists them in SQLite. Then there is another JSI call to fetch those data in SQLite.

web/account/log-in-form.react.js
27–29 ↗(On Diff #32184)

You are right - I need to add (..)();. The fact that test plan worked is because this differential also introduces healing the state using socket requests.

web/account/account-hooks.js
23 ↗(On Diff #32184)

I recently was looking around at the equivalent code in native while I was investigating while the notif issue in ENG-5354 was also crashing the app on start.

Regarding the idempotent JSI call that @marcin mentioned, I believe this is initializeCryptoModule. I observed it getting called a lot when the iOS app started. I wonder if adding a caching layer to skip the SQL query would be wise. We would just need to make sure we invalidate this cache appropriately when eg. the user logs out or deletes their account.

ashoat requested changes to this revision.Oct 22 2023, 3:37 PM

Making sure it pops up back in my review queue

This revision now requires changes to proceed.Oct 22 2023, 3:37 PM

Prevent two promises creating crypto store from running at the same time.

ashoat requested changes to this revision.Oct 24 2023, 5:11 AM

Directionally correct, but a ton of small issues...

lib/types/crypto-types.js
35 ↗(On Diff #32316)

In what situation will some of these fields be set, but others not?

If the reason is that you have multiple different actions, should those be merged into a single action?

If we always set this together, it would be better if CryptoStore was just ?CompleteCryptoStore (in that case we'd want to rename the types though)

web/account/account-hooks.js
24 ↗(On Diff #32316)

This type is not very extensible if we ever want to add something to it. I think it would be better for the context to be an object with a single property, which is the function you want

37 ↗(On Diff #32316)

Why is this an async function? You're returning a promise wrapping another promise... Javascript handles this for you, but you shouldn't be doing it either way.

Please read up on await / async in JS... from this and other places, it feels to me that you don't have a full understanding of it.

In general, if you have a function with no await statements, it almost certainly should not be as async function...

59 ↗(On Diff #32316)

Can this be defined via React.useCallback instead? I think it would be better to do that so we can reduce the indentation here

113 ↗(On Diff #32316)

What's the point of this line? It seems like we can leave the promise there without any negative consequences.

This line may potentially introduce a risk where GetOrCreateCryptoStoreProvider gets rerendered first for some other reason before getting rerendered with the updated currentCryptoStore. That could result in the promise getting "restarted" rather than using the already-resolved promise.

115–132 ↗(On Diff #32316)

What happens if something in this promise throws an exception?

If anything, that's probably the case where you want to unset createCryptoStorePromiseRef.current... rather than unsetting it in the success case.

I think that as-written, if something in this promise throws an exception (rejects), then every subsequent invocation will throw the same exception. The user would have to refresh the web app to try again, which is frustrating and not very discoverable on macOS / Windows Electron apps

160 ↗(On Diff #32316)
  1. Is this line necessary?
  2. If it's necessary, why are you calling it "serially"? There is no need to wait on getOrCreateCryptoStore before calling this. I think I've given you this feedback before... I'd appreciate it if you could integrate it into your understanding, and make sure I never have to bring it up again.
web/root.js
41 ↗(On Diff #32316)

Most of our providers are defined in web/app.react.js. Why did you choose to put this one here?

web/selectors/socket-selectors.js
48–49 ↗(On Diff #32316)

Please make this type read-only, and please try to keep this in mind so I don't have to keep bringing it up

This revision now requires changes to proceed.Oct 24 2023, 5:11 AM
lib/types/crypto-types.js
35 ↗(On Diff #32316)

We should have a single action to set entire CryptoStore at once.

web/account/account-hooks.js
113 ↗(On Diff #32316)

On the other hand not unsetting this promise will result in not creating new CryptoStore when the user logs-out and CryptoStore is removed but returning old CryptoStore that was created with cached promise. That is since when the user logs out we will see that CryptoStore became nullish, we will not do early return and check for already existing promise. We will see that the promise is there so we will just return it and never create new cryptoStore.

That said I am starting to doubt the approach where we have a hook that returns CryptoStore or creates and returns it if it wasn't already there (hence working somehow like RAII). I am wondering if we could adopt much simpler approach where the context returns CompleteCryptoStore instead of () => Promise<CompleteCryptoStore> and the provider creates necessary data in useEffect`.

Actually this is what already happens on web. LoginForm performs exactly same action. The disadvantage of the code currently on master is that we don't handel the case of the user that hasa never logged-out and (most importantly) we are forced to tread data in CryptoStore as optional types which I found inconvenient in initial stages of this work. That said changing what the context returns will keep as on pair with current functionality.

160 ↗(On Diff #32316)
  1. Is this line necessary?

I think so - we can't guarantee that getOrCreateCryptoStore will call await initOlm.

  1. If it's necessary, why are you calling it "serially"? There is no need to wait on getOrCreateCryptoStore before calling this. I think I've given you this feedback before... I'd appreciate it if you could integrate it into your understanding, and make sure I never have to bring it up again.

I will change it to use Promise.all()

web/root.js
41 ↗(On Diff #32316)

I have to guarantee that GetOrCreateCryptoStoreContext has set value before Socket is created since Socket requires this context. Putting it anywhere in web/app.react.js will result in an error.

web/account/account-hooks.js
113 ↗(On Diff #32316)

I'm open to the idea of initializing in an effect, but keep in mind that the context will not always be able to return CompleteCryptoStore if the promise has not been resolved yet. As such, we will still need an interface on the context that returns Promise<CompleteCryptoStore>

160 ↗(On Diff #32316)

I think so - we can't guarantee that getOrCreateCryptoStore will call await initOlm.

Really? It seems guaranteed to me...

web/root.js
41 ↗(On Diff #32316)

Thanks for explaining

web/account/account-hooks.js
37 ↗(On Diff #32316)

Without async keyword this function caused flow error since it considered its type to be CryptoStore | Promise<CryptoStore>. This is why I put async keyword in front of this function.

160 ↗(On Diff #32316)

It is not guaranteed. If getOrCreateCryptoStore returned early it wouldn't call await initOlm();.

Refactor context provider to use useMemo

web/account/account-hooks.js
30 ↗(On Diff #32388)

First I tried to set this value using useEffect. However I found that useEffect doesn't get executed before other components try to access the context which results in an error. That is why I decided to opt for useMemo since it is guaranteed to be executed before this component returns. However there is a major drawback pointed by @ashoat here: https://phab.comm.dev/D9532?id=32316#inline-60292. I don't think there is a good way to handle exception in useMemo since function in useMemo shouldn't have side effects. That said this approach is not perfectly textbook. Theoretically we could try{} .. catch{} and return something falsy. However this undermines the entire purpose of this differential which is to enable other components to acquire CryptStore data without considering it optional.

If this solution is not acceptable we might consider abandoning this differential. It is not obligatory for e2e web notifications to work. Without this differential we would have to treat CryptoStore optional and instantiate session with the keyserver only if it is there.

Let promises in getSignedIdentityKeysBlob run simultaneously.

Revert to previous approach, address my feedback, and unset the Promise in an effect if the cryptoStore is unset

Notes –

  1. I haven't tested my changes. Hoping @marcin can cover this
  2. One point of feedback that wasn't addressed is setting the whole CryptoStore with a single action, but @marcin mentioned he has a diff up for this and will add it to the stack shortly

Accepting, but before landing @marcin please address the two points in my last comment

This revision is now accepted and ready to land.Oct 25 2023, 9:13 AM

Keep already implemented logic but adapt to new reudx action.

web/account/log-in-form.react.js
27–29 ↗(On Diff #32421)

I think this can be simplified

Simplify effect in log-in form