fix: Ensure no camera outlives a failed initialization (#2657)

This commit is contained in:
Dermot Duffy
2026-08-04 21:33:46 -07:00
committed by GitHub
parent 80ec92e3b2
commit 9f88aacefe
13 changed files with 646 additions and 123 deletions
+38 -11
View File
@@ -17,7 +17,7 @@ import type { Entity, EntityRegistryManager } from '../ha/registry/entity/types'
import type { HassStateDifference, HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
import type { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
import { arrayify } from '../utils/basic';
import { arrayify, errorToConsole } from '../utils/basic';
import {
isGo2RTCLiveProvider,
liveProviderSupports2WayAudio,
@@ -56,7 +56,9 @@ export interface CameraInitializationOptions {
type DestroyCallback = () => void | Promise<void>;
export class Camera {
export class Camera<
Options extends CameraInitializationOptions = CameraInitializationOptions,
> {
protected _config: CameraConfig;
protected _engine: CameraManagerEngine;
protected _capabilities?: Capabilities;
@@ -87,7 +89,7 @@ export class Camera {
return this._initialized;
}
async initialize(options: CameraInitializationOptions): Promise<Camera> {
async initialize(options: Options): Promise<this> {
// Freeze a single HASS snapshot for the whole (async, multi-step)
// initialization so every step observes a consistent entity world; live
// subscriptions below still use the manager's current watchers.
@@ -96,8 +98,11 @@ export class Camera {
return this;
}
// Subscriptions are registered part-way through, so a later failure would
// otherwise strand them on a camera nobody holds a reference to.
try {
this._entity = await this._resolveEntity(hass, options);
await this._initialize(hass, options);
await this._initializeBeforeCapabilities(hass, options);
this._capabilities =
options.capabilityOptions?.capabilities ??
@@ -132,6 +137,18 @@ export class Camera {
}
}
await this._initializeAfterCapabilities(options);
} catch (e) {
try {
await this.destroy();
} catch (destroyError: unknown) {
// A camera that cannot clean up must not replace the failure that is
// actually worth reporting.
errorToConsole(destroyError);
}
throw e;
}
this._initialized = true;
return this;
}
@@ -160,7 +177,7 @@ export class Camera {
private async _resolveEntity(
hass: HomeAssistant,
options: CameraInitializationOptions,
options: Options,
): Promise<Entity | null> {
const cameraEntityID = getCameraEntityFromConfig(this._config);
if (!cameraEntityID || !options.entityRegistryManager) {
@@ -175,14 +192,14 @@ export class Camera {
*/
protected async _getTriggerEntities(
hass: HomeAssistant,
options: CameraInitializationOptions,
options: Options,
): Promise<void> {
await this._getDoorbellEntities(hass, options);
}
private async _getDoorbellEntities(
hass: HomeAssistant,
options: CameraInitializationOptions,
options: Options,
): Promise<void> {
if (
!this._config.triggers.doorbell ||
@@ -216,16 +233,26 @@ export class Camera {
/**
* Subclass initialization hook. Override for async initialization work.
*/
protected async _initialize(
protected async _initializeBeforeCapabilities(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_hass: HomeAssistant,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_options: CameraInitializationOptions,
_options: Options,
): Promise<void> {}
/**
* Subclass initialization hook for work that needs the built capabilities.
* Runs inside the initialization guard, so whatever it registers is released
* if it, or anything after it, throws.
*/
protected async _initializeAfterCapabilities(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_options: Options,
): Promise<void> {}
protected async _buildCapabilities(
hass: HomeAssistant,
options: CameraInitializationOptions,
options: Options,
): Promise<Capabilities> {
const rawCapabilities = await this._getRawCapabilities(hass, options);
const config = this.getConfig();
@@ -271,7 +298,7 @@ export class Camera {
*/
protected async _getRawCapabilities(
_hass: HomeAssistant,
options: CameraInitializationOptions,
options: Options,
): Promise<CapabilitiesRaw> {
return {
live: true,
+6 -4
View File
@@ -8,14 +8,16 @@ import { CameraNoEntityError } from './error';
* subclass turns absence into an error for engines that cannot function
* without it (motionEye, Reolink, TPLink).
*/
export class EntityCamera extends Camera {
protected override async _initialize(
export class EntityCamera<
Options extends CameraInitializationOptions = CameraInitializationOptions,
> extends Camera<Options> {
protected override async _initializeBeforeCapabilities(
hass: HomeAssistant,
options: CameraInitializationOptions,
options: Options,
): Promise<void> {
if (!this._entity) {
throw new CameraNoEntityError(this.getConfig());
}
await super._initialize(hass, options);
await super._initializeBeforeCapabilities(hass, options);
}
}
+10 -10
View File
@@ -46,27 +46,25 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
return cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE;
};
export class FrigateCamera extends Camera {
export class FrigateCamera extends Camera<FrigateCameraInitializationOptions> {
// Short-circuits subscription when destroy() was invoked while base
// initialization was still awaiting. Set BEFORE awaiting `super.destroy()` so
// an in-flight initialize() sees the flip immediately.
private _destroyed = false;
public async initialize(options: FrigateCameraInitializationOptions): Promise<Camera> {
await super.initialize(options);
// A destroy() during the await above means the camera is being torn down;
// it must not register live subscriptions afterward.
protected override async _initializeAfterCapabilities(
options: FrigateCameraInitializationOptions,
): Promise<void> {
// A destroy() while the base class was still initializing means the camera
// is being torn down; it must not register live subscriptions afterward.
if (this._destroyed) {
return this;
return;
}
if (this._capabilities?.has('trigger')) {
this._subscribeToEvents(options.frigateEventWatcher);
this._subscribeToReviews(options.frigateReviewWatcher);
}
return this;
}
public override async destroy(): Promise<void> {
@@ -124,7 +122,9 @@ export class FrigateCamera extends Camera {
return true;
}
protected override async _initialize(hass: HomeAssistant): Promise<void> {
protected override async _initializeBeforeCapabilities(
hass: HomeAssistant,
): Promise<void> {
const config = this.getConfig();
const hasCameraName = !!config.frigate?.camera_name;
const cameraEntity = getCameraEntityFromConfig(config);
+100 -39
View File
@@ -16,15 +16,18 @@ import type { Endpoint } from '../types.js';
import {
allPromises,
arrayify,
errorToConsole,
isTruthy,
recursivelyMergeObjectsNotArrays,
setify,
} from '../utils/basic.js';
import { getCameraID } from '../utils/camera.js';
import { Generation } from '../utils/concurrency/generation.js';
import { log } from '../utils/debug.js';
import { ViewItemClassifier } from '../view/item-classifier.js';
import type { ViewItem, ViewMedia } from '../view/item.js';
import type { ViewItemCapabilities } from '../view/types.js';
import type { Camera } from './camera.js';
import { Capabilities } from './capabilities.js';
import { CameraManagerEngineFactory } from './engine-factory.js';
import type { CameraManagerEngine } from './engine.js';
@@ -137,6 +140,14 @@ export class CameraManager {
private _store: CameraManagerStore;
private _requestLimit = new PQueue();
// Cameras take time to build, so a teardown or a newer initialization can
// arrive mid-build and leave the finished cameras with no owner.
private _generation = new Generation();
// Handing cameras to the store is not atomic, so commits and teardowns run
// one at a time and cannot observe each other half-applied.
private _storeCommits = new PQueue({ concurrency: 1 });
constructor(
api: CardCameraAPI,
options?: {
@@ -155,6 +166,11 @@ export class CameraManager {
}
public async initializeCamerasFromConfig(): Promise<void> {
// Taken before the early return below: a call that cannot proceed still
// supersedes an older one whose cameras are being built from a
// configuration that no longer applies.
const generation = this._generation.next();
const config = this._api.getConfigManager().getConfig();
const hass = this._api.getHASSManager().getHASS();
@@ -173,11 +189,12 @@ export class CameraManager {
recursivelyMergeObjectsNotArrays(config?.cameras_global, camera),
);
await this._initializeCameras(cameras);
await this._initializeCameras(cameras, generation);
}
public async destroy(): Promise<void> {
await this._store.reset();
this._generation.invalidate();
await this._storeCommits.add(() => this._store.reset());
}
private async _getEnginesForCameras(
@@ -221,44 +238,33 @@ export class CameraManager {
return output;
}
private async _initializeCameras(camerasConfig: CameraConfig[]): Promise<void> {
const initializationStartTime = new Date();
const hass = this._api.getHASSManager().getHASS();
/**
* Create a camera for each engine, and assign each its ID. An initialized
* camera holds live subscriptions, so either every camera is returned ready
* for the store to own, or none survive: any failure destroys all of them
* before throwing.
*/
private async _createCameras(
engineByConfig: Map<CameraConfig, CameraManagerEngine>,
): Promise<Camera[]> {
// A camera that fails is taken out of the results rather than abandoning
// the others mid-flight, which would leave an initialized camera with
// nobody holding a reference to it.
const failures: unknown[] = [];
const cameras = (
await allPromises(engineByConfig, ([cameraConfig, engine]) =>
engine.createCamera(cameraConfig).catch((error: unknown) => {
failures.push(error);
return null;
}),
)
).filter(isTruthy);
/* v8 ignore if: the if path cannot be reached -- @preserve */
if (!hass) {
return;
try {
if (failures.length) {
throw failures[0];
}
const hasAutoTriggers = (config: CameraConfig): boolean => {
return (
config.triggers.motion || config.triggers.occupancy || config.triggers.doorbell
);
};
if (
// If any camera requires automatic trigger detection ...
camerasConfig.some((config) => hasAutoTriggers(config))
) {
// ... then we need to populate the entity cache by fetching all entities
// from Home Assistant. Attempt to do this once upfront, to avoid each
// camera doing needing to fetch entity state.
await this._api.getEntityRegistryManager().fetchEntityList(hass);
}
// Engines are created sequentially, to avoid duplicate creation of the same
// engine. See: https://github.com/dermotduffy/advanced-camera-card/issues/941
const engineByConfig = await this._getEnginesForCameras(camerasConfig);
// Configuration is initialized in parallel.
const cameras = await allPromises(
engineByConfig.entries(),
async ([cameraConfig, engine]) => await engine.createCamera(cameraConfig),
);
const destroyCameras = async () => {
await allPromises(cameras, (camera) => camera.destroy());
};
const cameraIDs: Set<string> = new Set();
// Do the additions based off the result-order, to ensure the map order is
@@ -267,12 +273,10 @@ export class CameraManager {
const cameraID = getCameraID(camera.getConfig());
if (!cameraID) {
await destroyCameras();
throw new CameraNoIDError(camera.getConfig());
}
if (cameraIDs.has(cameraID)) {
await destroyCameras();
throw new CameraDuplicateIDError(camera.getConfig());
}
@@ -280,8 +284,65 @@ export class CameraManager {
camera.setID(cameraID);
cameraIDs.add(cameraID);
}
} catch (e) {
await this._destroyCameras(cameras);
throw e;
}
return cameras;
}
private async _destroyCameras(cameras: Camera[]): Promise<void> {
await allPromises(cameras, async (camera) => {
try {
await camera.destroy();
} catch (error: unknown) {
errorToConsole(error);
}
});
}
private async _initializeCameras(
camerasConfig: CameraConfig[],
generation: number,
): Promise<void> {
const initializationStartTime = new Date();
const hass = this._api.getHASSManager().getHASS();
/* v8 ignore if: the if path cannot be reached -- @preserve */
if (!hass) {
return;
}
const requiresAutoTriggerDetection = camerasConfig.some(
({ triggers }) => triggers.motion || triggers.occupancy || triggers.doorbell,
);
if (requiresAutoTriggerDetection) {
// Populate the entity cache by fetching all entities from Home Assistant
// once upfront, to avoid each camera needing to fetch entity state.
await this._api.getEntityRegistryManager().fetchEntityList(hass);
}
// Engines are created sequentially, to avoid duplicate creation of the same
// engine. See: https://github.com/dermotduffy/advanced-camera-card/issues/941
const engineByConfig = await this._getEnginesForCameras(camerasConfig);
const cameras = await this._createCameras(engineByConfig);
// The store mutates incrementally, so staleness is re-checked inside the
// queue rather than before it: a teardown or a later initialization that
// arrives mid-commit would otherwise interleave with this one.
await this._storeCommits.add(async () => {
// Nothing will ever own these cameras, so they are destroyed instead of
// being handed to a store that has moved on.
if (!this._generation.isCurrent(generation)) {
await this._destroyCameras(cameras);
return;
}
await this._store.setCameras(cameras);
});
log(
this._api.getConfigManager().getCardWideConfig(),
+4 -14
View File
@@ -14,7 +14,7 @@ import {
type PTZCapabilities,
} from '../../types';
import { createSelectOptionAction } from '../../utils/action.js';
import type { Camera, CameraInitializationOptions } from '../camera';
import type { CameraInitializationOptions } from '../camera';
import { EntityCamera } from '../entity-camera';
import { ReolinkInitializationError } from '../error';
import type { CameraEndpointsContext, CameraProxyConfig } from '../types';
@@ -77,7 +77,7 @@ const PTZ_BUTTON_ENTITY_KEYS: readonly (keyof PTZButtonEntities)[] = [
'zoom_out',
];
export class ReolinkCamera extends EntityCamera {
export class ReolinkCamera extends EntityCamera<ReolinkCameraInitializationOptions> {
// The HostID identifying the camera or NVR.
private _reolinkHostID: string | null = null;
@@ -90,16 +90,6 @@ export class ReolinkCamera extends EntityCamera {
// Entities used for PTZ control.
private _ptzEntities: PTZEntities | null = null;
/**
* Reolink cameras require additional options not present in the base class
* initialization options, so this ~empty method is used to expand the type
* expectations. Without this, callers cannot specify objects (e.g. the device
* registry) without TypeScript errors.
*/
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
return super.initialize(options);
}
private async _getChannelFromConfigurationURL(
hass: HomeAssistant,
deviceRegistryManager: DeviceRegistryManager,
@@ -171,11 +161,11 @@ export class ReolinkCamera extends EntityCamera {
this._reolinkCameraUID = reolinkCameraUID;
}
protected async _initialize(
protected async _initializeBeforeCapabilities(
hass: HomeAssistant,
options: ReolinkCameraInitializationOptions,
): Promise<void> {
await super._initialize(hass, options);
await super._initializeBeforeCapabilities(hass, options);
await this._initializeChannel(hass, options.deviceRegistryManager);
this._ptzEntities = await this._getPTZEntities(hass, options.entityRegistryManager);
}
+3 -3
View File
@@ -23,14 +23,14 @@ interface PTZEntities {
}
type PTZEntity = keyof PTZEntities;
export class TPLinkCamera extends EntityCamera {
export class TPLinkCamera extends EntityCamera<TPLinkCameraInitializationOptions> {
private _ptzEntities: PTZEntities | null = null;
protected async _initialize(
protected async _initializeBeforeCapabilities(
hass: HomeAssistant,
options: TPLinkCameraInitializationOptions,
): Promise<void> {
await super._initialize(hass, options);
await super._initializeBeforeCapabilities(hass, options);
this._ptzEntities = await this._getPTZEntities(hass, options.entityRegistryManager);
}
@@ -12,6 +12,11 @@ import {
import { TPLinkCamera } from './camera';
export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine {
// TPLink cameras require a registry manager to resolve their PTZ entities,
// which the constructor below guarantees; the base engine only optionally
// has one.
protected declare _entityRegistryManager: EntityRegistryManager;
constructor(
entityRegistryManager: EntityRegistryManager,
hassManager: HASSManagerReadonlyInterface,
+15 -1
View File
@@ -101,6 +101,7 @@ export class FakeHASS {
private _isAdmin: boolean;
private _handlers = new Map<string, WSCommandHandler>();
private _commandLog: MessageBase[] = [];
private _openEventSubscriptions = 0;
constructor(options?: FakeHASSOptions) {
this._language = options?.language ?? 'en';
@@ -129,6 +130,13 @@ export class FakeHASS {
this._handlers.set(type, handler);
}
/**
* Number of event subscriptions not yet released.
*/
public getOpenEventSubscriptionCount(): number {
return this._openEventSubscriptions;
}
/**
* Every WebSocket command the card has issued, in order.
*/
@@ -202,7 +210,13 @@ export class FakeHASS {
private _createConnection(): Connection {
const connection = mock<Connection>();
connection.subscribeMessage.mockResolvedValue(() => Promise.resolve());
connection.subscribeEvents.mockResolvedValue(() => Promise.resolve());
connection.subscribeEvents.mockImplementation(async () => {
this._openEventSubscriptions++;
return () => {
this._openEventSubscriptions--;
return Promise.resolve();
};
});
// `callWS` and `sendMessagePromise` are the same request/response channel,
// so both go through one handler table. Given two tables, a command
+7
View File
@@ -491,6 +491,13 @@ export class MountedCard {
this.card.hass = this._hass.getHASS();
}
/**
* How many Home Assistant event subscriptions the card currently holds open.
*/
public getOpenEventSubscriptionCount(): number {
return this._hass.getOpenEventSubscriptionCount();
}
/**
* Hand the card a new `hass` with nothing in it changed.
*/
+60
View File
@@ -726,6 +726,66 @@ describe('Camera', () => {
expect(eventWatcher.unsubscribe).toHaveBeenCalled();
});
it('should release earlier subscriptions when a later subscription throws', async () => {
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
events: [{ event_type: 'zha_event' }],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
const error = new Error('subscribe failed');
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
vi.mocked(eventWatcher.subscribe).mockImplementation(() => {
throw error;
});
await expect(
camera.initialize({
hassManager: createHASSManager({ stateWatcher, eventWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
}),
).rejects.toThrow(error);
// The state subscription was registered before the failure, so nothing
// else can release it.
expect(stateWatcher.subscribe).toHaveBeenCalled();
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
});
it('should report the initialization failure when cleanup also fails', async () => {
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
events: [{ event_type: 'zha_event' }],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
const error = new Error('subscribe failed');
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
vi.mocked(stateWatcher.unsubscribe).mockRejectedValue(new Error('destroy failed'));
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
vi.mocked(eventWatcher.subscribe).mockImplementation(() => {
throw error;
});
await expect(
camera.initialize({
hassManager: createHASSManager({ stateWatcher, eventWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
}),
).rejects.toThrow(error);
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
});
it('should attach a context-only matcher when only a context filter is set', async () => {
const camera = new Camera(
createCameraConfig({
+43 -2
View File
@@ -19,6 +19,7 @@ import type {
FrigateReviewWatcher,
} from '../../../src/camera-manager/frigate/watcher';
import type { ActionsExecutor } from '../../../src/card-controller/actions/types';
import type { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
import type { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
import type { CameraTriggerMediaEventType } from '../../../src/config/schema/cameras';
import type {
@@ -831,6 +832,46 @@ describe('FrigateCamera', () => {
);
});
it('should release base class subscriptions when subscribing throws', async () => {
const camera = new FrigateCamera(
createCameraConfig({
frigate: {
client_id: 'CLIENT_ID',
camera_name: 'CAMERA',
},
triggers: {
media_events: ['events'],
entities: ['binary_sensor.motion'],
},
}),
mock<CameraManagerEngine>(),
);
const error = new Error('subscribe failed');
const eventWatcher = mock<FrigateEventWatcher>();
vi.mocked(eventWatcher.subscribe).mockImplementation(() => {
throw error;
});
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await expect(
camera.initialize({
hassManager: createHASSManager({ stateWatcher }),
entityRegistryManager: mock<EntityRegistryManager>(),
frigateEventWatcher: eventWatcher,
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
}),
).rejects.toThrow(error);
// The state subscription belongs to the base class, which registered it
// before `_initializeAfterCapabilities` ran and therefore before this
// failure. Nothing here knows about it, so destroying the whole camera is
// the only thing that can release it.
expect(stateWatcher.subscribe).toHaveBeenCalled();
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
});
it('should not subscribe with no trigger events', async () => {
const camera = new FrigateCamera(
createCameraConfig({
@@ -969,8 +1010,8 @@ describe('FrigateCamera', () => {
await camera.destroy();
// `_destroyed` short-circuits initialize() after the pending await, so
// neither watcher is ever subscribed.
// `_destroyed` short-circuits `_initializeAfterCapabilities`, so neither
// watcher is ever subscribed.
expect(eventWatcher.subscribe).not.toHaveBeenCalled();
expect(reviewWatcher.subscribe).not.toHaveBeenCalled();
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest';
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
import {
createStillCameraHASS,
createStillImageCameraConfig,
createStillImageCardConfig,
getBlockNotificationText,
} from '../browser/test-utils';
const INIT_FAILED_ISSUE_HEADING = 'Initialization failed';
const TRIGGERING_CAMERA_ENTITY = 'camera.triggering';
const OTHER_TRIGGERING_CAMERA_ENTITY = 'camera.triggering_too';
/**
* A camera that subscribes to a Home Assistant event once initialized, so that
* whether it was cleaned up is externally observable.
*/
const createSubscribingCameraConfig = (
cameraEntity: string,
cameraID?: string,
): RawAdvancedCameraCardConfig => ({
...createStillImageCameraConfig(cameraEntity),
...(cameraID && { id: cameraID }),
triggers: {
events: [{ event_type: 'acc_test_event' }],
},
});
describe('CameraManager', () => {
it('should release the subscriptions of cameras that initialized before initialization failed', async () => {
// Duplicate identifiers are rejected only after every camera has been
// built, so both cameras are live and subscribed when the failure happens.
const DUPLICATE_ID = 'duplicate';
const card = await MountedCardFactory.createFromSource(
createStillImageCardConfig({
cameras: [
createSubscribingCameraConfig(TRIGGERING_CAMERA_ENTITY, DUPLICATE_ID),
createSubscribingCameraConfig(OTHER_TRIGGERING_CAMERA_ENTITY, DUPLICATE_ID),
],
view: { issues: { retry_seconds: 0 } },
}),
createStillCameraHASS({
cameras: [TRIGGERING_CAMERA_ENTITY, OTHER_TRIGGERING_CAMERA_ENTITY],
}),
);
await vi.waitFor(() =>
expect(getBlockNotificationText(card.card)).toContain(INIT_FAILED_ISSUE_HEADING),
);
// Both cameras are unreachable once initialization has failed, so nothing
// else could ever release what they subscribed to.
await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(0));
card.destroy();
});
it('should release camera subscriptions when the card is taken off the page', async () => {
const card: MountedCard = await MountedCardFactory.createFromSource(
createStillImageCardConfig({
cameras: [createSubscribingCameraConfig(TRIGGERING_CAMERA_ENTITY)],
}),
createStillCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY] }),
);
await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(1));
card.detach();
await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(0));
card.destroy();
});
});
+240 -2
View File
@@ -16,6 +16,7 @@ import {
CameraQueryClassifier,
QueryResultClassifier,
} from '../../src/camera-manager/manager.js';
import type { CameraManagerStore } from '../../src/camera-manager/store.js';
import {
Engine,
QueryResultsType,
@@ -266,6 +267,11 @@ describe('CameraManager', () => {
engine?: CameraManagerEngine,
cameras: {
config?: CameraConfig;
// Replaces what the engine does for this camera, for cases the default
// path cannot express (e.g. failing, or completing out of order).
createCamera?: (cameraConfig: CameraConfig) => Promise<Camera>;
engineType?: Engine | null;
capabilties?: Capabilities;
stateWatcher?: StateWatcherSubscriptionInterface;
@@ -290,13 +296,14 @@ describe('CameraManager', () => {
camera.engineType === undefined ? Engine.Generic : camera.engineType;
if (engineType) {
vi.mocked(mockEngine.createCamera).mockImplementationOnce(
async (cameraConfig: CameraConfig): Promise<Camera> =>
camera.createCamera ??
(async (cameraConfig: CameraConfig): Promise<Camera> =>
await createInitializedCamera(
cameraConfig,
mockEngine,
camera.capabilties ?? createCapabilities(),
camera.stateWatcher,
),
)),
);
}
vi.mocked(mockFactory.getEngineForCamera).mockResolvedValueOnce(engineType);
@@ -437,6 +444,237 @@ describe('CameraManager', () => {
expect(order).toEqual(['destroy-done', 'destroy-done', 'throw']);
});
describe('should handle a camera that fails to initialize', () => {
const createAPI = (): CardController => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
return api;
};
it('should destroy a camera that initialized before the failure', async () => {
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
const error = new Error('initialization failed');
const manager = createCameraManager(createAPI(), mock<CameraManagerEngine>(), [
{ capabilties: createCapabilities({ trigger: true }), stateWatcher },
{ createCamera: () => Promise.reject(error) },
]);
await expect(manager.initializeCamerasFromConfig()).rejects.toThrow(error);
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
expect(manager.getStore().getCameraCount()).toBe(0);
});
it('should destroy a camera that initialized after the failure', async () => {
const engine = mock<CameraManagerEngine>();
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
const error = new Error('initialization failed');
let releaseSlowCamera: () => void = () => {};
const slowCameraReady = new Promise<void>((resolve) => {
releaseSlowCamera = resolve;
});
const manager = createCameraManager(createAPI(), engine, [
{ createCamera: () => Promise.reject(error) },
{
createCamera: async (cameraConfig) => {
await slowCameraReady;
return await createInitializedCamera(
cameraConfig,
engine,
createCapabilities({ trigger: true }),
stateWatcher,
);
},
},
]);
const initialization = manager.initializeCamerasFromConfig();
releaseSlowCamera();
await expect(initialization).rejects.toThrow(error);
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
});
it('should report the initialization failure when a camera cannot be destroyed', async () => {
const error = new Error('initialization failed');
const unluckyWatcher = mock<StateWatcherSubscriptionInterface>();
vi.mocked(unluckyWatcher.unsubscribe).mockRejectedValue(
new Error('destroy failed'),
);
// Destroy completion is observable through the trigger-path
// unsubscribe, without spying on any Camera method.
const slowWatcher = mock<StateWatcherSubscriptionInterface>();
let releaseSlowDestroy: () => void = () => {};
vi.mocked(slowWatcher.unsubscribe).mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseSlowDestroy = resolve;
}),
);
const cameraEntry = { capabilties: createCapabilities({ trigger: true }) };
const manager = createCameraManager(createAPI(), mock<CameraManagerEngine>(), [
{ ...cameraEntry, stateWatcher: unluckyWatcher },
{ ...cameraEntry, stateWatcher: slowWatcher },
{ createCamera: () => Promise.reject(error) },
]);
let settled = false;
const initialization = manager
.initializeCamerasFromConfig()
.catch((e: unknown) => {
settled = true;
throw e;
});
await vi.waitFor(() => expect(slowWatcher.unsubscribe).toHaveBeenCalled());
expect(settled).toBe(false);
releaseSlowDestroy();
// The failing destroy neither masks the initialization error nor
// prevents the other camera from being destroyed.
await expect(initialization).rejects.toThrow(error);
expect(unluckyWatcher.unsubscribe).toHaveBeenCalled();
});
});
describe('should discard cameras nothing will own', () => {
const createSlowManager = (): {
manager: CameraManager;
stateWatcher: StateWatcherSubscriptionInterface;
releaseCamera: () => void;
} => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const engine = mock<CameraManagerEngine>();
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
let releaseCamera: () => void = () => {};
const cameraReady = new Promise<void>((resolve) => {
releaseCamera = resolve;
});
const manager = createCameraManager(api, engine, [
{
createCamera: async (cameraConfig) => {
await cameraReady;
return await createInitializedCamera(
cameraConfig,
engine,
createCapabilities({ trigger: true }),
stateWatcher,
);
},
},
]);
return { manager, stateWatcher, releaseCamera: () => releaseCamera() };
};
it('should destroy cameras built after the manager was destroyed', async () => {
const { manager, stateWatcher, releaseCamera } = createSlowManager();
const initialization = manager.initializeCamerasFromConfig();
await manager.destroy();
releaseCamera();
await initialization;
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
expect(manager.getStore().getCameraCount()).toBe(0);
});
it('should destroy cameras built by a superseded initialization', async () => {
const { manager, stateWatcher, releaseCamera } = createSlowManager();
const superseded = manager.initializeCamerasFromConfig();
// A second initialization takes over. It cannot build cameras of its
// own from the exhausted mocks; what is under test is that the first
// initialization's cameras are discarded rather than stored.
const current = manager.initializeCamerasFromConfig();
releaseCamera();
await expect(current).rejects.toThrow(CameraNoEngineError);
await superseded;
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
expect(manager.getStore().getCameraCount()).toBe(0);
});
it('should discard cameras superseded by a call that cannot proceed', async () => {
const { manager, stateWatcher, releaseCamera } = createSlowManager();
const superseded = manager.initializeCamerasFromConfig();
// A newer call that returns early still supersedes: the cameras being
// built belong to a configuration that no longer applies.
vi.mocked(manager['_api'].getConfigManager().getConfig).mockReturnValue(null);
await manager.initializeCamerasFromConfig();
releaseCamera();
await superseded;
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
expect(manager.getStore().getCameraCount()).toBe(0);
});
it('should not reset the store while a commit is in flight', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ cameras: [{ id: 'id', engine: 'generic' }] }),
);
const engine = mock<CameraManagerEngine>();
const factory = mock<CameraManagerEngineFactory>();
vi.mocked(factory.createEngine).mockResolvedValue(engine);
vi.mocked(factory.getEngineForCamera).mockResolvedValue(Engine.Generic);
vi.mocked(engine.createCamera).mockImplementation(
async (cameraConfig: CameraConfig) =>
await createInitializedCamera(cameraConfig, engine, createCapabilities()),
);
// The real store mutates incrementally; a mock makes the commit window
// externally controllable.
const order: string[] = [];
const store = mock<CameraManagerStore>();
let releaseCommit: () => void = () => {};
vi.mocked(store.setCameras).mockImplementation(
() =>
new Promise<void>((resolve) => {
order.push('commit-start');
releaseCommit = () => {
order.push('commit-end');
resolve();
};
}),
);
vi.mocked(store.reset).mockImplementation(async () => {
order.push('reset');
});
const manager = new CameraManager(api, { factory, store });
const initialization = manager.initializeCamerasFromConfig();
await vi.waitFor(() => expect(store.setCameras).toHaveBeenCalled());
const destruction = manager.destroy();
releaseCommit();
await Promise.all([initialization, destruction]);
// The reset waits for the commit rather than interleaving with it.
expect(order).toEqual(['commit-start', 'commit-end', 'reset']);
});
});
it('should reject missing engine', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());