Skip to content

store: Have UpdateMachine.load check account still exists after async gaps #1386

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Mar 13, 2025
Merged
79 changes: 56 additions & 23 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
@@ -147,13 +147,17 @@ abstract class GlobalStore extends ChangeNotifier {
// It's up to us. Start loading.
future = loadPerAccount(accountId);
_perAccountStoresLoading[accountId] = future;
store = await future;
_setPerAccount(accountId, store);
unawaited(_perAccountStoresLoading.remove(accountId));
return store;
try {
store = await future;
_setPerAccount(accountId, store);
return store;
} finally {
unawaited(_perAccountStoresLoading.remove(accountId));
}
}

Future<void> _reloadPerAccount(int accountId) async {
assert(_accounts.containsKey(accountId));
assert(_perAccountStores.containsKey(accountId));
assert(!_perAccountStoresLoading.containsKey(accountId));
final store = await loadPerAccount(accountId);
@@ -169,6 +173,11 @@ abstract class GlobalStore extends ChangeNotifier {

/// Load per-account data for the given account, unconditionally.
///
/// The account for `accountId` must exist.
Copy link
Member

Choose a reason for hiding this comment

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

(This is fine to add, though.)

///
/// Throws [AccountNotFoundException] if after any async gap
/// the account has been removed.
///
/// This method should be called only by the implementation of [perAccount].
/// Other callers interested in per-account data should use [perAccount]
/// and/or [perAccountSync].
@@ -183,36 +192,30 @@ abstract class GlobalStore extends ChangeNotifier {
// The API key is invalid and the store can never be loaded
// unless the user retries manually.
final account = getAccount(accountId);
if (account == null) {
// The account was logged out during `await doLoadPerAccount`.
// Here, that seems possible only by the user's own action;
// the logout can't have been done programmatically.
// Even if it were, it would have come with its own UI feedback.
// Anyway, skip showing feedback, to not be confusing or repetitive.
throw AccountNotFoundException();
}
assert(account != null); // doLoadPerAccount would have thrown AccountNotFoundException
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
reportErrorToUserModally(
zulipLocalizations.errorCouldNotConnectTitle,
message: zulipLocalizations.errorInvalidApiKeyMessage(
account.realmUrl.toString()));
account!.realmUrl.toString()));
await logOutAccount(this, accountId);
throw AccountNotFoundException();
default:
rethrow;
}
}
if (!_accounts.containsKey(accountId)) {
// TODO(#1354): handle this earlier
// [removeAccount] was called during [doLoadPerAccount].
store.dispose();
throw AccountNotFoundException();
}
// doLoadPerAccount would have thrown AccountNotFoundException
assert(_accounts.containsKey(accountId));
return store;
}

/// Load per-account data for the given account, unconditionally.
///
/// The account for `accountId` must exist.
Copy link
Member

Choose a reason for hiding this comment

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

Making this explicit in dartdocs seems useful; I think this also applies to removeAccount?

Copy link
Member

Choose a reason for hiding this comment

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

Thinking about the validity of the accountId, I feel that this is analogous to memory management — an object is allocated some memory, and we have pointers to it in the data store; freeing that object leaves the pointers dangling, hence the need for null-checks and assertions.

I vaguely feel that we can make stopAndThrowIfNoAccount available as a helper similar to getAccount where the account is required, and only check for its existence on use, not after async gaps, but I'm not sure if that will be cleaner/easier to maintain.

Copy link
Collaborator Author

@chrisbobbe chrisbobbe Mar 8, 2025

Choose a reason for hiding this comment

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

Hmm, I think I wouldn't be satisfied with an approach like that.

Within some synchronous code, say point A is where we retrieve the Account data (getAccount), and points B, C, and D are where we need to make decisions about it or pass it across some boundary where point A couldn't go (like if we call a platform API). I'd be happier if we could freely put point A where it makes the most sense for code organization, which will depend on the context; maybe we want to shorten the paths to B, C, and D, or maybe to put it neatly at the top of a function body. If the placement of point A affects behavior (because that's where we do the null-check), It feels much less flexible and harder to reason about. We can get that flexibility by doing stopAndThrowIfNoAccount after the async gaps.

only check for its existence on use

This wouldn't answer the need to interrupt the retry loop in _registerQueueWithRetry (the symptom I mentioned at #1354 (comment) ). We need to know if the account has been logged out, to stop retrying in that case, but we don't actually use the account's data.

Copy link
Member

Choose a reason for hiding this comment

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

Bumping #1386 (comment); I guess we still want to mention the constraint to its dartdoc?


Thanks for the explanation. I think Account will go well with a version of use_build_context_synchronously, if we have a way to annotate this type somehow.

///
/// Throws [AccountNotFoundException] if after any async gap
/// the account has been removed.
///
/// This method should be called only by [loadPerAccount].
Future<PerAccountStore> doLoadPerAccount(int accountId);

@@ -263,6 +266,8 @@ abstract class GlobalStore extends ChangeNotifier {
Future<void> doUpdateAccount(int accountId, AccountsCompanion data);

/// Remove an account from the store.
///
/// The account for `accountId` must exist.
Future<void> removeAccount(int accountId) async {
assert(_accounts.containsKey(accountId));
await doRemoveAccount(accountId);
@@ -941,15 +946,31 @@ class UpdateMachine {
store.updateMachine = this;
}

/// Load the user's data from the server, and start an event queue going.
/// Load data for the given account from the server,
/// and start an event queue going.
///
/// The account for `accountId` must exist.
///
/// Throws [AccountNotFoundException] if after any async gap
/// the account has been removed.
///
/// In the future this might load an old snapshot from local storage first.
static Future<UpdateMachine> load(GlobalStore globalStore, int accountId) async {
Account account = globalStore.getAccount(accountId)!;
final connection = globalStore.apiConnectionFromAccount(account);

void stopAndThrowIfNoAccount() {
final account = globalStore.getAccount(accountId);
if (account == null) {
assert(debugLog('Account logged out during UpdateMachine.load'));
connection.close();
throw AccountNotFoundException();
}
}

final stopwatch = Stopwatch()..start();
final initialSnapshot = await _registerQueueWithRetry(connection);
final initialSnapshot = await _registerQueueWithRetry(connection,
stopAndThrowIfNoAccount: stopAndThrowIfNoAccount);
final t = (stopwatch..stop()).elapsed;
assert(debugLog("initial fetch time: ${t.inMilliseconds}ms"));

Copy link
Member

Choose a reason for hiding this comment

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

Further down there is another async gap at await globalStore.updateAccount; should we call stopAndThrowIfNoAccount there as well?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I believe there isn't a bug that we could catch by doing so.

  1. We hope and expect that await globalStore.updateAccount always fails if the account is removed concurrently. If it doesn't, that's a bug in the database implementation.
  2. If await globalStore.updateAccount succeeds, that means there's still an Account object in GlobalStore._accounts. Since stopAndThrowIfNoAccount looks at GlobalStore._accounts too, it would find it there and wouldn't throw. So even if the (1) bug exists, stopAndThrowIfNoAccount wouldn't help us catch it.

@@ -991,13 +1012,20 @@ class UpdateMachine {

bool _disposed = false;

/// Make the register-queue request, with retries.
///
/// After each async gap, calls [stopAndThrowIfNoAccount].
static Future<InitialSnapshot> _registerQueueWithRetry(
ApiConnection connection) async {
ApiConnection connection, {
required void Function() stopAndThrowIfNoAccount,
}) async {
BackoffMachine? backoffMachine;
while (true) {
InitialSnapshot? result;
try {
return await registerQueue(connection);
result = await registerQueue(connection);
} catch (e, s) {
stopAndThrowIfNoAccount();
// TODO(#890): tell user if initial-fetch errors persist, or look non-transient
switch (e) {
case HttpException(httpStatus: 401):
@@ -1012,8 +1040,13 @@ class UpdateMachine {
}
assert(debugLog('Backing off, then will retry…'));
await (backoffMachine ??= BackoffMachine()).wait();
stopAndThrowIfNoAccount();
assert(debugLog('… Backoff wait complete, retrying initial fetch.'));
}
if (result != null) {
stopAndThrowIfNoAccount();
return result;
}
}
}

5 changes: 5 additions & 0 deletions test/api/fake_api.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:collection';
import 'dart:convert';

import 'package:checks/checks.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:zulip/api/core.dart';
@@ -278,3 +279,7 @@ class FakeApiConnection extends ApiConnection {
);
}
}

extension FakeApiConnectionChecks on Subject<FakeApiConnection> {
Subject<bool> get isOpen => has((x) => x.isOpen, 'isOpen');
}
9 changes: 7 additions & 2 deletions test/example_data.dart
Original file line number Diff line number Diff line change
@@ -981,9 +981,14 @@ PerAccountStore store({
}
const _store = store;

UpdateMachine updateMachine({Account? account, InitialSnapshot? initialSnapshot}) {
UpdateMachine updateMachine({
GlobalStore? globalStore,
Account? account,
InitialSnapshot? initialSnapshot,
}) {
initialSnapshot ??= _initialSnapshot();
final store = _store(account: account, initialSnapshot: initialSnapshot);
final store = _store(globalStore: globalStore,
account: account, initialSnapshot: initialSnapshot);
return UpdateMachine.fromInitialSnapshot(
store: store, initialSnapshot: initialSnapshot);
}
Loading