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();