From 05fe734233e0c56fa4fe99d17f85d05e987ee448 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:18:00 +0900 Subject: [PATCH 1/9] fix(collaboration): contain awareness host failures Signed-off-by: Seongho Bae --- docs/CONTRACTS.md | 2 + docs/TRD.md | 2 + src/collaboration/awareness.ts | 99 ++++++++++++------- .../awarenessCountFailureContainment.test.ts | 68 +++++++++++++ ...awarenessDisposeFailureContainment.test.ts | 53 ++++++++++ .../awarenessListenerContainment.test.ts | 78 +++++++++++++++ .../awarenessProviderAccess.test.ts | 70 +++++++++++++ 7 files changed, 338 insertions(+), 34 deletions(-) create mode 100644 src/collaboration/awarenessCountFailureContainment.test.ts create mode 100644 src/collaboration/awarenessDisposeFailureContainment.test.ts create mode 100644 src/collaboration/awarenessListenerContainment.test.ts create mode 100644 src/collaboration/awarenessProviderAccess.test.ts diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 5a62716e5..1f5dba229 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -87,6 +87,8 @@ Inkspan collaboration is **provider-neutral**. A host may supply Yjs-compatible Collaboration document content and awareness state are untrusted tenant data. Awareness presence is not authorization. Provider outage is a host-owned degraded mode; Inkspan must not synthesize remote durability or identity from local Yjs state. +Host awareness capability reads and listener operations are failure-contained: incompatible access reports a stable Inkspan error, collaborator counting falls back to zero, and teardown continues across failing removals without exposing host-thrown values. This containment does not make Inkspan the provider lifecycle owner. + No secret is required by the framework-independent collaboration contract itself. Provider credentials remain host-owned and must not be embedded in editor configuration or document content. ## Naruon modular composition contract diff --git a/docs/TRD.md b/docs/TRD.md index 80cec07ac..efd2b138f 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,6 +68,8 @@ Inkspan may bind to host-supplied Yjs-compatible document/awareness surfaces. In Provider updates and awareness state are untrusted tenant data, not authorization evidence. Provider outage/degraded mode is resolved by host policy; Inkspan must not invent durable collaboration success. +Inkspan validates the host awareness capability before use and contains host-thrown capability, collaborator-count, listener-registration, listener-removal, and teardown failures behind stable editor-owned outcomes. Cleanup still attempts every registered listener removal; these guards do not transfer provider lifecycle or transport authority to Inkspan. + ## Deterministic conversion and Office renderer Deterministic conversion is an authority boundary separate from model-assisted authoring. Model output may become a proposed document change, but deterministic editor/conversion validation decides whether the resulting content is structurally acceptable. diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 0444244d4..91905d7f8 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -24,6 +24,35 @@ const NUMERIC_IDENTIFIER_PATTERN = /^\d+$/; const FALLBACK_CURSOR_COLOR = '#475569'; const MAX_CURSOR_LABEL_LENGTH = 80; +/** Read and validate the host-owned awareness capability without leaking failures. */ +function readCompatibleCollaborationAwareness( + provider: CollaborationProviderLike, +): CollaborationAwareness { + try { + const awareness = provider.awareness as + | Partial + | undefined; + if ( + awareness !== undefined && + typeof awareness.clientID === 'number' && + awareness.states instanceof Map && + typeof awareness.getLocalState === 'function' && + typeof awareness.getStates === 'function' && + typeof awareness.setLocalStateField === 'function' && + typeof awareness.on === 'function' && + typeof awareness.off === 'function' + ) { + return awareness as CollaborationAwareness; + } + } catch { + // Normalize host capability access failures at the public Inkspan boundary. + } + + throw new Error( + 'collaboration provider must expose a compatible Yjs awareness instance', + ); +} + /** Validate and serialize the only public fields permitted in awareness. */ export function serializeCollaborationUser( user: CollaborationUser, @@ -60,23 +89,7 @@ export function assertCollaborationConfiguration( } if (!provider) return; - const awareness = provider.awareness as - | Partial - | undefined; - if ( - !awareness || - typeof awareness.clientID !== 'number' || - !(awareness.states instanceof Map) || - typeof awareness.getLocalState !== 'function' || - typeof awareness.getStates !== 'function' || - typeof awareness.setLocalStateField !== 'function' || - typeof awareness.on !== 'function' || - typeof awareness.off !== 'function' - ) { - throw new Error( - 'collaboration provider must expose a compatible Yjs awareness instance', - ); - } + readCompatibleCollaborationAwareness(provider); } /** @@ -86,7 +99,7 @@ export function assertCollaborationConfiguration( export function createScopedCollaborationProvider( provider: CollaborationProviderLike, ): ScopedCollaborationProvider { - const source = provider.awareness; + const source = readCompatibleCollaborationAwareness(provider); const listenerWrappers: Record< CollaborationAwarenessEvent, Map<(...args: unknown[]) => void, (...args: unknown[]) => void> @@ -109,13 +122,21 @@ export function createScopedCollaborationProvider( on: (event, listener) => { if (listenerWrappers[event].has(listener)) return; const wrapper = (...args: unknown[]) => listener(...args); + try { + source.on(event, wrapper); + } catch { + throw new Error('collaboration awareness listener registration failed'); + } listenerWrappers[event].set(listener, wrapper); - source.on(event, wrapper); }, off: (event, listener) => { const wrapper = listenerWrappers[event].get(listener); if (!wrapper) return; - source.off(event, wrapper); + try { + source.off(event, wrapper); + } catch { + throw new Error('collaboration awareness listener removal failed'); + } listenerWrappers[event].delete(listener); }, }; @@ -128,7 +149,12 @@ export function createScopedCollaborationProvider( disposed = true; for (const event of ['change', 'update'] as const) { for (const wrapper of listenerWrappers[event].values()) { - source.off(event, wrapper); + try { + source.off(event, wrapper); + } catch { + // Host-owned listener teardown must not abort remaining cleanup or + // leak a private provider failure through React effect disposal. + } } listenerWrappers[event].clear(); } @@ -136,25 +162,30 @@ export function createScopedCollaborationProvider( }; } -/** Count remote awareness clients carrying a valid public user identifier. */ +/** Count remote awareness clients without leaking host awareness failures. */ export function countRemoteCollaborators( awareness: CollaborationAwareness | undefined, ): number { if (!awareness) return 0; - let count = 0; - for (const [clientId, state] of awareness.getStates()) { - if (clientId === awareness.clientID) continue; - const user = state.user; - if ( - typeof user === 'object' && - user !== null && - typeof (user as Record).id === 'string' && - (user as Record).id !== '' - ) { - count += 1; + try { + const localClientId = awareness.clientID; + let count = 0; + for (const [clientId, state] of awareness.getStates()) { + if (clientId === localClientId) continue; + const user = state.user; + if ( + typeof user === 'object' && + user !== null && + typeof (user as Record).id === 'string' && + (user as Record).id !== '' + ) { + count += 1; + } } + return count; + } catch { + return 0; } - return count; } /** Convert a host connection state into concise status-region text. */ diff --git a/src/collaboration/awarenessCountFailureContainment.test.ts b/src/collaboration/awarenessCountFailureContainment.test.ts new file mode 100644 index 000000000..b42272b16 --- /dev/null +++ b/src/collaboration/awarenessCountFailureContainment.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { countRemoteCollaborators } from './awareness.js'; +import type { CollaborationAwareness } from './types.js'; + +function baseAwareness(): CollaborationAwareness { + const states = new Map>(); + return { + clientID: 11, + states, + getLocalState: () => null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => undefined, + }; +} + +describe('remote collaborator count failure containment', () => { + it('fails closed without leaking getStates failures', () => { + const privateFailure = { secret: 'provider-get-states-private' }; + const awareness = { + ...baseAwareness(), + getStates: () => { + throw privateFailure; + }, + }; + + let observed: unknown; + let count: number | undefined; + try { + count = countRemoteCollaborators(awareness); + } catch (error) { + observed = error; + } + + expect(observed).toBeUndefined(); + expect(count).toBe(0); + }); + + it('fails closed without leaking clientID access failures', () => { + const privateFailure = { secret: 'provider-client-id-private' }; + const states = new Map>([ + [12, { user: { id: 'remote-one' } }], + ]); + const awareness = { + ...baseAwareness(), + states, + getStates: () => states, + } as CollaborationAwareness; + Object.defineProperty(awareness, 'clientID', { + enumerable: true, + get() { + throw privateFailure; + }, + }); + + let observed: unknown; + let count: number | undefined; + try { + count = countRemoteCollaborators(awareness); + } catch (error) { + observed = error; + } + + expect(observed).toBeUndefined(); + expect(count).toBe(0); + }); +}); diff --git a/src/collaboration/awarenessDisposeFailureContainment.test.ts b/src/collaboration/awarenessDisposeFailureContainment.test.ts new file mode 100644 index 000000000..033204875 --- /dev/null +++ b/src/collaboration/awarenessDisposeFailureContainment.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createScopedCollaborationProvider } from './awareness.js'; +import type { + CollaborationAwareness, + CollaborationAwarenessEvent, +} from './types.js'; + +describe('scoped collaboration provider cleanup containment', () => { + it('attempts every listener detachment without leaking host cleanup failures', () => { + const privateFailure = new Error('sensitive-provider-cleanup-internal'); + const sourceListeners: Record< + CollaborationAwarenessEvent, + Set<(...args: unknown[]) => void> + > = { + change: new Set(), + update: new Set(), + }; + let offCalls = 0; + const source: CollaborationAwareness = { + clientID: 7, + states: new Map(), + getLocalState: () => null, + getStates: () => new Map(), + setLocalStateField: () => undefined, + on: (event, listener) => sourceListeners[event].add(listener), + off: (event, listener) => { + offCalls += 1; + if (event === 'change') throw privateFailure; + sourceListeners[event].delete(listener); + }, + }; + const scoped = createScopedCollaborationProvider({ awareness: source }); + const changeListener = vi.fn(); + const updateListener = vi.fn(); + + scoped.awareness.on('change', changeListener); + scoped.awareness.on('update', updateListener); + + let observed: unknown; + try { + scoped.dispose(); + } catch (error) { + observed = error; + } + + expect(observed).toBeUndefined(); + expect(offCalls).toBe(2); + expect(sourceListeners.update.size).toBe(0); + + scoped.dispose(); + expect(offCalls).toBe(2); + }); +}); diff --git a/src/collaboration/awarenessListenerContainment.test.ts b/src/collaboration/awarenessListenerContainment.test.ts new file mode 100644 index 000000000..e2b7625bf --- /dev/null +++ b/src/collaboration/awarenessListenerContainment.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createScopedCollaborationProvider } from './awareness.js'; +import type { CollaborationAwareness } from './types.js'; + +function awarenessWithListenerRegistrationFailure(): { + awareness: CollaborationAwareness; + registrationAttempts: () => number; +} { + const states = new Map>(); + let attempts = 0; + const privateFailure = new Error('private provider listener registration failure'); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => { + attempts += 1; + if (attempts === 1) throw privateFailure; + }, + off: () => undefined, + }; + return { awareness, registrationAttempts: () => attempts }; +} + +function awarenessWithListenerRemovalFailure(): { + awareness: CollaborationAwareness; + removalAttempts: () => number; +} { + const states = new Map>(); + let attempts = 0; + const privateFailure = new Error('private provider listener removal failure'); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => { + attempts += 1; + if (attempts === 1) throw privateFailure; + }, + }; + return { awareness, removalAttempts: () => attempts }; +} + +describe('scoped collaboration provider listener containment', () => { + it('redacts a rejected listener registration and permits a clean retry', () => { + const source = awarenessWithListenerRegistrationFailure(); + const scoped = createScopedCollaborationProvider({ awareness: source.awareness }); + const listener = vi.fn(); + + expect(() => scoped.awareness.on('change', listener)).toThrowError( + new Error('collaboration awareness listener registration failed'), + ); + expect(source.registrationAttempts()).toBe(1); + + expect(() => scoped.awareness.on('change', listener)).not.toThrow(); + expect(source.registrationAttempts()).toBe(2); + }); + + it('redacts a rejected listener removal and retains state for retry', () => { + const source = awarenessWithListenerRemovalFailure(); + const scoped = createScopedCollaborationProvider({ awareness: source.awareness }); + const listener = vi.fn(); + + scoped.awareness.on('change', listener); + expect(() => scoped.awareness.off('change', listener)).toThrowError( + new Error('collaboration awareness listener removal failed'), + ); + expect(source.removalAttempts()).toBe(1); + + expect(() => scoped.awareness.off('change', listener)).not.toThrow(); + expect(source.removalAttempts()).toBe(2); + }); +}); diff --git a/src/collaboration/awarenessProviderAccess.test.ts b/src/collaboration/awarenessProviderAccess.test.ts new file mode 100644 index 000000000..65eeec0ac --- /dev/null +++ b/src/collaboration/awarenessProviderAccess.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + assertCollaborationConfiguration, + createScopedCollaborationProvider, +} from './awareness.js'; +import type { + CollaborationAwareness, + CollaborationProviderLike, +} from './types.js'; + +function validAwareness(): CollaborationAwareness { + const states = new Map>(); + return { + clientID: 11, + states, + getLocalState: () => null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => undefined, + }; +} + +describe('collaboration provider capability access', () => { + it('does not leak a private provider error when awareness changes after validation', () => { + const awareness = validAwareness(); + let reads = 0; + const provider = Object.defineProperty({}, 'awareness', { + enumerable: true, + get() { + reads += 1; + if (reads === 1) return awareness; + throw new Error('sensitive-provider-internal'); + }, + }) as CollaborationProviderLike; + + expect(() => + assertCollaborationConfiguration(provider, undefined), + ).not.toThrow(); + expect(() => createScopedCollaborationProvider(provider)).toThrowError( + new Error( + 'collaboration provider must expose a compatible Yjs awareness instance', + ), + ); + }); + + it('normalizes private structural awareness access failures', () => { + const privateFailure = new Error('sensitive-awareness-internal'); + const awareness = Object.defineProperty({}, 'clientID', { + enumerable: true, + get() { + throw privateFailure; + }, + }) as CollaborationAwareness; + const provider = { awareness } as CollaborationProviderLike; + + let observed: unknown; + try { + assertCollaborationConfiguration(provider, undefined); + } catch (error) { + observed = error; + } + + expect(observed).toBeInstanceOf(Error); + expect(observed).not.toBe(privateFailure); + expect((observed as Error).message).toBe( + 'collaboration provider must expose a compatible Yjs awareness instance', + ); + }); +}); From 8666ca7ebf46a0e1549f4bcdec2c5ee8c68467a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:26:47 +0900 Subject: [PATCH 2/9] test(ci): cover event-specific Python matrix Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 7104fd661..209f48454 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,10 +50,14 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) + matrix_match = re.search(r"python-version:\s*(.+)", office_job) assert matrix_match is not None - matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) - assert matrix_versions == SUPPORTED_PYTHON_VERSIONS + pull_request_versions, push_versions = ( + tuple(re.findall(r'"(3\.\d+)"', versions)) + for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + ) + assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) + assert push_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: From 7248b87c1aa2a4965a0a1318a377e600d1202d38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:32:54 +0900 Subject: [PATCH 3/9] test(ci): bind Python matrix to event Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 209f48454..a52ddec39 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,11 +50,16 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r"python-version:\s*(.+)", office_job) + matrix_match = re.search( + r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" + r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" + r"fromJSON\('(\[[^']+\])'\)\s*\}\}", + office_job, + ) assert matrix_match is not None pull_request_versions, push_versions = ( tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + for versions in matrix_match.groups() ) assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) assert push_versions == SUPPORTED_PYTHON_VERSIONS From f79017ae455ee37d99b4b0d078ed2baf388ead4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:55:43 +0900 Subject: [PATCH 4/9] test(collaboration): expose partial awareness registration leak Signed-off-by: Seongho Bae --- .../awarenessListenerContainment.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/collaboration/awarenessListenerContainment.test.ts b/src/collaboration/awarenessListenerContainment.test.ts index e2b7625bf..22369cb52 100644 --- a/src/collaboration/awarenessListenerContainment.test.ts +++ b/src/collaboration/awarenessListenerContainment.test.ts @@ -47,6 +47,35 @@ function awarenessWithListenerRemovalFailure(): { } describe('scoped collaboration provider listener containment', () => { + it('removes a listener when the host registers it before throwing', () => { + const states = new Map>(); + const listeners = new Set<(...args: unknown[]) => void>(); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => null, + getStates: () => states, + setLocalStateField: () => undefined, + on: (_event, listener) => { + listeners.add(listener); + throw new Error('private post-registration failure'); + }, + off: (_event, listener) => { + listeners.delete(listener); + }, + }; + const scoped = createScopedCollaborationProvider({ awareness }); + const listener = vi.fn(); + + expect(() => scoped.awareness.on('change', listener)).toThrowError( + new Error('collaboration awareness listener registration failed'), + ); + for (const registeredListener of listeners) registeredListener(); + + expect(listener).not.toHaveBeenCalled(); + expect(listeners.size).toBe(0); + }); + it('redacts a rejected listener registration and permits a clean retry', () => { const source = awarenessWithListenerRegistrationFailure(); const scoped = createScopedCollaborationProvider({ awareness: source.awareness }); From 8ad3e640dfc8be1cc32f2a9d2d19aa88b7ded564 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:55:59 +0900 Subject: [PATCH 5/9] fix(collaboration): roll back partial awareness registration Signed-off-by: Seongho Bae --- src/collaboration/awareness.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 91905d7f8..6f6dd9241 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -125,6 +125,11 @@ export function createScopedCollaborationProvider( try { source.on(event, wrapper); } catch { + try { + source.off(event, wrapper); + } catch { + // The host may reject rollback too; preserve the stable public error. + } throw new Error('collaboration awareness listener registration failed'); } listenerWrappers[event].set(listener, wrapper); From 284f9ce601cab4a0aa0332640125dc423c142c13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:57:41 +0900 Subject: [PATCH 6/9] test(collaboration): cover failed awareness rollback Signed-off-by: Seongho Bae --- .../awarenessListenerContainment.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/collaboration/awarenessListenerContainment.test.ts b/src/collaboration/awarenessListenerContainment.test.ts index 22369cb52..ec65e9aa0 100644 --- a/src/collaboration/awarenessListenerContainment.test.ts +++ b/src/collaboration/awarenessListenerContainment.test.ts @@ -76,6 +76,34 @@ describe('scoped collaboration provider listener containment', () => { expect(listeners.size).toBe(0); }); + it('deactivates a leaked listener when registration rollback also fails', () => { + const states = new Map>(); + const listeners = new Set<(...args: unknown[]) => void>(); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => null, + getStates: () => states, + setLocalStateField: () => undefined, + on: (_event, listener) => { + listeners.add(listener); + throw new Error('private post-registration failure'); + }, + off: () => { + throw new Error('private rollback failure'); + }, + }; + const scoped = createScopedCollaborationProvider({ awareness }); + const listener = vi.fn(); + + expect(() => scoped.awareness.on('change', listener)).toThrowError( + new Error('collaboration awareness listener registration failed'), + ); + for (const registeredListener of listeners) registeredListener(); + + expect(listener).not.toHaveBeenCalled(); + }); + it('redacts a rejected listener registration and permits a clean retry', () => { const source = awarenessWithListenerRegistrationFailure(); const scoped = createScopedCollaborationProvider({ awareness: source.awareness }); From f6c39ed0f29e76be5ec0a42a601ec2771b46bdce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:58:05 +0900 Subject: [PATCH 7/9] fix(collaboration): deactivate failed awareness listeners Signed-off-by: Seongho Bae --- src/collaboration/awareness.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 6f6dd9241..a3304ec2d 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -121,10 +121,14 @@ export function createScopedCollaborationProvider( source.setLocalStateField(field, value), on: (event, listener) => { if (listenerWrappers[event].has(listener)) return; - const wrapper = (...args: unknown[]) => listener(...args); + let active = true; + const wrapper = (...args: unknown[]) => { + if (active) listener(...args); + }; try { source.on(event, wrapper); } catch { + active = false; try { source.off(event, wrapper); } catch { From a995e4c6df70564df843cd90bb1e93b6b5562061 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:13:12 +0900 Subject: [PATCH 8/9] test(collaboration): require inert listeners after failed disposal --- .../awarenessDisposeFailureContainment.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/collaboration/awarenessDisposeFailureContainment.test.ts b/src/collaboration/awarenessDisposeFailureContainment.test.ts index 033204875..ac95671af 100644 --- a/src/collaboration/awarenessDisposeFailureContainment.test.ts +++ b/src/collaboration/awarenessDisposeFailureContainment.test.ts @@ -46,6 +46,12 @@ describe('scoped collaboration provider cleanup containment', () => { expect(observed).toBeUndefined(); expect(offCalls).toBe(2); expect(sourceListeners.update.size).toBe(0); + expect(sourceListeners.change.size).toBe(1); + + for (const retainedHostListener of sourceListeners.change) { + retainedHostListener({ stale: true }); + } + expect(changeListener).not.toHaveBeenCalled(); scoped.dispose(); expect(offCalls).toBe(2); From bebaed9e95becd0e82952a1b641a45b2d68c8b2e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:13:59 +0900 Subject: [PATCH 9/9] fix(collaboration): deactivate listeners before host teardown --- src/collaboration/awareness.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index a3304ec2d..6525b580f 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -19,6 +19,11 @@ export interface ScopedCollaborationProvider extends CollaborationProviderLike { dispose(): void; } +interface ScopedListenerWrapper { + callback: (...args: unknown[]) => void; + deactivate(): void; +} + const CURSOR_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; const NUMERIC_IDENTIFIER_PATTERN = /^\d+$/; const FALLBACK_CURSOR_COLOR = '#475569'; @@ -102,7 +107,7 @@ export function createScopedCollaborationProvider( const source = readCompatibleCollaborationAwareness(provider); const listenerWrappers: Record< CollaborationAwarenessEvent, - Map<(...args: unknown[]) => void, (...args: unknown[]) => void> + Map<(...args: unknown[]) => void, ScopedListenerWrapper> > = { change: new Map(), update: new Map(), @@ -125,10 +130,13 @@ export function createScopedCollaborationProvider( const wrapper = (...args: unknown[]) => { if (active) listener(...args); }; + const deactivate = () => { + active = false; + }; try { source.on(event, wrapper); } catch { - active = false; + deactivate(); try { source.off(event, wrapper); } catch { @@ -136,13 +144,17 @@ export function createScopedCollaborationProvider( } throw new Error('collaboration awareness listener registration failed'); } - listenerWrappers[event].set(listener, wrapper); + listenerWrappers[event].set(listener, { + callback: wrapper, + deactivate, + }); }, off: (event, listener) => { const wrapper = listenerWrappers[event].get(listener); if (!wrapper) return; + wrapper.deactivate(); try { - source.off(event, wrapper); + source.off(event, wrapper.callback); } catch { throw new Error('collaboration awareness listener removal failed'); } @@ -158,8 +170,9 @@ export function createScopedCollaborationProvider( disposed = true; for (const event of ['change', 'update'] as const) { for (const wrapper of listenerWrappers[event].values()) { + wrapper.deactivate(); try { - source.off(event, wrapper); + source.off(event, wrapper.callback); } catch { // Host-owned listener teardown must not abort remaining cleanup or // leak a private provider failure through React effect disposal.