Skip to content

Commit 0922ca5

Browse files
committed
store: Have UpdateMachine.load check account still exists after async gaps
Fixes: #1354
1 parent ef8664d commit 0922ca5

File tree

2 files changed

+105
-31
lines changed

2 files changed

+105
-31
lines changed

lib/model/store.dart

+38-18
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

+67-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';
@@ -157,6 +158,42 @@ void main() {
157158
await check(future).throws<AccountNotFoundException>();
158159
}));
159160

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

182242
// TODO test insertAccount
@@ -278,10 +338,10 @@ void main() {
278338
checkGlobalStore(globalStore, eg.selfAccount.id,
279339
expectAccount: true, expectStore: false);
280340

281-
// assert(globalStore.useCachedApiConnections);
341+
assert(globalStore.useCachedApiConnections);
282342
// Cache a connection and get this reference to it,
283343
// so we can check later that it gets closed.
284-
// final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
344+
final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
285345

286346
globalStore.prepareRegisterQueueResponse = (connection) {
287347
connection.prepare(
@@ -301,14 +361,11 @@ void main() {
301361
expectAccount: false, expectStore: false);
302362
check(notifyCount).equals(1);
303363

304-
// Actually throws a null-check error; that's the bug #1354.
305-
// TODO(#1354) should specifically throw AccountNotFoundException
306-
await check(loadingFuture).throws();
364+
await check(loadingFuture).throws<AccountNotFoundException>();
307365
checkGlobalStore(globalStore, eg.selfAccount.id,
308366
expectAccount: false, expectStore: false);
309367
check(notifyCount).equals(1); // no extra notify
310-
// TODO(#1354) uncomment
311-
// check(connection).isOpen.isFalse();
368+
check(connection).isOpen.isFalse();
312369

313370
check(globalStore.debugNumPerAccountStoresLoading).equals(0);
314371
});
@@ -1051,10 +1108,7 @@ void main() {
10511108
async.flushTimers();
10521109
// Reload never succeeds and there are no unhandled errors.
10531110
check(globalStore.perAccountSync(eg.selfAccount.id)).isNull();
1054-
}),
1055-
// An unhandled error is actually the bug #1354, so skip for now
1056-
// TODO(#1354) unskip
1057-
skip: true);
1111+
}));
10581112

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

0 commit comments

Comments
 (0)