Skip to content

Commit e0cd3a9

Browse files
chrisbobbegnprice
authored andcommitted
store: Have UpdateMachine.load check account still exists after async gaps
Fixes: #1354
1 parent da56401 commit e0cd3a9

File tree

2 files changed

+90
-31
lines changed

2 files changed

+90
-31
lines changed

lib/model/store.dart

+38-18
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ abstract class GlobalStore extends ChangeNotifier {
152152
///
153153
/// The account for `accountId` must exist.
154154
///
155+
/// Throws [AccountNotFoundException] after an async gap
156+
/// if the account has been removed.
157+
///
155158
/// This method should be called only by the implementation of [perAccount].
156159
/// Other callers interested in per-account data should use [perAccount]
157160
/// and/or [perAccountSync].
@@ -166,38 +169,30 @@ abstract class GlobalStore extends ChangeNotifier {
166169
// The API key is invalid and the store can never be loaded
167170
// unless the user retries manually.
168171
final account = getAccount(accountId);
169-
if (account == null) {
170-
// The account was logged out during `await doLoadPerAccount`.
171-
// Here, that seems possible only by the user's own action;
172-
// the logout can't have been done programmatically.
173-
// Even if it were, it would have come with its own UI feedback.
174-
// Anyway, skip showing feedback, to not be confusing or repetitive.
175-
throw AccountNotFoundException();
176-
}
172+
assert(account != null); // doLoadPerAccount would have thrown AccountNotFoundException
177173
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
178174
reportErrorToUserModally(
179175
zulipLocalizations.errorCouldNotConnectTitle,
180176
message: zulipLocalizations.errorInvalidApiKeyMessage(
181-
account.realmUrl.toString()));
177+
account!.realmUrl.toString()));
182178
await logOutAccount(this, accountId);
183179
throw AccountNotFoundException();
184180
default:
185181
rethrow;
186182
}
187183
}
188-
if (!_accounts.containsKey(accountId)) {
189-
// TODO(#1354): handle this earlier
190-
// [removeAccount] was called during [doLoadPerAccount].
191-
store.dispose();
192-
throw AccountNotFoundException();
193-
}
184+
// doLoadPerAccount would have thrown AccountNotFoundException
185+
assert(_accounts.containsKey(accountId));
194186
return store;
195187
}
196188

197189
/// Load per-account data for the given account ID, unconditionally.
198190
///
199191
/// The account for `accountId` must exist.
200192
///
193+
/// Throws [AccountNotFoundException] after an async gap
194+
/// if the account has been removed.
195+
///
201196
/// This method should be called only by [loadPerAccount].
202197
Future<PerAccountStore> doLoadPerAccount(int accountId);
203198

@@ -921,13 +916,26 @@ class UpdateMachine {
921916
///
922917
/// The account for `accountId` must exist.
923918
///
919+
/// Throws [AccountNotFoundException] after an async gap
920+
/// if the account has been removed.
921+
///
924922
/// In the future this might load an old snapshot from local storage first.
925923
static Future<UpdateMachine> load(GlobalStore globalStore, int accountId) async {
926924
Account account = globalStore.getAccount(accountId)!;
927925
final connection = globalStore.apiConnectionFromAccount(account);
928926

927+
void stopAndThrowIfNoAccount() {
928+
final account = globalStore.getAccount(accountId);
929+
if (account == null) {
930+
assert(debugLog('Account logged out during UpdateMachine.load'));
931+
connection.close();
932+
throw AccountNotFoundException();
933+
}
934+
}
935+
929936
final stopwatch = Stopwatch()..start();
930-
final initialSnapshot = await _registerQueueWithRetry(connection);
937+
final initialSnapshot = await _registerQueueWithRetry(connection,
938+
stopAndThrowIfNoAccount: stopAndThrowIfNoAccount);
931939
final t = (stopwatch..stop()).elapsed;
932940
assert(debugLog("initial fetch time: ${t.inMilliseconds}ms"));
933941

@@ -969,13 +977,20 @@ class UpdateMachine {
969977

970978
bool _disposed = false;
971979

980+
/// Make the register-queue request, with retries.
981+
///
982+
/// After each async gap, calls [stopAndThrowIfNoAccount].
972983
static Future<InitialSnapshot> _registerQueueWithRetry(
973-
ApiConnection connection) async {
984+
ApiConnection connection, {
985+
required void Function() stopAndThrowIfNoAccount,
986+
}) async {
974987
BackoffMachine? backoffMachine;
975988
while (true) {
989+
InitialSnapshot? result;
976990
try {
977-
return await registerQueue(connection);
991+
result = await registerQueue(connection);
978992
} catch (e, s) {
993+
stopAndThrowIfNoAccount();
979994
// TODO(#890): tell user if initial-fetch errors persist, or look non-transient
980995
switch (e) {
981996
case HttpException(httpStatus: 401):
@@ -990,8 +1005,13 @@ class UpdateMachine {
9901005
}
9911006
assert(debugLog('Backing off, then will retry…'));
9921007
await (backoffMachine ??= BackoffMachine()).wait();
1008+
stopAndThrowIfNoAccount();
9931009
assert(debugLog('… Backoff wait complete, retrying initial fetch.'));
9941010
}
1011+
if (result != null) {
1012+
stopAndThrowIfNoAccount();
1013+
return result;
1014+
}
9951015
}
9961016
}
9971017

test/model/store_test.dart

+52-13
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';
@@ -130,6 +131,27 @@ void main() {
130131
await check(future).throws<AccountNotFoundException>();
131132
}));
132133

134+
test('GlobalStore.perAccount account is logged out while loading; then succeeds', () => awaitFakeAsync((async) async {
135+
final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]);
136+
globalStore.prepareRegisterQueueResponse = (connection) =>
137+
connection.prepare(
138+
delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1),
139+
json: eg.initialSnapshot().toJson());
140+
final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
141+
final future = globalStore.perAccount(eg.selfAccount.id);
142+
check(connection.takeRequests()).length.equals(1); // register request
143+
144+
await logOutAccount(globalStore, eg.selfAccount.id);
145+
check(globalStore.takeDoRemoveAccountCalls())
146+
.single.equals(eg.selfAccount.id);
147+
148+
await check(future).throws<AccountNotFoundException>();
149+
check(globalStore.takeDoRemoveAccountCalls()).isEmpty();
150+
// no poll, server-emoji-data, or register-token requests
151+
check(connection.takeRequests()).isEmpty();
152+
check(connection).isOpen.isFalse();
153+
}));
154+
133155
test('GlobalStore.perAccount account is logged out while loading; then fails with HTTP status code 401', () => awaitFakeAsync((async) async {
134156
final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]);
135157
globalStore.prepareRegisterQueueResponse = (connection) =>
@@ -148,8 +170,31 @@ void main() {
148170
check(globalStore.takeDoRemoveAccountCalls()).isEmpty();
149171
// no poll, server-emoji-data, or register-token requests
150172
check(connection.takeRequests()).isEmpty();
151-
// TODO(#1354) uncomment
152-
// check(connection).isOpen.isFalse();
173+
check(connection).isOpen.isFalse();
174+
}));
175+
176+
test('GlobalStore.perAccount account is logged out during transient-error backoff', () => awaitFakeAsync((async) async {
177+
final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]);
178+
globalStore.prepareRegisterQueueResponse = (connection) =>
179+
connection.prepare(
180+
delay: Duration(seconds: 1),
181+
httpException: http.ClientException('Oops'));
182+
final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
183+
final future = globalStore.perAccount(eg.selfAccount.id);
184+
BackoffMachine.debugDuration = Duration(seconds: 1);
185+
async.elapse(Duration(milliseconds: 1500));
186+
check(connection.takeRequests()).length.equals(1); // register request
187+
188+
assert(TestGlobalStore.removeAccountDuration < Duration(milliseconds: 500));
189+
await logOutAccount(globalStore, eg.selfAccount.id);
190+
check(globalStore.takeDoRemoveAccountCalls())
191+
.single.equals(eg.selfAccount.id);
192+
193+
await check(future).throws<AccountNotFoundException>();
194+
check(globalStore.takeDoRemoveAccountCalls()).isEmpty();
195+
// no retry-register, poll, server-emoji-data, or register-token requests
196+
check(connection.takeRequests()).isEmpty();
197+
check(connection).isOpen.isFalse();
153198
}));
154199

155200
// TODO test insertAccount
@@ -251,10 +296,10 @@ void main() {
251296
checkGlobalStore(globalStore, eg.selfAccount.id,
252297
expectAccount: true, expectStore: false);
253298

254-
// assert(globalStore.useCachedApiConnections);
299+
assert(globalStore.useCachedApiConnections);
255300
// Cache a connection and get this reference to it,
256301
// so we can check later that it gets closed.
257-
// final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
302+
final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
258303

259304
globalStore.prepareRegisterQueueResponse = (connection) {
260305
connection.prepare(
@@ -274,14 +319,11 @@ void main() {
274319
expectAccount: false, expectStore: false);
275320
check(notifyCount).equals(1);
276321

277-
// Actually throws a null-check error; that's the bug #1354.
278-
// TODO(#1354) should specifically throw AccountNotFoundException
279-
await check(loadingFuture).throws();
322+
await check(loadingFuture).throws<AccountNotFoundException>();
280323
checkGlobalStore(globalStore, eg.selfAccount.id,
281324
expectAccount: false, expectStore: false);
282325
check(notifyCount).equals(1); // no extra notify
283-
// TODO(#1354) uncomment
284-
// check(connection).isOpen.isFalse();
326+
check(connection).isOpen.isFalse();
285327

286328
check(globalStore.debugNumPerAccountStoresLoading).equals(0);
287329
});
@@ -1027,10 +1069,7 @@ void main() {
10271069
async.flushTimers();
10281070
// Reload never succeeds and there are no unhandled errors.
10291071
check(globalStore.perAccountSync(eg.selfAccount.id)).isNull();
1030-
}),
1031-
// An unhandled error is actually the bug #1354, so skip for now
1032-
// TODO(#1354) unskip
1033-
skip: true);
1072+
}));
10341073

10351074
test('new store is not loaded, gets HTTP 401 error instead', () => awaitFakeAsync((async) async {
10361075
await prepareReload(async,

0 commit comments

Comments
 (0)