fix: Ensure no camera outlives a failed initialization (#2657)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user