Skip to content

Commit 174521a

Browse files
Don't crash if Target cannot be persisted (#2944)
1 parent e03614c commit 174521a

File tree

10 files changed

+136
-60
lines changed

10 files changed

+136
-60
lines changed

packages/firestore/.idea/runConfigurations/Unit_Tests.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/firestore/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Unreleased
2+
- [fixed] Firestore now rejects `onSnapshot()` listeners if they cannot be
3+
registered in IndexedDB. Previously, these errors crashed the client.
24
- [fixed] Firestore now rejects write operations if they cannot be persisted
35
in IndexedDB. Previously, these errors crashed the client.
46
- [fixed] Fixed a source of IndexedDB-related crashes for tabs that receive

packages/firestore/src/core/event_manager.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,19 @@ import { EventHandler } from '../util/misc';
2020
import { ObjectMap } from '../util/obj_map';
2121
import { Query } from './query';
2222
import { SyncEngine, SyncEngineListener } from './sync_engine';
23-
import { OnlineState, TargetId } from './types';
24-
import { DocumentViewChange, ChangeType, ViewSnapshot } from './view_snapshot';
23+
import { OnlineState } from './types';
24+
import { ChangeType, DocumentViewChange, ViewSnapshot } from './view_snapshot';
25+
import { logError } from '../util/log';
26+
import { Code, FirestoreError } from '../util/error';
27+
28+
const LOG_TAG = 'EventManager';
2529

2630
/**
2731
* Holds the listeners and the last received ViewSnapshot for a query being
2832
* tracked by EventManager.
2933
*/
3034
class QueryListenersInfo {
31-
viewSnap: ViewSnapshot | null = null;
32-
targetId: TargetId = 0;
35+
viewSnap: ViewSnapshot | undefined = undefined;
3336
listeners: QueryListener[] = [];
3437
}
3538

@@ -59,16 +62,32 @@ export class EventManager implements SyncEngineListener {
5962
this.syncEngine.subscribe(this);
6063
}
6164

62-
listen(listener: QueryListener): Promise<TargetId> {
65+
async listen(listener: QueryListener): Promise<void> {
6366
const query = listener.query;
6467
let firstListen = false;
6568

6669
let queryInfo = this.queries.get(query);
6770
if (!queryInfo) {
6871
firstListen = true;
6972
queryInfo = new QueryListenersInfo();
70-
this.queries.set(query, queryInfo);
7173
}
74+
75+
if (firstListen) {
76+
try {
77+
queryInfo.viewSnap = await this.syncEngine.listen(query);
78+
} catch (e) {
79+
const msg = `Initialization of query '${query}' failed: ${e}`;
80+
logError(LOG_TAG, msg);
81+
if (e.name === 'IndexedDbTransactionError') {
82+
listener.onError(new FirestoreError(Code.UNAVAILABLE, msg));
83+
} else {
84+
throw e;
85+
}
86+
return;
87+
}
88+
}
89+
90+
this.queries.set(query, queryInfo);
7291
queryInfo.listeners.push(listener);
7392

7493
// Run global snapshot listeners if a consistent snapshot has been emitted.
@@ -84,15 +103,6 @@ export class EventManager implements SyncEngineListener {
84103
this.raiseSnapshotsInSyncEvent();
85104
}
86105
}
87-
88-
if (firstListen) {
89-
return this.syncEngine.listen(query).then(targetId => {
90-
queryInfo!.targetId = targetId;
91-
return targetId;
92-
});
93-
} else {
94-
return Promise.resolve(queryInfo.targetId);
95-
}
96106
}
97107

98108
async unlisten(listener: QueryListener): Promise<void> {

packages/firestore/src/core/firestore_client.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,7 @@ export class FirestoreClient {
393393
): QueryListener {
394394
this.verifyNotTerminated();
395395
const listener = new QueryListener(query, observer, options);
396-
this.asyncQueue.enqueueAndForget(() => {
397-
return this.eventMgr.listen(listener);
398-
});
396+
this.asyncQueue.enqueueAndForget(() => this.eventMgr.listen(listener));
399397
return listener;
400398
}
401399

packages/firestore/src/core/sync_engine.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,9 @@ export class SyncEngine implements RemoteSyncer {
213213
/**
214214
* Initiates the new listen, resolves promise when listen enqueued to the
215215
* server. All the subsequent view snapshots or errors are sent to the
216-
* subscribed handlers. Returns the targetId of the query.
216+
* subscribed handlers. Returns the initial snapshot.
217217
*/
218-
async listen(query: Query): Promise<TargetId> {
218+
async listen(query: Query): Promise<ViewSnapshot> {
219219
this.assertSubscribed('listen()');
220220

221221
let targetId;
@@ -249,8 +249,7 @@ export class SyncEngine implements RemoteSyncer {
249249
}
250250
}
251251

252-
this.syncEngineListener!.onWatchChange([viewSnapshot]);
253-
return targetId;
252+
return viewSnapshot;
254253
}
255254

256255
/**

packages/firestore/test/unit/specs/recovery_spec.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { describeSpec, specTest } from './describe_spec';
1919
import { client, spec } from './spec_builder';
2020
import { TimerId } from '../../../src/util/async_queue';
2121
import { Query } from '../../../src/core/query';
22+
import { Code } from '../../../src/util/error';
2223
import { doc, path } from '../../util/helpers';
2324

2425
describeSpec('Persistence Recovery', ['no-ios', 'no-android'], () => {
@@ -162,4 +163,37 @@ describeSpec('Persistence Recovery', ['no-ios', 'no-android'], () => {
162163
.watchAcksFull(query, 2, doc1, doc3)
163164
.expectEvents(query, { metadata: [doc1, doc3] });
164165
});
166+
167+
specTest('Fails targets that cannot be allocated', [], () => {
168+
const query1 = Query.atPath(path('collection1'));
169+
const query2 = Query.atPath(path('collection2'));
170+
const query3 = Query.atPath(path('collection3'));
171+
return spec()
172+
.userListens(query1)
173+
.watchAcksFull(query1, 1)
174+
.expectEvents(query1, {})
175+
.failDatabase()
176+
.userListens(query2)
177+
.expectEvents(query2, { errorCode: Code.UNAVAILABLE })
178+
.recoverDatabase()
179+
.userListens(query3)
180+
.watchAcksFull(query3, 1)
181+
.expectEvents(query3, {});
182+
});
183+
184+
specTest('Can re-add failed target', [], () => {
185+
const query1 = Query.atPath(path('collection1'));
186+
const query2 = Query.atPath(path('collection2'));
187+
return spec()
188+
.userListens(query1)
189+
.watchAcksFull(query1, 1)
190+
.expectEvents(query1, {})
191+
.failDatabase()
192+
.userListens(query2)
193+
.expectEvents(query2, { errorCode: Code.UNAVAILABLE })
194+
.recoverDatabase()
195+
.userListens(query2)
196+
.watchAcksFull(query2, 1)
197+
.expectEvents(query2, {});
198+
});
165199
});

packages/firestore/test/unit/specs/spec_builder.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export class ClientMemoryState {
8989
limboMapping: LimboMap = {};
9090

9191
limboIdGenerator: TargetIdGenerator = TargetIdGenerator.forSyncEngine();
92+
injectFailures = false;
9293

9394
constructor() {
9495
this.reset();
@@ -191,6 +192,14 @@ export class SpecBuilder {
191192
return this.clientState.activeTargets;
192193
}
193194

195+
private get injectFailures(): boolean {
196+
return this.clientState.injectFailures;
197+
}
198+
199+
private set injectFailures(injectFailures: boolean) {
200+
this.clientState.injectFailures = injectFailures;
201+
}
202+
194203
/**
195204
* Exports the spec steps as a JSON object that be used in the spec runner.
196205
*/
@@ -232,18 +241,26 @@ export class SpecBuilder {
232241

233242
const target = query.toTarget();
234243
let targetId: TargetId = 0;
235-
if (this.queryMapping.has(target)) {
236-
targetId = this.queryMapping.get(target)!;
244+
245+
if (this.injectFailures) {
246+
// Return a `userListens()` step but don't advance the target IDs.
247+
this.currentStep = {
248+
userListen: [targetId, SpecBuilder.queryToSpec(query)]
249+
};
237250
} else {
238-
targetId = this.queryIdGenerator.next(target);
239-
}
251+
if (this.queryMapping.has(target)) {
252+
targetId = this.queryMapping.get(target)!;
253+
} else {
254+
targetId = this.queryIdGenerator.next(target);
255+
}
240256

241-
this.queryMapping.set(target, targetId);
242-
this.addQueryToActiveTargets(targetId, query, resumeToken);
243-
this.currentStep = {
244-
userListen: [targetId, SpecBuilder.queryToSpec(query)],
245-
expectedState: { activeTargets: { ...this.activeTargets } }
246-
};
257+
this.queryMapping.set(target, targetId);
258+
this.addQueryToActiveTargets(targetId, query, resumeToken);
259+
this.currentStep = {
260+
userListen: [targetId, SpecBuilder.queryToSpec(query)],
261+
expectedState: { activeTargets: { ...this.activeTargets } }
262+
};
263+
}
247264
return this;
248265
}
249266

@@ -421,6 +438,7 @@ export class SpecBuilder {
421438
/** Fails all database operations until `recoverDatabase()` is called. */
422439
failDatabase(): this {
423440
this.nextStep();
441+
this.injectFailures = true;
424442
this.currentStep = {
425443
failDatabase: true
426444
};
@@ -430,6 +448,7 @@ export class SpecBuilder {
430448
/** Stops failing database operations. */
431449
recoverDatabase(): this {
432450
this.nextStep();
451+
this.injectFailures = false;
433452
this.currentStep = {
434453
failDatabase: false
435454
};

packages/firestore/test/unit/specs/spec_test_components.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ import { Token } from '../../../src/api/credentials';
4848
import { Observer } from '../../../src/core/event_manager';
4949
import { ViewSnapshot } from '../../../src/core/view_snapshot';
5050
import { Query } from '../../../src/core/query';
51-
import { expectFirestoreError } from '../../util/helpers';
5251
import { Mutation } from '../../../src/model/mutation';
52+
import { expect } from 'chai';
5353

5454
/**
5555
* A test-only MemoryPersistence implementation that is able to inject
@@ -367,7 +367,7 @@ export class EventAggregator implements Observer<ViewSnapshot> {
367367
}
368368

369369
error(error: Error): void {
370-
expectFirestoreError(error);
370+
expect(error.name).to.equal('FirebaseError');
371371
this.pushEvent({ query: this.query, error: error as FirestoreError });
372372
}
373373
}

packages/firestore/test/unit/specs/spec_test_runner.ts

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ import {
7676
deletedDoc,
7777
deleteMutation,
7878
doc,
79-
expectFirestoreError,
79+
validateFirestoreError,
8080
filter,
8181
key,
8282
orderBy,
@@ -350,10 +350,16 @@ abstract class TestRunner {
350350
}
351351

352352
private async doListen(listenSpec: SpecUserListen): Promise<void> {
353-
const expectedTargetId = listenSpec[0];
353+
let targetFailed = false;
354+
354355
const querySpec = listenSpec[1];
355356
const query = parseQuery(querySpec);
356-
const aggregator = new EventAggregator(query, this.pushEvent.bind(this));
357+
const aggregator = new EventAggregator(query, e => {
358+
if (e.error) {
359+
targetFailed = true;
360+
}
361+
this.pushEvent(e);
362+
});
357363
// TODO(dimond): Allow customizing listen options in spec tests
358364
const options = {
359365
includeMetadataChanges: true,
@@ -362,27 +368,27 @@ abstract class TestRunner {
362368
const queryListener = new QueryListener(query, aggregator, options);
363369
this.queryListeners.set(query, queryListener);
364370

365-
await this.queue.enqueue(async () => {
366-
const targetId = await this.eventManager.listen(queryListener);
367-
expect(targetId).to.equal(
368-
expectedTargetId,
369-
'targetId assigned to listen'
370-
);
371-
});
371+
await this.queue.enqueue(() => this.eventManager.listen(queryListener));
372372

373-
// Skip the backoff that may have been triggered by a previous call to
374-
// `watchStreamCloses()`.
375-
if (
376-
this.queue.containsDelayedOperation(TimerId.ListenStreamConnectionBackoff)
377-
) {
378-
await this.queue.runDelayedOperationsEarly(
379-
TimerId.ListenStreamConnectionBackoff
380-
);
381-
}
373+
if (targetFailed) {
374+
expect(this.persistence.injectFailures).to.be.true;
375+
} else {
376+
// Skip the backoff that may have been triggered by a previous call to
377+
// `watchStreamCloses()`.
378+
if (
379+
this.queue.containsDelayedOperation(
380+
TimerId.ListenStreamConnectionBackoff
381+
)
382+
) {
383+
await this.queue.runDelayedOperationsEarly(
384+
TimerId.ListenStreamConnectionBackoff
385+
);
386+
}
382387

383-
if (this.isPrimaryClient && this.networkEnabled) {
384-
// Open should always have happened after a listen
385-
await this.connection.waitForWatchOpen();
388+
if (this.isPrimaryClient && this.networkEnabled) {
389+
// Open should always have happened after a listen
390+
await this.connection.waitForWatchOpen();
391+
}
386392
}
387393
}
388394

@@ -955,7 +961,10 @@ abstract class TestRunner {
955961
const expectedQuery = parseQuery(expected.query);
956962
expect(actual.query).to.deep.equal(expectedQuery);
957963
if (expected.errorCode) {
958-
expectFirestoreError(actual.error!);
964+
validateFirestoreError(
965+
mapCodeFromRpcCode(expected.errorCode),
966+
actual.error!
967+
);
959968
} else {
960969
const expectedChanges: DocumentViewChange[] = [];
961970
if (expected.removed) {

packages/firestore/test/util/helpers.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import { ByteString } from '../../src/util/byte_string';
9292
import { PlatformSupport } from '../../src/platform/platform';
9393
import { JsonProtoSerializer } from '../../src/remote/serializer';
9494
import { Timestamp } from '../../src/api/timestamp';
95+
import { Code, FirestoreError } from '../../src/util/error';
9596

9697
/* eslint-disable no-restricted-globals */
9798

@@ -815,8 +816,12 @@ export function expectEqualitySets<T>(
815816
}
816817
}
817818

818-
export function expectFirestoreError(err: Error): void {
819-
expect(err.name).to.equal('FirebaseError');
819+
export function validateFirestoreError(
820+
expectedCode: Code,
821+
actualError: Error
822+
): void {
823+
expect(actualError.name).to.equal('FirebaseError');
824+
expect((actualError as FirestoreError).code).to.equal(expectedCode);
820825
}
821826

822827
export function forEachNumber<V>(

0 commit comments

Comments
 (0)