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
+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();
});
});
+245 -7
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> =>
await createInitializedCamera(
cameraConfig,
mockEngine,
camera.capabilties ?? createCapabilities(),
camera.stateWatcher,
),
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());