Skip to content

Commit 6c12175

Browse files
committed
store: Have UpdateMachine.load check account still exists after async gaps
Fixes: #1354
1 parent 68cd773 commit 6c12175

File tree

2 files changed

+108
-31
lines changed

2 files changed

+108
-31
lines changed

lib/model/store.dart

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ abstract class GlobalStore extends ChangeNotifier {
175175
///
176176
/// The account for `accountId` must exist.
177177
///
178+
/// Throws [AccountNotFoundException] if after any async gap
179+
/// the account has been removed.
180+
///
178181
/// This method should be called only by the implementation of [perAccount].
179182
/// Other callers interested in per-account data should use [perAccount]
180183
/// and/or [perAccountSync].
@@ -189,38 +192,30 @@ abstract class GlobalStore extends ChangeNotifier {
189192
// The API key is invalid and the store can never be loaded
190193
// unless the user retries manually.
191194
final account = getAccount(accountId);
192-
if (account == null) {
193-
// The account was logged out during `await doLoadPerAccount`.
194-
// Here, that seems possible only by the user's own action;
195-
// the logout can't have been done programmatically.
196-
// Even if it were, it would have come with its own UI feedback.
197-
// Anyway, skip showing feedback, to not be confusing or repetitive.
198-
throw AccountNotFoundException();
199-
}
195+
assert(account != null); // doLoadPerAccount would have thrown AccountNotFoundException
200196
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
201197
reportErrorToUserModally(
202198
zulipLocalizations.errorCouldNotConnectTitle,
203199
message: zulipLocalizations.errorInvalidApiKeyMessage(
204-
account.realmUrl.toString()));
200+
account!.realmUrl.toString()));
205201
await logOutAccount(this, accountId);
206202
throw AccountNotFoundException();
207203
default:
208204
rethrow;
209205
}
210206
}
211-
if (!_accounts.containsKey(accountId)) {
212-
// TODO(#1354): handle this earlier
213-
// [removeAccount] was called during [doLoadPerAccount].
214-
store.dispose();
215-
throw AccountNotFoundException();
216-
}
207+
// doLoadPerAccount would have thrown AccountNotFoundException
208+
assert(_accounts.containsKey(accountId));
217209
return store;
218210
}
219211

220212
/// Load per-account data for the given account, unconditionally.
221213
///
222214
/// The account for `accountId` must exist.
223215
///
216+
/// Throws [AccountNotFoundException] if after any async gap
217+
/// the account has been removed.
218+
///
224219
/// This method should be called only by [loadPerAccount].
225220
Future<PerAccountStore> doLoadPerAccount(int accountId);
226221

@@ -956,13 +951,26 @@ class UpdateMachine {
956951
///
957952
/// The account for `accountId` must exist.
958953
///
954+
/// Throws [AccountNotFoundException] if after any async gap
955+
/// the account has been removed.
956+
///
959957
/// In the future this might load an old snapshot from local storage first.
960958
static Future<UpdateMachine> load(GlobalStore globalStore, int accountId) async {
961959
Account account = globalStore.getAccount(accountId)!;
962960
final connection = globalStore.apiConnectionFromAccount(account);
963961

962+
void stopAndThrowIfNoAccount() {
963+
final account = globalStore.getAccount(accountId);
964+
if (account == null) {
965+
assert(debugLog('Account logged out during UpdateMachine.load'));
966+
connection.close();
967+
throw AccountNotFoundException();
968+
}
969+
}
970+
964971
final stopwatch = Stopwatch()..start();
965-
final initialSnapshot = await _registerQueueWithRetry(connection);
972+
final initialSnapshot = await _registerQueueWithRetry(connection,
973+
stopAndThrowIfNoAccount: stopAndThrowIfNoAccount);
966974
final t = (stopwatch..stop()).elapsed;
967975
assert(debugLog("initial fetch time: ${t.inMilliseconds}ms"));
968976

@@ -1004,13 +1012,20 @@ class UpdateMachine {
10041012

10051013
bool _disposed = false;
10061014

1015+
/// Make the register-queue request, with retries.
1016+
///
1017+
/// After each async gap, calls [stopAndThrowIfNoAccount].
10071018
static Future<InitialSnapshot> _registerQueueWithRetry(
1008-
ApiConnection connection) async {
1019+
ApiConnection connection, {
1020+
required void Function() stopAndThrowIfNoAccount,
1021+
}) async {
10091022
BackoffMachine? backoffMachine;
10101023
while (true) {
1024+
InitialSnapshot? result;
10111025
try {
1012-
return await registerQueue(connection);
1026+
result = await registerQueue(connection);
10131027
} catch (e, s) {
1028+
stopAndThrowIfNoAccount();
10141029
// TODO(#890): tell user if initial-fetch errors persist, or look non-transient
10151030
switch (e) {
10161031
case HttpException(httpStatus: 401):
@@ -1025,8 +1040,13 @@ class UpdateMachine {
10251040
}
10261041
assert(debugLog('Backing off, then will retry…'));
10271042
await (backoffMachine ??= BackoffMachine()).wait();
1043+
stopAndThrowIfNoAccount();
10281044
assert(debugLog('… Backoff wait complete, retrying initial fetch.'));
10291045
}
1046+
if (result != null) {
1047+
stopAndThrowIfNoAccount();
1048+
return result;
1049+
}
10301050
}
10311051
}
10321052

test/model/store_test.dart

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:fake_async/fake_async.dart';
66
import 'package:flutter/foundation.dart';
77
import 'package:http/http.dart' as http;
88
import 'package:test/scaffolding.dart';
9+
import 'package:zulip/api/backoff.dart';
910
import 'package:zulip/api/core.dart';
1011
import 'package:zulip/api/model/events.dart';
1112
import 'package:zulip/api/model/model.dart';
@@ -157,6 +158,44 @@ void main() {
157158
await check(future).throws<AccountNotFoundException>();
158159
}));
159160

161+
test('GlobalStore.perAccount loading succeeds', () => awaitFakeAsync((async) async {
162+
final globalStore = UpdateMachineTestGlobalStore(
163+
globalSettings: eg.globalSettings(), accounts: [eg.selfAccount]);
164+
final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
165+
166+
NotificationService.instance.token = ValueNotifier('asdf');
167+
addTearDown(NotificationService.debugReset);
168+
169+
final future = globalStore.perAccount(eg.selfAccount.id);
170+
check(connection.takeRequests()).length.equals(1); // register request
171+
await future;
172+
// poll, server-emoji-data, register-token requests
173+
check(connection.takeRequests()).length.equals(3);
174+
check(connection).isOpen.isTrue();
175+
}));
176+
177+
test('GlobalStore.perAccount account is logged out while loading; then succeeds', () => awaitFakeAsync((async) async {
178+
final globalStore = UpdateMachineTestGlobalStore(
179+
globalSettings: eg.globalSettings(), accounts: [eg.selfAccount]);
180+
globalStore.prepareRegisterQueueResponse = (connection) =>
181+
connection.prepare(
182+
delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1),
183+
json: eg.initialSnapshot().toJson());
184+
final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
185+
final future = globalStore.perAccount(eg.selfAccount.id);
186+
check(connection.takeRequests()).length.equals(1); // register request
187+
188+
await logOutAccount(globalStore, eg.selfAccount.id);
189+
check(globalStore.takeDoRemoveAccountCalls())
190+
.single.equals(eg.selfAccount.id);
191+
192+
await check(future).throws<AccountNotFoundException>();
193+
check(globalStore.takeDoRemoveAccountCalls()).isEmpty();
194+
// no poll, server-emoji-data, or register-token requests
195+
check(connection.takeRequests()).isEmpty();
196+
check(connection).isOpen.isFalse();
197+
}));
198+
160199
test('GlobalStore.perAccount account is logged out while loading; then fails with HTTP status code 401', () => awaitFakeAsync((async) async {
161200
final globalStore = UpdateMachineTestGlobalStore(
162201
globalSettings: eg.globalSettings(), accounts: [eg.selfAccount]);
@@ -176,8 +215,32 @@ void main() {
176215
check(globalStore.takeDoRemoveAccountCalls()).isEmpty();
177216
// no poll, server-emoji-data, or register-token requests
178217
check(connection.takeRequests()).isEmpty();
179-
// TODO(#1354) uncomment
180-
// check(connection).isOpen.isFalse();
218+
check(connection).isOpen.isFalse();
219+
}));
220+
221+
test('GlobalStore.perAccount account is logged out during transient-error backoff', () => awaitFakeAsync((async) async {
222+
final globalStore = UpdateMachineTestGlobalStore(
223+
globalSettings: eg.globalSettings(), accounts: [eg.selfAccount]);
224+
globalStore.prepareRegisterQueueResponse = (connection) =>
225+
connection.prepare(
226+
delay: Duration(seconds: 1),
227+
httpException: http.ClientException('Oops'));
228+
final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
229+
final future = globalStore.perAccount(eg.selfAccount.id);
230+
BackoffMachine.debugDuration = Duration(seconds: 1);
231+
async.elapse(Duration(milliseconds: 1500));
232+
check(connection.takeRequests()).length.equals(1); // register request
233+
234+
assert(TestGlobalStore.removeAccountDuration < Duration(milliseconds: 500));
235+
await logOutAccount(globalStore, eg.selfAccount.id);
236+
check(globalStore.takeDoRemoveAccountCalls())
237+
.single.equals(eg.selfAccount.id);
238+
239+
await check(future).throws<AccountNotFoundException>();
240+
check(globalStore.takeDoRemoveAccountCalls()).isEmpty();
241+
// no retry-register, poll, server-emoji-data, or register-token requests
242+
check(connection.takeRequests()).isEmpty();
243+
check(connection).isOpen.isFalse();
181244
}));
182245

183246
// TODO test insertAccount
@@ -280,10 +343,10 @@ void main() {
280343
checkGlobalStore(globalStore, eg.selfAccount.id,
281344
expectAccount: true, expectStore: false);
282345

283-
// assert(globalStore.useCachedApiConnections);
346+
assert(globalStore.useCachedApiConnections);
284347
// Cache a connection and get this reference to it,
285348
// so we can check later that it gets closed.
286-
// final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
349+
final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
287350

288351
globalStore.prepareRegisterQueueResponse = (connection) {
289352
connection.prepare(
@@ -303,14 +366,11 @@ void main() {
303366
expectAccount: false, expectStore: false);
304367
check(notifyCount).equals(1);
305368

306-
// Actually throws a null-check error; that's the bug #1354.
307-
// TODO(#1354) should specifically throw AccountNotFoundException
308-
await check(loadingFuture).throws();
369+
await check(loadingFuture).throws<AccountNotFoundException>();
309370
checkGlobalStore(globalStore, eg.selfAccount.id,
310371
expectAccount: false, expectStore: false);
311372
check(notifyCount).equals(1); // no extra notify
312-
// TODO(#1354) uncomment
313-
// check(connection).isOpen.isFalse();
373+
check(connection).isOpen.isFalse();
314374

315375
check(globalStore.debugNumPerAccountStoresLoading).equals(0);
316376
});
@@ -1054,10 +1114,7 @@ void main() {
10541114
async.flushTimers();
10551115
// Reload never succeeds and there are no unhandled errors.
10561116
check(globalStore.perAccountSync(eg.selfAccount.id)).isNull();
1057-
}),
1058-
// An unhandled error is actually the bug #1354, so skip for now
1059-
// TODO(#1354) unskip
1060-
skip: true);
1117+
}));
10611118

10621119
test('new store is not loaded, gets HTTP 401 error instead', () => awaitFakeAsync((async) async {
10631120
await prepareReload(async, prepareRegisterQueueResponse: (connection) {

0 commit comments

Comments
 (0)