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
+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);
}
}