mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 22:33:35 +00:00
Ps/avoid state emit until updated (#7198)
* Add a small default time to limit timing failures * Handle subscription race conditions * Add Symbols to tracked emission types This is a bit of a cheat, but Symbols can't be cloned, so we need to nudge them to something we can handle. They are rare enough that anyone hitting this is likely to expect some special handling. * Ref count state listeners to minimize storage activity * Ensure statuses are updated * Remove notes * Use `test` when gramatically more proper * Copy race and subscription improvements to single user * Simplify observer initialization * Correct parameter names * Simplify update promises test we don't accidentally deadlock along the `getFromState` path * Fix save mock * WIP: most tests working * Avoid infinite update loop * Avoid potential deadlocks with awaiting assigned promises We were awaiting a promise assigned in a thenable. It turns out that assignment occurs before all thenables are concatenated, which can cause deadlocks. Likely, these were not showing up in tests because we're using very quick memory storage. * Fix update deadlock test * Add user update tests * Assert no double emit for multiple observers * Add use intent to method name * Ensure new subscriptions receive only newest data TODO: is this worth doing for active user state? * Remove unnecessary design requirement We don't need to await an executing update promise, we can support two emissions as long as the observable is guaranteed to get the new data. * Cleanup await spam * test cleanup option behavior * Remove unnecessary typecast * Throw over coerce for definition options * Fix state$ observable durability on cleanup
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
defer,
|
||||
Subscription,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
timeout,
|
||||
} from "rxjs";
|
||||
|
||||
@@ -23,16 +21,25 @@ import { Converter, SingleUserState } from "../user-state";
|
||||
|
||||
import { DefaultDerivedUserState } from "./default-derived-state";
|
||||
import { getStoredValue } from "./util";
|
||||
|
||||
const FAKE_DEFAULT = Symbol("fakeDefault");
|
||||
|
||||
export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
||||
private storageKey: string;
|
||||
private updatePromise: Promise<T> | null = null;
|
||||
private storageUpdateSubscription: Subscription;
|
||||
private subscriberCount = new BehaviorSubject<number>(0);
|
||||
private stateObservable: Observable<T>;
|
||||
private reinitialize = false;
|
||||
|
||||
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
||||
T | typeof FAKE_DEFAULT
|
||||
>(FAKE_DEFAULT);
|
||||
|
||||
state$: Observable<T>;
|
||||
get state$() {
|
||||
this.stateObservable = this.stateObservable ?? this.initializeObservable();
|
||||
return this.stateObservable;
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
@@ -41,42 +48,6 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
||||
private chosenLocation: AbstractStorageService & ObservableStorageService,
|
||||
) {
|
||||
this.storageKey = userKeyBuilder(this.userId, this.keyDefinition);
|
||||
|
||||
const storageUpdates$ = this.chosenLocation.updates$.pipe(
|
||||
filter((update) => update.key === this.storageKey),
|
||||
switchMap(async (update) => {
|
||||
if (update.updateType === "remove") {
|
||||
return null;
|
||||
}
|
||||
return await getStoredValue(
|
||||
this.storageKey,
|
||||
this.chosenLocation,
|
||||
this.keyDefinition.deserializer,
|
||||
);
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
this.state$ = defer(() => {
|
||||
const storageUpdateSubscription = storageUpdates$.subscribe((value) => {
|
||||
this.stateSubject.next(value);
|
||||
});
|
||||
|
||||
this.getFromState().then((s) => {
|
||||
this.stateSubject.next(s);
|
||||
});
|
||||
|
||||
return this.stateSubject.pipe(
|
||||
tap({
|
||||
complete: () => {
|
||||
storageUpdateSubscription.unsubscribe();
|
||||
},
|
||||
}),
|
||||
);
|
||||
}).pipe(
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
filter<T>((i) => i != FAKE_DEFAULT),
|
||||
);
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
@@ -84,7 +55,28 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
||||
options: StateUpdateOptions<T, TCombine> = {},
|
||||
): Promise<T> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const currentState = await this.getGuaranteedState();
|
||||
if (this.updatePromise != null) {
|
||||
await this.updatePromise;
|
||||
}
|
||||
|
||||
try {
|
||||
this.updatePromise = this.internalUpdate(configureState, options);
|
||||
const newState = await this.updatePromise;
|
||||
return newState;
|
||||
} finally {
|
||||
this.updatePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
||||
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
|
||||
}
|
||||
|
||||
private async internalUpdate<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
options: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T> {
|
||||
const currentState = await this.getStateForUpdate();
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
@@ -99,20 +91,94 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
||||
return newState;
|
||||
}
|
||||
|
||||
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
||||
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
|
||||
private initializeObservable() {
|
||||
this.storageUpdateSubscription = this.chosenLocation.updates$
|
||||
.pipe(
|
||||
filter((update) => update.key === this.storageKey),
|
||||
switchMap(async (update) => {
|
||||
if (update.updateType === "remove") {
|
||||
return null;
|
||||
}
|
||||
return await this.getFromState();
|
||||
}),
|
||||
)
|
||||
.subscribe((v) => this.stateSubject.next(v));
|
||||
|
||||
this.subscriberCount.subscribe((count) => {
|
||||
if (count === 0 && this.stateObservable != null) {
|
||||
this.triggerCleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Intentionally un-awaited promise, we don't want to delay return of observable, but we do want to
|
||||
// trigger populating it immediately.
|
||||
this.getFromState().then((s) => {
|
||||
this.stateSubject.next(s);
|
||||
});
|
||||
|
||||
return new Observable<T>((subscriber) => {
|
||||
this.incrementSubscribers();
|
||||
|
||||
// reinitialize listeners after cleanup
|
||||
if (this.reinitialize) {
|
||||
this.reinitialize = false;
|
||||
this.initializeObservable();
|
||||
}
|
||||
|
||||
const prevUnsubscribe = subscriber.unsubscribe.bind(subscriber);
|
||||
subscriber.unsubscribe = () => {
|
||||
this.decrementSubscribers();
|
||||
prevUnsubscribe();
|
||||
};
|
||||
|
||||
return this.stateSubject
|
||||
.pipe(
|
||||
// Filter out fake default, which is used to indicate that state is not ready to be emitted yet.
|
||||
filter<T>((i) => i != FAKE_DEFAULT),
|
||||
)
|
||||
.subscribe(subscriber);
|
||||
});
|
||||
}
|
||||
|
||||
private async getGuaranteedState() {
|
||||
/** For use in update methods, does not wait for update to complete before yielding state.
|
||||
* The expectation is that that await is already done
|
||||
*/
|
||||
private async getStateForUpdate() {
|
||||
const currentValue = this.stateSubject.getValue();
|
||||
return currentValue === FAKE_DEFAULT ? await this.getFromState() : currentValue;
|
||||
return currentValue === FAKE_DEFAULT
|
||||
? await getStoredValue(this.storageKey, this.chosenLocation, this.keyDefinition.deserializer)
|
||||
: currentValue;
|
||||
}
|
||||
|
||||
async getFromState(): Promise<T> {
|
||||
if (this.updatePromise != null) {
|
||||
return await this.updatePromise;
|
||||
}
|
||||
return await getStoredValue(
|
||||
this.storageKey,
|
||||
this.chosenLocation,
|
||||
this.keyDefinition.deserializer,
|
||||
);
|
||||
}
|
||||
|
||||
private incrementSubscribers() {
|
||||
this.subscriberCount.next(this.subscriberCount.value + 1);
|
||||
}
|
||||
|
||||
private decrementSubscribers() {
|
||||
this.subscriberCount.next(this.subscriberCount.value - 1);
|
||||
}
|
||||
|
||||
private triggerCleanup() {
|
||||
setTimeout(() => {
|
||||
if (this.subscriberCount.value === 0) {
|
||||
this.updatePromise = null;
|
||||
this.storageUpdateSubscription.unsubscribe();
|
||||
this.subscriberCount.complete();
|
||||
this.subscriberCount = new BehaviorSubject<number>(0);
|
||||
this.stateSubject.next(FAKE_DEFAULT);
|
||||
this.reinitialize = true;
|
||||
}
|
||||
}, this.keyDefinition.cleanupDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user