diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index 1c6c2f1d..24b6dbb8 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -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, + unsubscribe: () => void | Promise, + ): Promise { + 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 { - 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 { diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 371976c1..67fd963a 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -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 => { diff --git a/src/camera-manager/frigate/watcher.ts b/src/camera-manager/frigate/watcher.ts index 48f37705..3433e5e5 100644 --- a/src/camera-manager/frigate/watcher.ts +++ b/src/camera-manager/frigate/watcher.ts @@ -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 { // Generic subscription interface export interface FrigateWatcherSubscriptionInterface { subscribe(hass: HomeAssistant, request: FrigateWatcherRequest): Promise; - unsubscribe(request: FrigateWatcherRequest): void; + unsubscribe(request: FrigateWatcherRequest): Promise; } /** - * 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 implements FrigateWatcherSubscriptionInterface { protected abstract _type: string; protected abstract _schema: z.ZodType; - protected _requests: FrigateWatcherRequest[] = []; - protected _unsubscribeCallback: Record = {}; + private _subscriptions = new KeyedSubscriptionManager< + string, + FrigateWatcherRequest + >((request) => request.instanceID); public async subscribe( hass: HomeAssistant, request: FrigateWatcherRequest, ): Promise { - const shouldSubscribe = !this._hasSubscribers(request.instanceID); - this._requests.push(request); - if (shouldSubscribe) { - this._unsubscribeCallback[request.instanceID] = - await hass.connection.subscribeMessage( - (data) => this._receiveHandler(request.instanceID, data), - { type: this._type, instance_id: request.instanceID }, - ); - } + await this._subscriptions.subscribe(request, () => + hass.connection.subscribeMessage( + (data) => this._receiveHandler(request.instanceID, data), + { type: this._type, instance_id: request.instanceID }, + ), + ); } public async unsubscribe(request: FrigateWatcherRequest): Promise { - 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 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); } } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 5956749b..3889907c 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -256,7 +256,7 @@ export class CameraManager { ); const destroyCameras = async () => { - cameras.forEach((camera) => camera.destroy()); + await allPromises(cameras, (camera) => camera.destroy()); }; const cameraIDs: Set = new Set(); diff --git a/src/card-controller/call/ringtone.ts b/src/card-controller/call/ringtone.ts index 794d8233..bbd6c8f9 100644 --- a/src/card-controller/call/ringtone.ts +++ b/src/card-controller/call/ringtone.ts @@ -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(); export class Ringtone { diff --git a/src/card-controller/hass/event-watcher.ts b/src/card-controller/hass/event-watcher.ts index ba298e8c..1ae05cc8 100644 --- a/src/card-controller/hass/event-watcher.ts +++ b/src/card-controller/hass/event-watcher.ts @@ -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>(); + private _subscriptions = new KeyedSubscriptionManager< + string, + EventSubscriptionRequest + >((request) => request.event_type); public async subscribe( hass: HomeAssistant, request: EventSubscriptionRequest, ): Promise { - const isFirst = !this._hasSubscribers(request.event_type); - this._requests.push(request); - if (isFirst) { - const pendingSubscription = hass.connection.subscribeEvents( + await this._subscriptions.subscribe(request, () => + hass.connection.subscribeEvents( (event) => this._receiveEvent(event), request.event_type, - ); - this._unsubscribers.set(request.event_type, pendingSubscription); - await pendingSubscription; - } + ), + ); } public async unsubscribe(request: EventSubscriptionRequest): Promise { - 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); } } diff --git a/src/ha/types.ts b/src/ha/types.ts index 124a2a39..98e0317b 100644 --- a/src/ha/types.ts +++ b/src/ha/types.ts @@ -243,8 +243,6 @@ export interface HassStateDifference { newState: HassEntity; } -export type SubscriptionUnsubscribe = () => Promise; - // ************************************************************************* // Home Assistant API types. // ************************************************************************* diff --git a/src/utils/keyed-subscription-manager.ts b/src/utils/keyed-subscription-manager.ts new file mode 100644 index 00000000..cfcee7d1 --- /dev/null +++ b/src/utils/keyed-subscription-manager.ts @@ -0,0 +1,65 @@ +import PQueue from 'p-queue'; + +type UnsubscribeFn = () => Promise; +type SubscribeFn = () => Promise; + +/** + * 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 { + private _requests: R[] = []; + private _unsubscribers = new Map(); + private _queues = new Map(); + private _getKeyFn: (request: R) => K; + + constructor(getKeyFn: (request: R) => K) { + this._getKeyFn = getKeyFn; + } + + public async subscribe(request: R, subscribeFn: SubscribeFn): Promise { + 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 { + 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); + } +} diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index 4721eede..aff8debc 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -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(), + ); + const hass = createHASS(); + let resolveSubscribe: () => void = () => {}; + const eventWatcher = mock(); + const reviewWatcher = mock(); + vi.mocked(eventWatcher.subscribe).mockReturnValue( + new Promise((resolve) => { + resolveSubscribe = resolve; + }), + ); + + const initializePromise = camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + eventWatcher: mock(), + 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([ diff --git a/tests/camera-manager/frigate/watcher.test.ts b/tests/camera-manager/frigate/watcher.test.ts index 444d19cc..2e7e81b4 100644 --- a/tests/camera-manager/frigate/watcher.test.ts +++ b/tests/camera-manager/frigate/watcher.test.ts @@ -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', () => { diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index a3027d2e..28cb0b44 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -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(); + vi.mocked(watcher.unsubscribe).mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => { + order.push('destroy-done'); + resolve(); + }, 0), + ), + ); + return watcher; + }; + + const cameraEntry = { + config: cameraConfig, + capabilties: createCapabilities({ trigger: true }), + }; + const manager = createCameraManager(api, mock(), [ + { ...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()); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 251b6b45..26f93ddf 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -120,11 +120,12 @@ export const createInitializedCamera = async ( config: CameraConfig, engine: CameraManagerEngine, capabilities?: Capabilities, + stateWatcher?: StateWatcherSubscriptionInterface, ): Promise => { const camera = new Camera(config, engine); await camera.initialize({ hass: createHASS(), - stateWatcher: mock(), + stateWatcher: stateWatcher ?? mock(), eventWatcher: mock(), ...(capabilities ? { capabilityOptions: { capabilities } } : {}), }); diff --git a/tests/utils/keyed-subscription-manager.test.ts b/tests/utils/keyed-subscription-manager.test.ts new file mode 100644 index 00000000..d4abbdbb --- /dev/null +++ b/tests/utils/keyed-subscription-manager.test.ts @@ -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 => + new KeyedSubscriptionManager((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) | undefined; + const openPromise = new Promise<() => Promise>((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]); + }); +});