fix: Fix initialization race condition that occurs in certain circumstances (#1545)

* fix: Fix initialization race condition in certain circumstances

* Minor improvements and camera iris logo
This commit is contained in:
Dermot Duffy
2024-09-19 19:42:38 -07:00
committed by GitHub
parent af4666e023
commit 650d99ec00
26 changed files with 611 additions and 461 deletions
@@ -67,7 +67,12 @@ export class CardElementManager {
this._api.getMediaLoadedInfoManager().initialize();
this._api.getMicrophoneManager().initialize();
this._api.getKeyboardStateManager().initialize();
// These initializers are called when the config is updated, but on initial
// creation of the card hass is not yet available when the config is first
// loaded.
this._api.getDefaultManager().initialize();
this._api.getMediaPlayerManager().initialize();
this._api
.getHASSManager()
@@ -146,6 +151,8 @@ export class CardElementManager {
// reconnection, to ensure the state subscription/unsubscription works
// correctly for triggers.
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
this._api.getCameraManager().reset();
this._element.removeEventListener(
'mousemove',
this._api.getInteractionManager().reportInteraction,
+17 -12
View File
@@ -92,16 +92,20 @@ export class ConfigManager {
camera: undefined,
});
this._api.getMediaLoadedInfoManager().clear();
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
this._api.getViewManager().reset();
this._api.getMessageManager().reset();
this._api.getStyleManager().setPerformance();
this._api.getCardElementManager().update();
this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
setKeyboardShortcutsFromConfig(this._api, this);
setAutomationsFromConfig(this._api);
this.computeOverrideConfig();
this._api.getCardElementManager().update();
}
public computeOverrideConfig(): void {
@@ -144,17 +148,18 @@ export class ConfigManager {
.uninitialize(InitializationAspect.MICROPHONE_CONNECT);
}
if (
previousConfig &&
!isEqual(
previousConfig?.view.default_reset,
this._overriddenConfig?.view.default_reset,
)
) {
this._api
.getInitializationManager()
.uninitialize(InitializationAspect.DEFAULT_RESET);
}
/* async */ this._initializeBackground(previousConfig);
}
/**
* Initialize config dependent items in the background. For items that the
* card hard requires, use InitializationManager instead.
*/
protected async _initializeBackground(
previousConfig: FrigateCardConfig | null,
): Promise<void> {
await this._api.getDefaultManager().initializeIfNecessary(previousConfig);
await this._api.getMediaPlayerManager().initializeIfNecessary(previousConfig);
this._api.getCardElementManager().update();
}
+34 -38
View File
@@ -1,4 +1,5 @@
import PQueue from 'p-queue';
import { isEqual } from 'lodash-es';
import { FrigateCardConfig } from '../config/types';
import { createGeneralAction } from '../utils/action';
import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode';
import { Timer } from '../utils/timer';
@@ -10,20 +11,45 @@ import { CardDefaultManagerAPI } from './types';
export class DefaultManager {
protected _timer = new Timer();
protected _api: CardDefaultManagerAPI;
protected _initializationLimit = new PQueue({ concurrency: 1 });
constructor(api: CardDefaultManagerAPI) {
this._api = api;
}
public async initializeIfNecessary(
previousConfig: FrigateCardConfig | null,
): Promise<void> {
if (
!isEqual(
previousConfig?.view.default_reset,
this._api.getConfigManager().getConfig()?.view.default_reset,
)
) {
await this.initialize();
}
}
/**
* Initialize the default manager. Requires both hass and configuration to be
* effective (so cannot be called from just the configuration manager, as hass
* will not be available yet)
* This needs to be public since the first initialization requires both hass
* and the config, so it is not suitable from calling exclusively from the
* config manager.
*/
public async initialize(): Promise<boolean> {
const result = await this._initializationLimit.add(() => this._reconfigure());
this._startTimer();
this.uninitialize();
const config = this._api.getConfigManager().getConfig()?.view.default_reset;
if (config?.entities.length) {
this._api
.getHASSManager()
.getStateWatcher()
.subscribe(this._stateChangeHandler, config.entities);
}
const timerSeconds = this._api.getConfigManager().getConfig()?.view
.default_reset.every_seconds;
if (timerSeconds) {
this._timer.startRepeated(timerSeconds, () => this._setToDefaultIfAllowed());
}
if (this._api.getConfigManager().getConfig()?.view.default_reset.after_interaction) {
this._api.getAutomationsManager().addAutomations([
@@ -40,7 +66,7 @@ export class DefaultManager {
]);
}
return !!result;
return true;
}
public uninitialize(): void {
@@ -49,28 +75,6 @@ export class DefaultManager {
this._api.getAutomationsManager().deleteAutomations(this);
}
protected async _reconfigure(): Promise<boolean> {
const hass = this._api.getHASSManager().getHASS();
const config = this._api.getConfigManager().getConfig()?.view.default_reset;
if (!hass || !config) {
return false;
}
this._api.getHASSManager().getStateWatcher().unsubscribe(this._stateChangeHandler);
this._api
.getHASSManager()
.getStateWatcher()
.subscribe(this._stateChangeHandler, config.entities);
// If the timer is running, restart it with the newly configured timer.
if (this._timer.isRunning()) {
this._timer.stop();
this._startTimer();
}
return true;
}
protected _stateChangeHandler = (): void => {
this._setToDefaultIfAllowed();
};
@@ -92,12 +96,4 @@ export class DefaultManager {
)
);
}
protected _startTimer(): void {
const timerSeconds = this._api.getConfigManager().getConfig()?.view
.default_reset.every_seconds;
if (timerSeconds) {
this._timer.startRepeated(timerSeconds, () => this._setToDefaultIfAllowed());
}
}
}
+45 -81
View File
@@ -1,3 +1,4 @@
import PQueue from 'p-queue';
import { loadLanguages } from '../localize/localize';
import { sideLoadHomeAssistantElements } from '../utils/ha';
import { Initializer } from '../utils/initializer/initializer';
@@ -6,14 +7,29 @@ import { CardInitializerAPI } from './types';
export enum InitializationAspect {
LANGUAGES = 'languages',
SIDE_LOAD_ELEMENTS = 'side-load-elements',
MEDIA_PLAYERS = 'media-players',
CAMERAS = 'cameras',
MICROPHONE_CONNECT = 'microphone-connect',
DEFAULT_RESET = 'default-reset',
VIEW = 'view',
}
// =========================================================================
// Rules for initialization. Initializers must be reentrant as these situations
// may occur:
//
// 1. Multiple JS async contexts may execute these functions at the same time.
// 2. At any point, something may uninitialize a part of the card (including
// while a different async context is in the middle of running the
// initialization method).
// =========================================================================
export class InitializationManager {
protected _api: CardInitializerAPI;
// A concurrency limit is placed to ensure that on card load multiple async
// contexts do not attempt to initialize the card at the same time. This is
// not strictly necessary, just more efficient, as long as the "Rules for
// initialization" (above) are followed.
protected _initializationQueue = new PQueue({ concurrency: 1 });
protected _initializer: Initializer;
constructor(api: CardInitializerAPI, initializer?: Initializer) {
@@ -27,28 +43,29 @@ export class InitializationManager {
return false;
}
return (
this._initializer.isInitializedMultiple([
InitializationAspect.LANGUAGES,
InitializationAspect.SIDE_LOAD_ELEMENTS,
InitializationAspect.CAMERAS,
...(config.live.microphone.always_connected
? [InitializationAspect.MICROPHONE_CONNECT]
: []),
]) &&
// If there's no view, re-initialize (e.g. config changes).
this._api.getViewManager().hasView()
);
return this._initializer.isInitializedMultiple([
InitializationAspect.LANGUAGES,
InitializationAspect.SIDE_LOAD_ELEMENTS,
InitializationAspect.CAMERAS,
...(config.live.microphone.always_connected
? [InitializationAspect.MICROPHONE_CONNECT]
: []),
InitializationAspect.VIEW,
]);
}
/**
* Initialize the hard requirements for rendering anything.
* @returns `true` if card rendering can continue.
*/
public async initializeMandatory(): Promise<boolean> {
public async initializeMandatory(): Promise<void> {
await this._initializationQueue.add(() => this._initializeMandatory());
}
protected async _initializeMandatory(): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return false;
if (!hass || this.isInitializedMandatory()) {
return;
}
if (
@@ -60,12 +77,12 @@ export class InitializationManager {
await sideLoadHomeAssistantElements(),
}))
) {
return false;
return;
}
const config = this._api.getConfigManager().getConfig();
if (!config) {
return false;
return;
}
if (
@@ -83,76 +100,23 @@ export class InitializationManager {
}),
}))
) {
return false;
}
if (!this._api.getMessageManager().hasMessage()) {
if (!this._api.getViewManager().hasView()) {
// Set a view on initial load. However, if the query string contains a
// view related action, we don't set any view here and allow that content
// to be triggered by the firstUpdated() call that runs query string
// actions. To do otherwise may cause a race condition between the default
// view and the querystring view, see:
// https://github.com/dermotduffy/frigate-hass-card/issues/1200
const hasViewRelatedActions = this._api
.getQueryStringManager()
.hasViewRelatedActions();
if (hasViewRelatedActions) {
this._api.getQueryStringManager().executeViewRelated();
} else {
this._api.getViewManager().setViewDefaultWithNewQuery({ failSafe: true });
}
} else {
// If we already have a view, something (e.g. cameras) may have been
// reinitialized, be sure to ask for an update.
this._api.getCardElementManager().update();
}
}
return true;
}
/**
* Initialize aspects of the card that can load in the 'background'.
* @returns `true` if card rendering can continue.
*/
public async initializeBackgroundIfNecessary(): Promise<boolean> {
const hass = this._api.getHASSManager().getHASS();
const config = this._api.getConfigManager().getConfig();
if (!hass || !config) {
return false;
return;
}
if (
this._initializer.isInitializedMultiple([
InitializationAspect.DEFAULT_RESET,
...(config.menu.buttons.media_player.enabled
? [InitializationAspect.MEDIA_PLAYERS]
: []),
])
this._api.getMessageManager().hasMessage() ||
!(await this._initializer.initializeIfNecessary(
InitializationAspect.VIEW,
this._api.getViewManager().initialize,
))
) {
return true;
}
if (
!(await this._initializer.initializeMultipleIfNecessary({
[InitializationAspect.DEFAULT_RESET]: async () =>
await this._api.getDefaultManager().initialize(),
...(config.menu.buttons.media_player.enabled && {
[InitializationAspect.MEDIA_PLAYERS]: async () =>
await this._api.getMediaPlayerManager().initialize(),
}),
}))
) {
return false;
return;
}
this._api.getCardElementManager().update();
return true;
}
public uninitialize(aspect: InitializationAspect) {
return this._initializer.uninitialize(aspect);
public uninitialize(aspect: InitializationAspect): void {
this._initializer.uninitialize(aspect);
}
}
+16 -2
View File
@@ -1,4 +1,4 @@
import { CameraConfig } from '../config/types';
import { CameraConfig, FrigateCardConfig } from '../config/types';
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const';
import { localize } from '../localize/localize';
import { errorToConsole } from '../utils/basic';
@@ -25,9 +25,23 @@ export class MediaPlayerManager {
return this._mediaPlayers.length > 0;
}
public async initializeIfNecessary(
previousConfig: FrigateCardConfig | null,
): Promise<void> {
if (
previousConfig?.menu.buttons.media_player.enabled !==
this._api.getConfigManager().getConfig()?.menu.buttons.media_player.enabled
) {
await this.initialize();
}
}
public async initialize(): Promise<boolean> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
if (
!hass ||
!this._api.getConfigManager().getConfig()?.menu.buttons.media_player.enabled
) {
return false;
}
+9 -9
View File
@@ -23,18 +23,18 @@ export class QueryStringManager {
return !!this._calculateIntent().view;
}
public executeNonViewRelated = (): void => {
this._executeNonViewRelated(this._calculateIntent());
public executeNonViewRelated = async (): Promise<void> => {
await this._executeNonViewRelated(this._calculateIntent());
};
public executeViewRelated = (): void => {
this._executeViewRelated(this._calculateIntent());
public executeViewRelated = async (): Promise<void> => {
await this._executeViewRelated(this._calculateIntent());
};
public executeAll = (): void => {
public executeAll = async (): Promise<void> => {
const intent = this._calculateIntent();
this._executeViewRelated(intent);
this._executeNonViewRelated(intent);
await this._executeViewRelated(intent);
await this._executeNonViewRelated(intent);
};
protected async _executeViewRelated(intent: QueryStringViewIntent): Promise<void> {
@@ -62,7 +62,7 @@ export class QueryStringManager {
}
}
protected _executeNonViewRelated(intent: QueryStringViewIntent): void {
protected async _executeNonViewRelated(intent: QueryStringViewIntent): Promise<void> {
if (
// Only execute non-view actions when the card has rendered at least once.
!this._api.getCardElementManager().hasUpdated() ||
@@ -71,7 +71,7 @@ export class QueryStringManager {
return;
}
this._api.getActionsManager().executeActions(intent.other);
await this._api.getActionsManager().executeActions(intent.other);
}
protected _calculateIntent(): QueryStringViewIntent {
+3
View File
@@ -91,6 +91,7 @@ export interface CardConfigAPI {
getDefaultManager(): DefaultManager;
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getStatusBarItemManager(): StatusBarItemManager;
getStyleManager(): StyleManager;
@@ -201,6 +202,7 @@ export interface CardMediaLoadedAPI {
export interface CardMediaPlayerAPI {
getCameraManager(): CameraManager;
getConfigManager(): ConfigManager;
getEntityRegistryManager(): EntityRegistryManager;
getHASSManager(): HASSManager;
getMessageManager(): MessageManager;
@@ -257,6 +259,7 @@ export interface CardViewAPI {
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getQueryStringManager(): QueryStringManager;
getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager;
}
+18
View File
@@ -133,6 +133,24 @@ export class ViewManager implements ViewManagerInterface {
);
}
public initialize = async (): Promise<boolean> => {
// Set a view on initial load. However, if the query string contains a view
// related action, we don't set any view here and allow that content to be
// triggered by the firstUpdated() call that runs query string actions. To
// do otherwise may cause a race condition between the default view and the
// querystring view, see:
// https://github.com/dermotduffy/frigate-hass-card/issues/1200
const hasViewRelatedActions = this._api
.getQueryStringManager()
.hasViewRelatedActions();
if (hasViewRelatedActions) {
await this._api.getQueryStringManager().executeViewRelated();
} else {
await this.setViewDefaultWithNewQuery({ failSafe: true });
}
return true;
};
protected _setView(view: View | null): void {
const oldView = this._view;