Skip to content

Commit b949d81

Browse files
authored
Fix for b/74749605: Cancel pending backoff operations when closing streams. (#564)
1 parent bf7a221 commit b949d81

File tree

6 files changed

+72
-8
lines changed

6 files changed

+72
-8
lines changed

packages/firestore/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
# Unreleased
2+
- [fixed] Fixed a regression in the Firebase JS release 4.11.0 that could
3+
cause a crash if a user signs out while the client is offline, resulting in
4+
an error of "Attempted to schedule multiple operations with timer id
5+
listen_stream_connection_backoff".
6+
7+
# 0.3.5
28
- [changed] If the SDK's attempt to connect to the Cloud Firestore backend
39
neither succeeds nor fails within 10 seconds, the SDK will consider itself
410
"offline", causing get() calls to resolve with cached results, rather than

packages/firestore/src/remote/backoff.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,8 @@ export class ExponentialBackoff {
8787
* already, it will be canceled.
8888
*/
8989
backoffAndRun(op: () => Promise<void>): void {
90-
if (this.timerPromise !== null) {
91-
this.timerPromise.cancel();
92-
}
90+
// Cancel any pending backoff operation.
91+
this.cancel();
9392

9493
// First schedule using the current base (which may be 0 and should be
9594
// honored as such).
@@ -118,6 +117,13 @@ export class ExponentialBackoff {
118117
}
119118
}
120119

120+
cancel(): void {
121+
if (this.timerPromise !== null) {
122+
this.timerPromise.cancel();
123+
this.timerPromise = null;
124+
}
125+
}
126+
121127
/** Returns a random value in the range [-currentBaseMs/2, currentBaseMs/2] */
122128
private jitterDelayMs(): number {
123129
return (Math.random() - 0.5) * this.currentBaseMs;

packages/firestore/src/remote/persistent_stream.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,13 @@ export abstract class PersistentStream<
314314
"Can't provide an error when not in an error state."
315315
);
316316

317+
// The stream will be closed so we don't need our idle close timer anymore.
317318
this.cancelIdleCheck();
318319

320+
// Ensure we don't leave a pending backoff operation queued (in case close()
321+
// was called while we were waiting to reconnect).
322+
this.backoff.cancel();
323+
319324
if (finalState !== PersistentStreamState.Error) {
320325
// If this is an intentional close ensure we don't delay our next connection attempt.
321326
this.backoff.reset();
@@ -462,10 +467,16 @@ export abstract class PersistentStream<
462467

463468
this.backoff.backoffAndRun(async () => {
464469
if (this.state === PersistentStreamState.Stopped) {
465-
// Stream can be stopped while waiting for backoff to complete.
470+
// We should have canceled the backoff timer when the stream was
471+
// closed, but just in case we make this a no-op.
466472
return;
467473
}
468474

475+
assert(
476+
this.state === PersistentStreamState.Backoff,
477+
'Backoff should have been canceled if we left the Backoff state.'
478+
);
479+
469480
this.state = PersistentStreamState.Initial;
470481
this.start(listener);
471482
assert(this.isStarted(), 'PersistentStream should have started');

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { expect } from 'chai';
1818
import { Query } from '../../../src/core/query';
1919
import { Code } from '../../../src/util/error';
20-
import { doc, path } from '../../util/helpers';
20+
import { doc, path, resumeTokenForSnapshot } from '../../util/helpers';
2121

2222
import { describeSpec, specTest } from './describe_spec';
2323
import { spec } from './spec_builder';
@@ -83,4 +83,36 @@ describeSpec('Remote store:', [], () => {
8383
.expectEvents(query, { added: [doc1] })
8484
);
8585
});
86+
87+
// TODO(b/72313632): This test is web-only because the Android / iOS spec
88+
// tests exclude backoff entirely.
89+
specTest(
90+
'Handles user changes while offline (b/74749605).',
91+
['no-android', 'no-ios'],
92+
() => {
93+
const query = Query.atPath(path('collection'));
94+
const docA = doc('collection/a', 1000, { key: 'a' });
95+
return (
96+
spec()
97+
.userListens(query)
98+
99+
// close the stream (this should trigger retry with backoff; but don't
100+
// run it in an attempt to reproduce b/74749605).
101+
.watchStreamCloses(Code.UNAVAILABLE, { runBackoffTimer: false })
102+
103+
// Because we didn't let the backoff timer run and restart the watch
104+
// stream, there will be no active targets.
105+
.expectActiveTargets()
106+
107+
// Change user (will shut down existing streams and start new ones).
108+
.changeUser('abc')
109+
// Our query should be sent to the new stream.
110+
.expectActiveTargets({ query, resumeToken: '' })
111+
112+
// Close the (newly-created) stream as if it too failed (should trigger
113+
// retry with backoff, potentially reproducing the crash in b/74749605).
114+
.watchStreamCloses(Code.UNAVAILABLE)
115+
);
116+
}
117+
);
86118
});

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,14 +496,22 @@ export class SpecBuilder {
496496
return this;
497497
}
498498

499-
watchStreamCloses(error: Code): SpecBuilder {
499+
watchStreamCloses(
500+
error: Code,
501+
opts?: { runBackoffTimer: boolean }
502+
): SpecBuilder {
503+
if (!opts) {
504+
opts = { runBackoffTimer: true };
505+
}
506+
500507
this.nextStep();
501508
this.currentStep = {
502509
watchStreamClose: {
503510
error: {
504511
code: mapRpcCodeFromCode(error),
505512
message: 'Simulated Backend Error'
506-
}
513+
},
514+
runBackoffTimer: opts.runBackoffTimer
507515
}
508516
};
509517
return this;

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ abstract class TestRunner {
700700
)
701701
);
702702
// The watch stream should re-open if we have active listeners.
703-
if (!this.queryListeners.isEmpty()) {
703+
if (spec.runBackoffTimer && !this.queryListeners.isEmpty()) {
704704
await this.queue.runDelayedOperationsEarly(
705705
TimerId.ListenStreamConnectionBackoff
706706
);
@@ -1167,6 +1167,7 @@ export type SpecSnapshotVersion = TestSnapshotVersion;
11671167

11681168
export type SpecWatchStreamClose = {
11691169
error: SpecError;
1170+
runBackoffTimer: boolean;
11701171
};
11711172

11721173
export type SpecWriteAck = {

0 commit comments

Comments
 (0)