Skip to content

Commit 56ada10

Browse files
Don't crash if Target cannot be persisted
1 parent b799fcd commit 56ada10

File tree

8 files changed

+162
-85
lines changed

8 files changed

+162
-85
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 failures crashed the client.
24
- [fixed] Fixed a source of IndexedDB-related crashes for tabs that receive
35
multi-tab notifications while the file system is locked.
46

packages/firestore/src/core/event_manager.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@ import { ObjectMap } from '../util/obj_map';
2121
import { Query } from './query';
2222
import { SyncEngine, SyncEngineListener } from './sync_engine';
2323
import { OnlineState, TargetId } from './types';
24-
import { DocumentViewChange, ChangeType, ViewSnapshot } from './view_snapshot';
24+
import { ChangeType, DocumentViewChange, ViewSnapshot } from './view_snapshot';
25+
import { logError } from '../util/log';
26+
import {
27+
Code,
28+
FirestoreError,
29+
isIndexedDbBrowserException
30+
} from '../util/error';
31+
32+
const LOG_TAG = 'EventManager';
2533

2634
/**
2735
* Holds the listeners and the last received ViewSnapshot for a query being
@@ -59,15 +67,14 @@ export class EventManager implements SyncEngineListener {
5967
this.syncEngine.subscribe(this);
6068
}
6169

62-
listen(listener: QueryListener): Promise<TargetId> {
70+
async listen(listener: QueryListener): Promise<void> {
6371
const query = listener.query;
6472
let firstListen = false;
6573

6674
let queryInfo = this.queries.get(query);
6775
if (!queryInfo) {
6876
firstListen = true;
6977
queryInfo = new QueryListenersInfo();
70-
this.queries.set(query, queryInfo);
7178
}
7279
queryInfo.listeners.push(listener);
7380

@@ -86,13 +93,24 @@ export class EventManager implements SyncEngineListener {
8693
}
8794

8895
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);
96+
try {
97+
queryInfo!.targetId = await this.syncEngine.listen(query);
98+
} catch (e) {
99+
if (isIndexedDbBrowserException(e)) {
100+
logError(LOG_TAG, `Failed to allocate query target: ${e}`);
101+
listener.onError(
102+
new FirestoreError(
103+
Code.UNKNOWN,
104+
'Query allocation failed with environment error'
105+
)
106+
);
107+
return Promise.resolve();
108+
}
109+
throw e;
110+
}
95111
}
112+
113+
this.queries.set(query, queryInfo!);
96114
}
97115

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

packages/firestore/src/util/async_queue.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import { debugAssert, fail } from './assert';
19-
import { Code, FirestoreError } from './error';
19+
import { Code, FirestoreError, isIndexedDbBrowserException } from './error';
2020
import { logDebug, logError } from './log';
2121
import { CancelablePromise, Deferred } from './promise';
2222
import { ExponentialBackoff } from '../remote/backoff';
@@ -338,13 +338,7 @@ export class AsyncQueue {
338338
deferred.resolve();
339339
this.backoff.reset();
340340
} catch (e) {
341-
// We only retry errors that match the ones thrown by IndexedDB (see
342-
// https://developer.mozilla.org/en-US/docs/Web/API/IDBTransaction/error)
343-
if (
344-
(typeof DOMException !== 'undefined' &&
345-
e instanceof DOMException) ||
346-
(typeof DOMError !== 'undefined' && e instanceof DOMError)
347-
) {
341+
if (isIndexedDbBrowserException(e)) {
348342
logDebug(LOG_TAG, 'Operation failed with retryable error: ' + e);
349343
this.backoff.backoffAndRun(retryingOp);
350344
} else {

packages/firestore/src/util/error.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* @license
3-
* Copyright 2017 Google Inc.
3+
* Copyright 2017 Google LLC
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -171,3 +171,18 @@ export class FirestoreError extends Error implements firestore.FirestoreError {
171171
this.toString = () => `${this.name}: [code=${this.code}]: ${this.message}`;
172172
}
173173
}
174+
175+
/**
176+
* Returns true if the provided error is a DOMException (as thrown by Chrome
177+
* and Firefox for IndexedDB exceptions) or a DOMError (Safari).
178+
*
179+
* @see https://developer.mozilla.org/en-US/docs/Web/API/IDBTransaction/error
180+
*/
181+
export function isIndexedDbBrowserException(
182+
e: DOMException | DOMError
183+
): boolean {
184+
return (
185+
(typeof DOMException !== 'undefined' && e instanceof DOMException) ||
186+
(typeof DOMError !== 'undefined' && e instanceof DOMError)
187+
);
188+
}

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

Lines changed: 79 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -16,64 +16,94 @@
1616
*/
1717

1818
import { describeSpec, specTest } from './describe_spec';
19-
import { client } from './spec_builder';
19+
import { client, spec } from './spec_builder';
2020
import { TimerId } from '../../../src/util/async_queue';
2121
import { Query } from '../../../src/core/query';
2222
import { path } from '../../util/helpers';
23+
import { Code } from '../../../src/util/error';
2324

24-
describeSpec(
25-
'Persistence Recovery',
26-
['durable-persistence', 'no-ios', 'no-android'],
27-
() => {
28-
specTest(
29-
'Write is acknowledged by primary client (with recovery)',
30-
['multi-client'],
31-
() => {
32-
return (
33-
client(0)
34-
.expectPrimaryState(true)
35-
.client(1)
36-
.expectPrimaryState(false)
37-
.userSets('collection/a', { v: 1 })
38-
.failDatabase()
39-
.client(0)
40-
.writeAcks('collection/a', 1, { expectUserCallback: false })
41-
.client(1)
42-
// Client 1 has received the WebStorage notification that the write
43-
// has been acknowledged, but failed to process the change. Hence,
44-
// we did not get a user callback. We schedule the first retry and
45-
// make sure that it also does not get processed until
46-
// `recoverDatabase` is called.
47-
.runTimer(TimerId.AsyncQueueRetry)
48-
.recoverDatabase()
49-
.runTimer(TimerId.AsyncQueueRetry)
50-
.expectUserCallbacks({
51-
acknowledged: ['collection/a']
52-
})
53-
);
54-
}
55-
);
56-
57-
specTest(
58-
'Query raises events in secondary client (with recovery)',
59-
['multi-client'],
60-
() => {
61-
const query = Query.atPath(path('collection'));
62-
63-
return client(0)
25+
describeSpec('Persistence Recovery', ['no-ios', 'no-android'], () => {
26+
specTest(
27+
'Write is acknowledged by primary client (with recovery)',
28+
['multi-client'],
29+
() => {
30+
return (
31+
client(0)
6432
.expectPrimaryState(true)
6533
.client(1)
6634
.expectPrimaryState(false)
67-
.userListens(query)
35+
.userSets('collection/a', { v: 1 })
6836
.failDatabase()
6937
.client(0)
70-
.expectListen(query)
71-
.watchAcksFull(query, 1000)
38+
.writeAcks('collection/a', 1, { expectUserCallback: false })
7239
.client(1)
40+
// Client 1 has received the WebStorage notification that the write
41+
// has been acknowledged, but failed to process the change. Hence,
42+
// we did not get a user callback. We schedule the first retry and
43+
// make sure that it also does not get processed until
44+
// `recoverDatabase` is called.
45+
.runTimer(TimerId.AsyncQueueRetry)
7346
.recoverDatabase()
7447
.runTimer(TimerId.AsyncQueueRetry)
75-
.expectEvents(query, {});
76-
}
77-
);
78-
}
79-
);
48+
.expectUserCallbacks({
49+
acknowledged: ['collection/a']
50+
})
51+
);
52+
}
53+
);
54+
55+
specTest(
56+
'Query raises events in secondary client (with recovery)',
57+
['multi-client'],
58+
() => {
59+
const query = Query.atPath(path('collection'));
60+
61+
return client(0)
62+
.expectPrimaryState(true)
63+
.client(1)
64+
.expectPrimaryState(false)
65+
.userListens(query)
66+
.failDatabase()
67+
.client(0)
68+
.expectListen(query)
69+
.watchAcksFull(query, 1000)
70+
.client(1)
71+
.recoverDatabase()
72+
.runTimer(TimerId.AsyncQueueRetry)
73+
.expectEvents(query, {});
74+
}
75+
);
76+
77+
specTest('Fails targets that cannot be allocated', [], () => {
78+
const query1 = Query.atPath(path('collection1'));
79+
const query2 = Query.atPath(path('collection2'));
80+
const query3 = Query.atPath(path('collection3'));
81+
return spec()
82+
.userListens(query1)
83+
.watchAcksFull(query1, 1)
84+
.expectEvents(query1, {})
85+
.failDatabase()
86+
.userListens(query2)
87+
.expectEvents(query2, { errorCode: Code.UNKNOWN })
88+
.recoverDatabase()
89+
.userListens(query3)
90+
.watchAcksFull(query3, 1)
91+
.expectEvents(query3, {});
92+
});
93+
94+
specTest('Can re-add failed target', [], () => {
95+
const query1 = Query.atPath(path('collection1'));
96+
const query2 = Query.atPath(path('collection2'));
97+
return spec()
98+
.userListens(query1)
99+
.watchAcksFull(query1, 1)
100+
.expectEvents(query1, {})
101+
.failDatabase()
102+
.userListens(query2)
103+
.expectEvents(query2, { errorCode: Code.UNKNOWN })
104+
.recoverDatabase()
105+
.userListens(query2)
106+
.watchAcksFull(query2, 1)
107+
.expectEvents(query2, {});
108+
});
109+
});

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

Lines changed: 28 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,25 @@ 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+
this.currentStep = {
247+
userListen: [targetId, SpecBuilder.queryToSpec(query)]
248+
};
237249
} else {
238-
targetId = this.queryIdGenerator.next(target);
239-
}
250+
if (this.queryMapping.has(target)) {
251+
targetId = this.queryMapping.get(target)!;
252+
} else {
253+
targetId = this.queryIdGenerator.next(target);
254+
}
240255

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-
};
256+
this.queryMapping.set(target, targetId);
257+
this.addQueryToActiveTargets(targetId, query, resumeToken);
258+
this.currentStep = {
259+
userListen: [targetId, SpecBuilder.queryToSpec(query)],
260+
expectedState: { activeTargets: { ...this.activeTargets } }
261+
};
262+
}
247263
return this;
248264
}
249265

@@ -421,6 +437,7 @@ export class SpecBuilder {
421437
/** Fails all database operations until `recoverDatabase()` is called. */
422438
failDatabase(): this {
423439
this.nextStep();
440+
this.injectFailures = true;
424441
this.currentStep = {
425442
failDatabase: true
426443
};
@@ -430,6 +447,7 @@ export class SpecBuilder {
430447
/** Stops failing database operations. */
431448
recoverDatabase(): this {
432449
this.nextStep();
450+
this.injectFailures = false;
433451
this.currentStep = {
434452
failDatabase: false
435453
};

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,6 @@ abstract class TestRunner {
597597
}
598598

599599
private async doListen(listenSpec: SpecUserListen): Promise<void> {
600-
const expectedTargetId = listenSpec[0];
601600
const querySpec = listenSpec[1];
602601
const query = parseQuery(querySpec);
603602
const aggregator = new EventAggregator(query, this.pushEvent.bind(this));
@@ -609,14 +608,15 @@ abstract class TestRunner {
609608
const queryListener = new QueryListener(query, aggregator, options);
610609
this.queryListeners.set(query, queryListener);
611610

612-
await this.queue.enqueue(async () => {
613-
const targetId = await this.eventManager.listen(queryListener);
614-
expect(targetId).to.equal(
615-
expectedTargetId,
616-
'targetId assigned to listen'
617-
);
611+
await this.queue.enqueue(() => {
612+
return this.eventManager.listen(queryListener);
618613
});
619614

615+
if (this.persistence.injectFailures) {
616+
// The Watch stream won't open if we inject failures.
617+
return;
618+
}
619+
620620
// Skip the backoff that may have been triggered by a previous call to
621621
// `watchStreamCloses()`.
622622
if (

0 commit comments

Comments
 (0)