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
+28 -4
View File
@@ -58,6 +58,7 @@ export class Camera {
protected _capabilities?: Capabilities;
protected _eventCallback?: CameraEventCallback;
protected _destroyCallbacks: DestroyCallback[] = [];
protected _destroyed = false;
protected _entity: Entity | null = null;
constructor(
@@ -91,7 +92,7 @@ export class Camera {
await this._getTriggerEntities(options);
this._config.triggers.entities = uniq(this._config.triggers.entities);
// Subscribe to state based triggers.
// Subscribe to state based triggers (sync; no race with destroy).
options.stateWatcher.subscribe(
this._stateChangeHandler,
this._config.triggers.entities,
@@ -101,14 +102,34 @@ export class Camera {
// Subscribe to event based triggers.
for (const event of this._config.triggers.events) {
const request = this._buildEventSubscriptionRequest(event);
await options.eventWatcher.subscribe(options.hass, request);
this._onDestroy(() => options.eventWatcher.unsubscribe(request));
await this._setupSubscription(
() => options.eventWatcher.subscribe(options.hass, request),
() => options.eventWatcher.unsubscribe(request),
);
}
}
return this;
}
/**
* Wire up an async subscription with its teardown. Registers the unsubscribe
* callback synchronously before awaiting subscribe, so a destroy during the
* await reliably triggers cleanup; short-circuits if destroy has already
* run, so the cleanup callback can't fire (and enqueue an unsubscribe)
* before the subscribe runs.
*/
protected async _setupSubscription(
subscribe: () => Promise<void>,
unsubscribe: () => void | Promise<void>,
): Promise<void> {
if (this._destroyed) {
return;
}
this._onDestroy(unsubscribe);
await subscribe();
}
private _buildEventSubscriptionRequest(event: TriggerEvent): EventSubscriptionRequest {
const filter = event.event_data;
return {
@@ -246,7 +267,10 @@ export class Camera {
}
public async destroy(): Promise<void> {
await Promise.all(this._destroyCallbacks.map((callback) => callback()));
this._destroyed = true;
const callbacks = this._destroyCallbacks;
this._destroyCallbacks = [];
await Promise.all(callbacks.map((callback) => callback()));
}
public getConfig(): CameraConfig {
+8 -4
View File
@@ -473,8 +473,10 @@ export class FrigateCamera extends Camera {
event.after.camera === config.frigate.camera_name,
};
await frigateEventWatcher.subscribe(hass, request);
this._onDestroy(() => frigateEventWatcher.unsubscribe(request));
await this._setupSubscription(
() => frigateEventWatcher.subscribe(hass, request),
() => frigateEventWatcher.unsubscribe(request),
);
}
private _frigateEventHandler = (ev: FrigateEventChange): void => {
@@ -548,8 +550,10 @@ export class FrigateCamera extends Camera {
review.after.camera === config.frigate.camera_name,
};
await frigateReviewWatcher.subscribe(hass, request);
this._onDestroy(() => frigateReviewWatcher.unsubscribe(request));
await this._setupSubscription(
() => frigateReviewWatcher.subscribe(hass, request),
() => frigateReviewWatcher.unsubscribe(request),
);
}
private _frigateReviewHandler = (review: FrigateReviewChange): void => {
+21 -37
View File
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { HomeAssistant, SubscriptionUnsubscribe } from '../../ha/types';
import { HomeAssistant } from '../../ha/types';
import { KeyedSubscriptionManager } from '../../utils/keyed-subscription-manager';
import {
FrigateEventChange,
FrigateReviewChange,
@@ -17,53 +18,39 @@ export interface FrigateWatcherRequest<T> {
// Generic subscription interface
export interface FrigateWatcherSubscriptionInterface<T> {
subscribe(hass: HomeAssistant, request: FrigateWatcherRequest<T>): Promise<void>;
unsubscribe(request: FrigateWatcherRequest<T>): void;
unsubscribe(request: FrigateWatcherRequest<T>): Promise<void>;
}
/**
* Base class for Frigate WebSocket watchers.
* Handles subscription management and message routing to callbacks.
* Base class for Frigate WebSocket watchers. Counted per `instanceID`: the
* first subscriber for an instance opens the WS subscription, the last to
* unsubscribe tears it down. Each message is parsed, schema-validated, and
* fanned out to every registered request whose `instanceID` matches and whose
* `matcher` accepts the payload.
*/
abstract class FrigateWatcher<T> implements FrigateWatcherSubscriptionInterface<T> {
protected abstract _type: string;
protected abstract _schema: z.ZodType<T>;
protected _requests: FrigateWatcherRequest<T>[] = [];
protected _unsubscribeCallback: Record<string, SubscriptionUnsubscribe> = {};
private _subscriptions = new KeyedSubscriptionManager<
string,
FrigateWatcherRequest<T>
>((request) => request.instanceID);
public async subscribe(
hass: HomeAssistant,
request: FrigateWatcherRequest<T>,
): Promise<void> {
const shouldSubscribe = !this._hasSubscribers(request.instanceID);
this._requests.push(request);
if (shouldSubscribe) {
this._unsubscribeCallback[request.instanceID] =
await hass.connection.subscribeMessage<string>(
(data) => this._receiveHandler(request.instanceID, data),
{ type: this._type, instance_id: request.instanceID },
);
}
await this._subscriptions.subscribe(request, () =>
hass.connection.subscribeMessage<string>(
(data) => this._receiveHandler(request.instanceID, data),
{ type: this._type, instance_id: request.instanceID },
),
);
}
public async unsubscribe(request: FrigateWatcherRequest<T>): Promise<void> {
this._requests = this._requests.filter(
(existingRequest) => existingRequest !== request,
);
if (!this._hasSubscribers(request.instanceID)) {
const callback = this._unsubscribeCallback[request.instanceID];
delete this._unsubscribeCallback[request.instanceID];
// Callback may be undefined if unsubscribe is called while subscribe is
// still awaiting the Home Assistant connection.
await callback?.();
}
}
protected _hasSubscribers(instanceID: string): boolean {
return !!this._requests.filter((request) => request.instanceID === instanceID)
.length;
await this._subscriptions.unsubscribe(request);
}
protected _receiveHandler(instanceID: string, data: string): void {
@@ -82,11 +69,8 @@ abstract class FrigateWatcher<T> implements FrigateWatcherSubscriptionInterface<
return;
}
for (const request of this._requests) {
if (
request.instanceID === instanceID &&
(!request.matcher || request.matcher(parseResult.data))
) {
for (const request of this._subscriptions.getRequestsForKey(instanceID)) {
if (!request.matcher || request.matcher(parseResult.data)) {
request.callback(parseResult.data);
}
}
+1 -1
View File
@@ -256,7 +256,7 @@ export class CameraManager {
);
const destroyCameras = async () => {
cameras.forEach((camera) => camera.destroy());
await allPromises(cameras, (camera) => camera.destroy());
};
const cameraIDs: Set<string> = new Set();
+4 -3
View File
@@ -12,9 +12,10 @@ import { WestminsterTone } from './tones/westminster';
// card placed twice), all of which may independently react to the same trigger
// state change -- with no lock, every instance would start its own AudioContext
// and the audio would layer. First-to-start wins; subsequent `start()` calls
// from other holders are no-ops until the active one releases via `stop()`. The
// lock auto-recovers from a holder that forgot to release (e.g. a controller
// GC'd without disconnect cleanup) via the `isPlaying()` sweep below.
// from other holders are no-ops until the active one releases via `stop()`.
// The `isPlaying()` sweep below is defensive against a future code path that
// clears `_tone` without removing from the lock -- today every such path keeps
// them in sync, but the sweep prevents a regression from wedging the lock.
const sharedLock = new Set<Ringtone>();
export class Ringtone {
+13 -32
View File
@@ -1,5 +1,6 @@
import { HassEvent } from 'home-assistant-js-websocket';
import { HomeAssistant, SubscriptionUnsubscribe } from '../../ha/types';
import { HomeAssistant } from '../../ha/types';
import { KeyedSubscriptionManager } from '../../utils/keyed-subscription-manager';
export interface EventSubscriptionRequest {
event_type: string;
@@ -23,50 +24,30 @@ export interface EventWatcherSubscriptionInterface {
* payload.
*/
export class EventWatcher implements EventWatcherSubscriptionInterface {
private _requests: EventSubscriptionRequest[] = [];
// Stored as a promise so an unsubscribe that races against an in-flight
// subscribe can await completion before tearing down -- otherwise the unsub
// func is unavailable and the subscription would leak (via hass.connection's
// internal subscription map).
private _unsubscribers = new Map<string, Promise<SubscriptionUnsubscribe>>();
private _subscriptions = new KeyedSubscriptionManager<
string,
EventSubscriptionRequest
>((request) => request.event_type);
public async subscribe(
hass: HomeAssistant,
request: EventSubscriptionRequest,
): Promise<void> {
const isFirst = !this._hasSubscribers(request.event_type);
this._requests.push(request);
if (isFirst) {
const pendingSubscription = hass.connection.subscribeEvents<HassEvent>(
await this._subscriptions.subscribe(request, () =>
hass.connection.subscribeEvents<HassEvent>(
(event) => this._receiveEvent(event),
request.event_type,
);
this._unsubscribers.set(request.event_type, pendingSubscription);
await pendingSubscription;
}
),
);
}
public async unsubscribe(request: EventSubscriptionRequest): Promise<void> {
this._requests = this._requests.filter((r) => r !== request);
if (!this._hasSubscribers(request.event_type)) {
const pendingSubscription = this._unsubscribers.get(request.event_type);
this._unsubscribers.delete(request.event_type);
const unsubscribeCallback = await pendingSubscription;
await unsubscribeCallback?.();
}
}
private _hasSubscribers(eventType: string): boolean {
return this._requests.some((r) => r.event_type === eventType);
await this._subscriptions.unsubscribe(request);
}
private _receiveEvent(event: HassEvent): void {
for (const request of this._requests) {
if (
request.event_type === event.event_type &&
(!request.matcher || request.matcher(event.data))
) {
for (const request of this._subscriptions.getRequestsForKey(event.event_type)) {
if (!request.matcher || request.matcher(event.data)) {
request.callback(event.data);
}
}
-2
View File
@@ -243,8 +243,6 @@ export interface HassStateDifference {
newState: HassEntity;
}
export type SubscriptionUnsubscribe = () => Promise<void>;
// *************************************************************************
// Home Assistant API types.
// *************************************************************************
+65
View File
@@ -0,0 +1,65 @@
import PQueue from 'p-queue';
type UnsubscribeFn = () => Promise<void>;
type SubscribeFn = () => Promise<UnsubscribeFn>;
/**
* Manages subscriptions keyed by `K`: the first subscriber for a key invokes
* `subscribeFn` to establish the underlying connection, subsequent subscribers
* for the same key piggyback on it, and the last to unsubscribe tears it down.
*
* Operations for a given key run through a single-concurrency queue, so
* subscribe and unsubscribe cannot interleave for the same key -- no race
* windows by construction. This aims to a leak-proof reusable subscription
* manager.
*/
export class KeyedSubscriptionManager<K, R> {
private _requests: R[] = [];
private _unsubscribers = new Map<K, UnsubscribeFn>();
private _queues = new Map<K, PQueue>();
private _getKeyFn: (request: R) => K;
constructor(getKeyFn: (request: R) => K) {
this._getKeyFn = getKeyFn;
}
public async subscribe(request: R, subscribeFn: SubscribeFn): Promise<void> {
const key = this._getKeyFn(request);
await this._queueFor(key).add(async () => {
this._requests.push(request);
if (!this._unsubscribers.has(key)) {
const unsubscribe = await subscribeFn();
this._unsubscribers.set(key, unsubscribe);
}
});
}
public async unsubscribe(request: R): Promise<void> {
const key = this._getKeyFn(request);
await this._queueFor(key).add(async () => {
this._requests = this._requests.filter((r) => r !== request);
if (!this._hasSubscribers(key)) {
const unsubscribe = this._unsubscribers.get(key);
this._unsubscribers.delete(key);
await unsubscribe?.();
}
});
}
public getRequestsForKey(key: K): readonly R[] {
return this._requests.filter((r) => this._getKeyFn(r) === key);
}
private _queueFor(key: K): PQueue {
let queue = this._queues.get(key);
if (!queue) {
queue = new PQueue({ concurrency: 1 });
this._queues.set(key, queue);
}
return queue;
}
private _hasSubscribers(key: K): boolean {
return this._requests.some((r) => this._getKeyFn(r) === key);
}
}
+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());
+2 -1
View File
@@ -120,11 +120,12 @@ export const createInitializedCamera = async (
config: CameraConfig,
engine: CameraManagerEngine,
capabilities?: Capabilities,
stateWatcher?: StateWatcherSubscriptionInterface,
): Promise<Camera> => {
const camera = new Camera(config, engine);
await camera.initialize({
hass: createHASS(),
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
stateWatcher: stateWatcher ?? mock<StateWatcherSubscriptionInterface>(),
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
...(capabilities ? { capabilityOptions: { capabilities } } : {}),
});
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest';
import { KeyedSubscriptionManager } from '../../src/utils/keyed-subscription-manager';
interface TestRequest {
key: string;
callback: () => void;
}
const create = (): KeyedSubscriptionManager<string, TestRequest> =>
new KeyedSubscriptionManager<string, TestRequest>((r) => r.key);
describe('KeyedSubscriptionManager', () => {
it('should open the subscription once per key regardless of subscriber count', async () => {
const manager = create();
const subscribeFn = vi.fn().mockResolvedValue(vi.fn());
await manager.subscribe({ key: 'a', callback: vi.fn() }, subscribeFn);
await manager.subscribe({ key: 'a', callback: vi.fn() }, subscribeFn);
expect(subscribeFn).toBeCalledTimes(1);
});
it('should open a separate subscription for each distinct key', async () => {
const manager = create();
const subscribeFn = vi.fn().mockResolvedValue(vi.fn());
await manager.subscribe({ key: 'a', callback: vi.fn() }, subscribeFn);
await manager.subscribe({ key: 'b', callback: vi.fn() }, subscribeFn);
expect(subscribeFn).toBeCalledTimes(2);
});
it('should tear down the subscription only when the last subscriber for a key unsubscribes', async () => {
const manager = create();
const unsub = vi.fn();
const subscribeFn = vi.fn().mockResolvedValue(unsub);
const req1 = { key: 'a', callback: vi.fn() };
const req2 = { key: 'a', callback: vi.fn() };
await manager.subscribe(req1, subscribeFn);
await manager.subscribe(req2, subscribeFn);
await manager.unsubscribe(req1);
expect(unsub).not.toBeCalled();
await manager.unsubscribe(req2);
expect(unsub).toBeCalledTimes(1);
});
it('should await a pending subscribe before tearing down when unsubscribed mid-flight', async () => {
const manager = create();
const unsub = vi.fn();
let resolveOpen: ((cb: () => Promise<void>) => void) | undefined;
const openPromise = new Promise<() => Promise<void>>((resolve) => {
resolveOpen = resolve;
});
const subscribeFn = vi.fn().mockReturnValue(openPromise);
const req = { key: 'a', callback: vi.fn() };
const subscribePromise = manager.subscribe(req, subscribeFn);
const unsubscribePromise = manager.unsubscribe(req);
resolveOpen?.(unsub);
await subscribePromise;
await unsubscribePromise;
expect(unsub).toBeCalledTimes(1);
});
it('should expose the requests matching a given key', async () => {
const manager = create();
const subscribeFn = vi.fn().mockResolvedValue(vi.fn());
const reqA1 = { key: 'a', callback: vi.fn() };
const reqA2 = { key: 'a', callback: vi.fn() };
const reqB = { key: 'b', callback: vi.fn() };
await manager.subscribe(reqA1, subscribeFn);
await manager.subscribe(reqA2, subscribeFn);
await manager.subscribe(reqB, subscribeFn);
expect(manager.getRequestsForKey('a')).toEqual([reqA1, reqA2]);
expect(manager.getRequestsForKey('b')).toEqual([reqB]);
await manager.unsubscribe(reqA1);
expect(manager.getRequestsForKey('a')).toEqual([reqA2]);
});
it('should treat unsubscribe of an unknown request as a no-op', async () => {
const manager = create();
const unsub = vi.fn();
const subscribeFn = vi.fn().mockResolvedValue(unsub);
const subscribed = { key: 'a', callback: vi.fn() };
await manager.subscribe(subscribed, subscribeFn);
await manager.unsubscribe({ key: 'a', callback: vi.fn() });
expect(unsub).not.toBeCalled();
expect(manager.getRequestsForKey('a')).toEqual([subscribed]);
});
});