-
Notifications
You must be signed in to change notification settings - Fork 289
finalization strategy for worker listeners #2079
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+288
−4
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
0fdaaba
prevent datacryptor errors flooding with rate limiter
lukasIO 8ebd62b
finalization strategy for worker listeners
lukasIO d402e06
prettier
lukasIO 941e550
re-add logs
lukasIO 2482aa8
add tests and reject pending futures on dispose
lukasIO 28e9f18
lint
lukasIO 165ff38
fix logger export
lukasIO bf9d7f1
fix import
lukasIO df9c418
use cryptor error to signal data encryption errors on dispose
lukasIO 6b2d59c
add tests and reject pending futures on dispose
lukasIO 98c266f
lint
lukasIO File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { LogLevel, getWorkerLogLevelListenerCount, setLogLevel, workerLogger } from '../logger'; | ||
| import Room from '../room/Room'; | ||
| import { E2EEManager } from './E2eeManager'; | ||
| import { BaseKeyProvider } from './KeyProvider'; | ||
|
|
||
| /** | ||
| * Install just enough of the DOM to let isE2EESupported() return true so | ||
| * setup() doesn't throw. | ||
| */ | ||
| function installE2EEShims() { | ||
| const w = window as unknown as Record<string, any>; | ||
| if (typeof w.RTCRtpSender === 'undefined') { | ||
| w.RTCRtpSender = class {}; | ||
| } | ||
| w.RTCRtpSender.prototype.createEncodedStreams = () => {}; | ||
| } | ||
|
|
||
| class FakeWorker { | ||
| postMessage = vi.fn(); | ||
|
|
||
| onmessage: unknown = null; | ||
|
|
||
| onerror: unknown = null; | ||
|
|
||
| levelMessages(): LogLevel[] { | ||
| return this.postMessage.mock.calls | ||
| .map(([m]) => m) | ||
| .filter((m: any) => m?.kind === 'setLogLevel') | ||
| .map((m: any) => m.data.level); | ||
| } | ||
| } | ||
|
|
||
| function makeManager() { | ||
| installE2EEShims(); | ||
| const room = new Room(); | ||
| const worker = new FakeWorker(); | ||
| const manager = new E2EEManager( | ||
| { keyProvider: new BaseKeyProvider({ sharedKey: true }), worker: worker as unknown as Worker }, | ||
| false, | ||
| ); | ||
| return { room, worker, manager }; | ||
| } | ||
|
|
||
| describe('E2EEManager log-level listener lifecycle', () => { | ||
| const startingLevel = workerLogger.getLevel(); | ||
| const startingCount = getWorkerLogLevelListenerCount(); | ||
|
|
||
| afterEach(() => { | ||
| setLogLevel(startingLevel); | ||
| }); | ||
|
|
||
| it('forwards level changes to the worker while subscribed', () => { | ||
| const { room, worker, manager } = makeManager(); | ||
| manager.setup(room); | ||
| worker.postMessage.mockClear(); | ||
|
|
||
| setLogLevel(LogLevel.debug); | ||
|
|
||
| expect(worker.levelMessages()).toEqual([LogLevel.debug]); | ||
| manager.dispose(); | ||
| }); | ||
|
|
||
| it('dispose() removes the listener and stops forwarding', () => { | ||
| const { room, worker, manager } = makeManager(); | ||
| manager.setup(room); | ||
| manager.dispose(); | ||
| worker.postMessage.mockClear(); | ||
|
|
||
| setLogLevel(LogLevel.warn); | ||
|
|
||
| expect(worker.postMessage).not.toHaveBeenCalled(); | ||
| expect(getWorkerLogLevelListenerCount()).toBe(startingCount); | ||
| }); | ||
|
|
||
| it('re-setup with a new room does not stack listeners', () => { | ||
| const { worker, manager } = makeManager(); | ||
| const roomA = new Room(); | ||
| const roomB = new Room(); | ||
| manager.setup(roomA); | ||
| const countAfterFirst = getWorkerLogLevelListenerCount(); | ||
| manager.setup(roomB); | ||
| expect(getWorkerLogLevelListenerCount()).toBe(countAfterFirst); | ||
|
|
||
| worker.postMessage.mockClear(); | ||
| setLogLevel(LogLevel.debug); | ||
| expect(worker.levelMessages()).toEqual([LogLevel.debug]); // exactly one delivery | ||
|
|
||
| manager.dispose(); | ||
| }); | ||
|
|
||
| it('dispose() is idempotent', () => { | ||
| const { room, manager } = makeManager(); | ||
| manager.setup(room); | ||
| manager.dispose(); | ||
| manager.dispose(); | ||
| expect(getWorkerLogLevelListenerCount()).toBe(startingCount); | ||
| }); | ||
|
|
||
| it('dispose() rejects pending encrypt/decrypt futures and clears both maps', async () => { | ||
| const { room, manager } = makeManager(); | ||
| manager.setup(room); | ||
|
|
||
| const encrypting = manager.encryptData(new Uint8Array([1, 2, 3]) as any); | ||
| const decrypting = manager.handleEncryptedData( | ||
| new Uint8Array([4, 5, 6]) as any, | ||
| new Uint8Array([7, 8, 9]) as any, | ||
| 'peer', | ||
| 0, | ||
| ); | ||
|
|
||
| const priv = manager as unknown as { | ||
| encryptDataRequests: Map<string, unknown>; | ||
| decryptDataRequests: Map<string, unknown>; | ||
| }; | ||
| expect(priv.encryptDataRequests.size).toBe(1); | ||
| expect(priv.decryptDataRequests.size).toBe(1); | ||
|
|
||
| manager.dispose(); | ||
|
|
||
| await expect(encrypting).rejects.toThrow(/disposed/); | ||
| await expect(decrypting).rejects.toThrow(/disposed/); | ||
| expect(priv.encryptDataRequests.size).toBe(0); | ||
| expect(priv.decryptDataRequests.size).toBe(0); | ||
|
|
||
| // Second dispose while maps are empty must not throw. | ||
| expect(() => manager.dispose()).not.toThrow(); | ||
| }); | ||
| }); | ||
|
|
||
| /** | ||
| * GC-path test. Flaky by construction — FinalizationRegistry callbacks are | ||
| * best-effort. Skipped unless vitest is run with `--expose-gc`: | ||
| * | ||
| * NODE_OPTIONS="--expose-gc" pnpm exec vitest run src/e2ee/E2eeManager.test.ts | ||
| * | ||
| * Deliberately bypasses `manager.setup(room)`. `new Room()` on its own is not | ||
| * collectable in this test environment (device-change listeners, timers), and | ||
| * that leak is not what this test is about — it would only mask what we | ||
| * actually want to verify: that the log-level listener wiring holds nothing | ||
| * strongly. | ||
| */ | ||
| describe('E2EEManager GC cleanup', () => { | ||
| const startingLevel = workerLogger.getLevel(); | ||
|
|
||
| beforeEach(() => { | ||
| installE2EEShims(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| setLogLevel(startingLevel); | ||
| }); | ||
|
|
||
| it.skipIf(!(globalThis as any).gc)( | ||
| 'releases the log-level listener when the manager is garbage collected', | ||
| async () => { | ||
| const before = getWorkerLogLevelListenerCount(); | ||
|
|
||
| // Construct + subscribe in an IIFE so nothing lives on the test's stack. | ||
| // Direct call to the private subscription — no Room, no leaky graph. | ||
| const managerRef = ((): WeakRef<E2EEManager> => { | ||
| const worker = new FakeWorker(); | ||
| const manager = new E2EEManager( | ||
| { | ||
| keyProvider: new BaseKeyProvider({ sharedKey: true }), | ||
| worker: worker as unknown as Worker, | ||
| }, | ||
| false, | ||
| ); | ||
| (manager as unknown as { subscribeToLogLevelChanges(): void }).subscribeToLogLevelChanges(); | ||
| expect(getWorkerLogLevelListenerCount()).toBe(before + 1); | ||
| return new WeakRef(manager); | ||
| })(); | ||
|
|
||
| // Full major GC + macrotask yield in a loop, with allocation pressure to | ||
| // force the major sweep FinalizationRegistry needs. | ||
| // | ||
| // Crucial: do NOT call `managerRef.deref()` inside the loop. Per spec, | ||
| // `WeakRef.prototype.deref` keeps the referent alive until the end of the | ||
| // current job — calling it in the check would pin the manager forever. | ||
| // Read the listener count (which does not touch the referent) instead. | ||
| const gc = (globalThis as any).gc as (opts?: { type?: 'major'; execution?: 'sync' }) => void; | ||
| for (let i = 0; i < 50; i++) { | ||
| // eslint-disable-next-line no-void | ||
| void new Array(100_000).fill({ i }); | ||
| gc({ type: 'major', execution: 'sync' }); | ||
| await new Promise((r) => setImmediate(r)); | ||
| if (getWorkerLogLevelListenerCount() === before) break; | ||
| } | ||
|
|
||
| // Diagnostic: separate "manager wasn't collected" from "FR didn't fire". | ||
| expect(managerRef.deref(), 'manager was not collected — strong ref leaked').toBeUndefined(); | ||
| expect(getWorkerLogLevelListenerCount()).toBe(before); | ||
| }, | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ import { | |
| import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays'; | ||
| import type { BaseKeyProvider } from './KeyProvider'; | ||
| import { E2EE_FLAG, E2EE_TRACK_ID } from './constants'; | ||
| import { CryptorError, CryptorErrorReason } from './errors'; | ||
| import { type E2EEManagerCallbacks, EncryptionEvent, KeyProviderEvent } from './events'; | ||
| import type { | ||
| DecryptDataRequestMessage, | ||
|
|
@@ -62,6 +63,7 @@ export interface BaseE2EEManager { | |
| keyIndex: number, | ||
| ): Promise<DecryptDataResponseMessage['data']>; | ||
| on<E extends keyof E2EEManagerCallbacks>(event: E, listener: E2EEManagerCallbacks[E]): this; | ||
| dispose?(): void; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -87,6 +89,20 @@ export class E2EEManager | |
|
|
||
| private dataChannelEncryptionEnabled: boolean; | ||
|
|
||
| private unsubscribeLogLevel?: () => void; | ||
|
|
||
| /** | ||
| * Runs a cleanup callback once this manager is garbage collected. Lets the | ||
| * log-level listener (held in a module-global Set on the main-thread logger) | ||
| * fall out of scope even when the consumer forgets to call `dispose()`. | ||
| */ | ||
| private static disposeRegistry = | ||
| typeof FinalizationRegistry !== 'undefined' && | ||
| typeof WeakRef !== 'undefined' && | ||
| new FinalizationRegistry((cleanup: () => void) => { | ||
| cleanup(); | ||
| }); | ||
|
|
||
| constructor(options: E2EEManagerOptions, dcEncryptionEnabled: boolean) { | ||
| super(); | ||
| this.keyProvider = options.keyProvider; | ||
|
|
@@ -129,13 +145,80 @@ export class E2EEManager | |
| this.worker.onmessage = this.onWorkerMessage; | ||
| this.worker.onerror = this.onWorkerError; | ||
| this.worker.postMessage(msg); | ||
| onWorkerLogLevelChanged((level) => { | ||
| this.worker?.postMessage({ kind: 'setLogLevel', data: { level } }); | ||
| }); | ||
| this.subscribeToLogLevelChanges(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Subscribe the current worker to main-thread `workerLogger` level changes, | ||
| * without strongly retaining `this` or `this.worker` from the module-global | ||
| * listener Set on the logger. See {@link disposeRegistry}. | ||
| */ | ||
| private subscribeToLogLevelChanges() { | ||
| // Guard against duplicate registration on re-setup. | ||
| this.unsubscribeLogLevel?.(); | ||
|
|
||
| let unsub: (() => void) | undefined; | ||
| if (E2EEManager.disposeRegistry) { | ||
| // Modern engines: hold the worker weakly so the module-global listener Set | ||
| // on the logger can't retain this manager, and clean up the entry on GC. | ||
| const workerRef = new WeakRef(this.worker); | ||
| unsub = onWorkerLogLevelChanged((level) => { | ||
| const worker = workerRef.deref(); | ||
| if (!worker) { | ||
| unsub?.(); | ||
| return; | ||
| } | ||
| worker.postMessage({ kind: 'setLogLevel', data: { level } }); | ||
| }); | ||
| E2EEManager.disposeRegistry.register(this, unsub, this); | ||
| } else { | ||
| // Safari <14.1 and similar: no WeakRef. Fall back to a strong reference; | ||
| // the leak lives until the consumer calls `dispose()`. | ||
| const worker = this.worker; | ||
| unsub = onWorkerLogLevelChanged((level) => { | ||
| worker.postMessage({ kind: 'setLogLevel', data: { level } }); | ||
| }); | ||
| } | ||
| this.unsubscribeLogLevel = unsub; | ||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| * Release the log-level subscription, reject any pending encrypt/decrypt | ||
| * futures, and detach the worker message handlers. The worker itself is | ||
| * caller-owned and is not terminated. Idempotent. | ||
| */ | ||
| dispose() { | ||
| this.unsubscribeLogLevel?.(); | ||
| this.unsubscribeLogLevel = undefined; | ||
| if (E2EEManager.disposeRegistry) { | ||
| E2EEManager.disposeRegistry.unregister(this); | ||
| } | ||
|
|
||
| // Reject pending futures BEFORE detaching worker handlers, so any late | ||
| // response can't resolve one after we've cut the pipe. Each future's | ||
| // `onFinally` deletes its own map entry, so both maps drain themselves. | ||
| // Snapshot before iterating in case a rejection handler mutates the map. | ||
| const disposalError = new CryptorError( | ||
| 'E2EEManager disposed', | ||
| CryptorErrorReason.InternalError, | ||
| ); | ||
| for (const future of [...this.encryptDataRequests.values()]) { | ||
| future.reject?.(disposalError); | ||
| } | ||
| for (const future of [...this.decryptDataRequests.values()]) { | ||
| future.reject?.(disposalError); | ||
| } | ||
|
|
||
| if (this.worker) { | ||
| this.worker.onmessage = null; | ||
| this.worker.onerror = null; | ||
| } | ||
|
Comment on lines
+215
to
+218
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
1egoman marked this conversation as resolved.
|
||
| this.removeAllListeners(); | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| */ | ||
|
|
@@ -175,7 +258,7 @@ export class E2EEManager | |
| break; | ||
| } | ||
| } | ||
|
|
||
| log.error(data.error.message); | ||
| this.emit(EncryptionEvent.EncryptionError, data.error, data.participantIdentity); | ||
| break; | ||
| case 'initAck': | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: Have you been able to actually verify this runs? I think there's a reference cycle between E2eeManager and Room currently (
E2eeManager.roomandRoom.e2eeManager) so I'm not convinced without breaking this cycle thatE2eeManagerwould ever get garbage collected.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you're right, that this won't work on its own, or rather won't address the issues around the cyclical Room references.
My intention was to set up everything in a way that would allow for it to get dropped once the cyclical room references are taken care of.
Addressing this is out of scope for this PR however.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added tests to verify the cleanup works without room references as expected, the value of that is somewhat questionable within the greater picture, but it allows us to verify the cleanup logic within the module works as expected.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good. I suppose this does set us up well to have this all working once we can break that reference cycle. It doesn't have to be part of this PR, but I wonder if it could be prudent to introduce that
room.dispose()type method we talked about in a 1:1 sooner rather than later and that could be used as a way to trigger this in the more near term.