fix: Fix leaky Frigate subscriptions/unsubscriptions (#2513)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent 0a36358394
commit 37df382aa2
13 changed files with 356 additions and 88 deletions
+57 -1
View File
@@ -1,5 +1,5 @@
import { format } from 'date-fns';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
import { FrigateCamera } from '../../../src/camera-manager/frigate/camera';
@@ -27,6 +27,7 @@ import { ViewMediaType } from '../../../src/view/item';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
import {
createCameraConfig,
createCapabilities,
createHASS,
createRegistryEntity,
createStateEntity,
@@ -965,6 +966,61 @@ describe('FrigateCamera', () => {
expect(eventWatcher.unsubscribe).toBeCalled();
});
it('should unsubscribe on destroy while event subscription is pending', async () => {
const camera = new FrigateCamera(
createCameraConfig({
frigate: { client_id: 'CLIENT_ID', camera_name: 'front_door' },
triggers: {
media_events: ['events'],
reviews: { severities: ['high'] },
},
}),
mock<CameraManagerEngine>(),
);
const hass = createHASS();
let resolveSubscribe: () => void = () => {};
const eventWatcher = mock<FrigateEventWatcher>();
const reviewWatcher = mock<FrigateReviewWatcher>();
vi.mocked(eventWatcher.subscribe).mockReturnValue(
new Promise<void>((resolve) => {
resolveSubscribe = resolve;
}),
);
const initializePromise = camera.initialize({
hass: hass,
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
frigateEventWatcher: eventWatcher,
frigateReviewWatcher: reviewWatcher,
// Pre-built so `_buildCapabilities` (which calls the un-mocked
// `liveProviderSupports2WayAudio`) is skipped and init reaches the
// pending Frigate event subscribe.
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
await vi.waitFor(() => expect(eventWatcher.subscribe).toBeCalled());
await camera.destroy();
// Destroy iterated `_destroyCallbacks` and called the unsubscribe that
// was registered before the (still pending) event subscribe.
const subscribeCall = vi.mocked(eventWatcher.subscribe).mock.calls[0];
assert(subscribeCall);
expect(eventWatcher.unsubscribe).toBeCalledWith(subscribeCall[1]);
resolveSubscribe();
await initializePromise;
// The subsequent `_subscribeToReviews` short-circuited on `_destroyed`,
// so the review watcher was never subscribed (and so never needs an
// unsubscribe -- which would otherwise be ordered before the subscribe
// in the per-key PQueue and leak the resulting subscription).
expect(reviewWatcher.subscribe).not.toBeCalled();
expect(reviewWatcher.unsubscribe).not.toBeCalled();
});
describe('should call handler correctly', () => {
describe('should handle event type correctly', () => {
it.each([
+10 -3
View File
@@ -138,12 +138,19 @@ describe('FrigateEventWatcher', () => {
const subscribePromise = stateWatcher.subscribe(hass, request);
// Unsubscribe while subscription is still pending.
await stateWatcher.unsubscribe(request);
const unsubscribePromise = stateWatcher.unsubscribe(request);
// Complete the subscription.
// Complete the subscription: both subscribe and unsubscribe await the same
// pending promise, and unsubscribe then invokes the resolved unsub.
const unsubscribeCallback = vi.fn();
assert(resolveSubscription);
resolveSubscription(vi.fn());
resolveSubscription(unsubscribeCallback);
await subscribePromise;
await unsubscribePromise;
expect(unsubscribeCallback).toBeCalledTimes(1);
callHASubscribeMessageCallback(hass, JSON.stringify(createEventChange()));
expect(request.callback).not.toBeCalled();
});
describe('should call handler', () => {
+45
View File
@@ -27,6 +27,7 @@ import {
QueryType,
} from '../../src/camera-manager/types.js';
import { CardController } from '../../src/card-controller/controller.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { sortItems } from '../../src/card-controller/view/sort.js';
import { CameraConfig } from '../../src/config/schema/cameras.js';
import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js';
@@ -274,6 +275,7 @@ describe('CameraManager', () => {
config?: CameraConfig;
engineType?: Engine | null;
capabilties?: Capabilities;
stateWatcher?: StateWatcherSubscriptionInterface;
}[] = [{}],
factory?: CameraManagerEngineFactory,
): CameraManager => {
@@ -300,6 +302,7 @@ describe('CameraManager', () => {
cameraConfig,
mockEngine,
camera.capabilties ?? createCapabilities(),
camera.stateWatcher,
),
);
}
@@ -399,6 +402,48 @@ describe('CameraManager', () => {
);
});
it('should await camera destruction before throwing on duplicate id', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const cameraConfig = createCameraConfig({
id: 'DUPLICATE',
engine: 'generic',
});
// Camera.destroy() awaits its _destroyCallbacks, one of which is the
// trigger-path unsubscribe. By returning a deferred Promise from each
// camera's stateWatcher.unsubscribe, we make destroy completion
// externally observable without spying on any Camera method.
const order: string[] = [];
const buildStateWatcher = (): StateWatcherSubscriptionInterface => {
const watcher = mock<StateWatcherSubscriptionInterface>();
vi.mocked(watcher.unsubscribe).mockImplementation(
() =>
new Promise<void>((resolve) =>
setTimeout(() => {
order.push('destroy-done');
resolve();
}, 0),
),
);
return watcher;
};
const cameraEntry = {
config: cameraConfig,
capabilties: createCapabilities({ trigger: true }),
};
const manager = createCameraManager(api, mock<CameraManagerEngine>(), [
{ ...cameraEntry, stateWatcher: buildStateWatcher() },
{ ...cameraEntry, stateWatcher: buildStateWatcher() },
]);
await manager.initializeCamerasFromConfig().catch(() => order.push('throw'));
expect(order).toEqual(['destroy-done', 'destroy-done', 'throw']);
});
it('should reject missing engine', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());