diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts index a5233812e6..68f509b916 100644 --- a/packages/client/ui-slots/src/deferred.ts +++ b/packages/client/ui-slots/src/deferred.ts @@ -89,3 +89,40 @@ export function deferRegistration( }, } } + +/** + * Defer ONE occupant into several holes as a unit. Construction that throws + * partway (a declared hole already occupied registers synchronously) rolls + * every earlier deferral back before rethrowing; a failure surfacing from a + * LATER ledger flush (holes declared after rival providers activated) rolls + * the whole group back the same way and re-raises the wrapped error on the + * global channel the boot's fail-loud handler owns — never a throw through + * the slot flush, never partial occupancy from the group's owner. + * @param registry - the slot registry face. + * @param names - the target holes (one registration per name). + * @param component - the occupant whose ledger presence marks "registered". + * @param register - performs one hole's registration; returns its disposer. + * @returns the group handle (dispose in the owning effect's disposer). + * @throws the immediate registration's failure, after rolling the group back. + */ +export function deferGroupRegistration( + registry: DeferralRegistry, + names: readonly K[], + component: unknown, + register: (name: K) => () => void, +): { dispose: () => void } { + const deferred: DeferredRegistration[] = [] + const lateFailure = (error: unknown): void => { + for (const entry of deferred) entry.dispose() + queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) }) + } + try { + for (const name of names) { + deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure)) + } + } catch (error) { + for (const entry of deferred) entry.dispose() + throw error + } + return { dispose: () => { for (const entry of deferred) entry.dispose() } } +} diff --git a/packages/client/ui-slots/tests/deferred.spec.ts b/packages/client/ui-slots/tests/deferred.spec.ts index ba4d9b91ee..6e7b983852 100644 --- a/packages/client/ui-slots/tests/deferred.spec.ts +++ b/packages/client/ui-slots/tests/deferred.spec.ts @@ -2,7 +2,7 @@ // re-registration, and — the failure contract — no subscription survives a // construction that throws synchronously (an already-occupied single slot). import { describe, expect, it, vi } from 'vitest' -import { deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots' +import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots' // Shares the merges declared by core.spec.ts (same program); reuse its keys. const HOLE = 'test.single' as const @@ -63,3 +63,64 @@ describe('deferRegistration', () => { expect(core.entries(HOLE)).toHaveLength(0) }) }) + +describe('deferGroupRegistration', () => { + const HOLES = ['test.single', 'test.grandchild'] as const + + function declaredPair(): SlotCore { + const core = new SlotCore() + core.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, (() => null) as never) + return core + } + + it('registers the whole group and disposes it as a unit', () => { + const core = declaredPair() + const component = (): null => null + const group = deferGroupRegistration(core, HOLES, component, name => + core.register({ name } as never, component as never)) + for (const name of HOLES) expect(core.entries(name)).toHaveLength(1) + group.dispose() + for (const name of HOLES) expect(core.entries(name)).toHaveLength(0) + }) + + it('rolls the group back when construction fails partway', () => { + const core = declaredPair() + const component = (): null => null + core.register({ name: HOLES[1] } as never, (() => null) as never) + expect(() => deferGroupRegistration(core, HOLES, component, name => + core.register({ name } as never, component as never))).toThrow(/already has a registration/) + // The first hole's registration and subscription rolled back with it. + expect(core.entries(HOLES[0])).toHaveLength(0) + }) + + it('rolls the group back and re-raises loudly on a late conflict', async () => { + const core = new SlotCore() + const component = (): null => null + const failures: unknown[] = [] + const onLoud = (reason: unknown): void => { failures.push(reason) } + process.on('uncaughtException', onLoud) + try { + const group = deferGroupRegistration(core, HOLES, component, name => + core.register({ name } as never, component as never)) + // Declaration lands with a rival racing in ahead of the flush. + core.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, (() => null) as never) + core.register({ name: HOLES[0] } as never, (() => null) as never) + core.register({ name: HOLES[1] } as never, (() => null) as never) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(failures.map(String).join('')).toContain('already has a registration') + // No partial occupancy from the group's owner survives. + for (const name of HOLES) { + expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0) + } + group.dispose() + } finally { + process.off('uncaughtException', onLoud) + } + }) +}) diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts index aa2936e1da..2d5de9a9e7 100644 --- a/packages/host/directory-picker-native/src/client/index.ts +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -7,7 +7,7 @@ * both sides of the native interaction with one cordis.yml row; no client * code branches on a capability kind. */ -import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -27,27 +27,15 @@ export const inject = ['slots', 'workspaces'] export function apply(ctx: ClientContext): void { const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() }) ctx.effect(() => { - // Constructing the pair can throw halfway (a declared hole already - // occupied registers synchronously): roll the earlier deferral back so - // no live subscription outlives the failed fiber. A conflict surfacing - // from a LATER ledger flush (holes declared after two flow providers - // activated) rolls the whole pair back the same way and re-raises on - // the global channel the boot's fail-loud handler owns — never a throw - // through the slot flush, never partial occupancy from this package. - const deferred: ReturnType[] = [] - const lateConflict = (error: unknown): void => { - for (const entry of deferred) entry.dispose() - queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) }) - } - try { - deferred.push(deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', NativeDirectoryFlow, () => - ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, NativeDirectoryFlow), lateConflict)) - deferred.push(deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', NativeDirectoryFlow, () => - ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, NativeDirectoryFlow), lateConflict)) - } catch (error) { - for (const entry of deferred) entry.dispose() - throw error - } - return () => { for (const entry of deferred) entry.dispose() } + // One occupant, both holes, as a unit: construction or late conflicts + // (holes declared after rival providers activated) roll the whole pair + // back and fail loud — semantics owned by deferGroupRegistration. + const group = deferGroupRegistration( + ctx.slots, + ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const, + NativeDirectoryFlow, + name => ctx.slots.register({ name, inject: injected }, NativeDirectoryFlow), + ) + return () => { group.dispose() } }, 'directory-picker-native: flow registrations') }