fix(host): roll back the surviving deferral when flow-registration setup fails halfway

A declared-but-occupied second hole registers synchronously, so the pair
construction can throw after the first deferral installed its subscription;
that orphan then fires against the failed fiber's inactive context. The
effect now disposes already-created deferrals before rethrowing
(ds-review-bot on the browse twin; same shape here).
This commit is contained in:
creatixchu
2026-07-29 01:29:08 +08:00
parent 6a78bf4dd0
commit b6762d20a2
2 changed files with 38 additions and 6 deletions

View File

@@ -62,12 +62,19 @@ export const inject = ['slots', 'workspaces']
export function apply(ctx: ClientContext): void {
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
ctx.effect(() => {
const deferred = [
deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', NativeDirectoryFlow, () =>
ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, NativeDirectoryFlow)),
deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', NativeDirectoryFlow, () =>
ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, NativeDirectoryFlow)),
]
// 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.
const deferred: ReturnType<typeof deferRegistration>[] = []
try {
deferred.push(deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', NativeDirectoryFlow, () =>
ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, NativeDirectoryFlow)))
deferred.push(deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', NativeDirectoryFlow, () =>
ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, NativeDirectoryFlow)))
} catch (error) {
for (const entry of deferred) entry.dispose()
throw error
}
return () => { for (const entry of deferred) entry.dispose() }
}, 'directory-picker-native: flow registrations')
}

View File

@@ -55,6 +55,31 @@ describe('directory-picker-native client half', () => {
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
})
it('rolls back the first deferral when the second hole is already occupied', async () => {
const b = await bench()
b.declare()
// Foreign occupant in the SECOND registered hole: the pair construction
// throws after the first deferral installed its subscription.
b.slots.register({ name: HOLES[1] } as never, () => null)
const rejections: unknown[] = []
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await expect(fiber.await()).rejects.toThrow(/already has a registration/)
// A leaked first deferral would now race this probe registration and
// throw from its orphaned subscription against the HERO hole; the
// rollback leaves only the activation failure itself (cordis re-raises
// the apply throw as a late rejection — installFailLoud's contract).
const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
await new Promise(resolve => setTimeout(resolve, 20))
expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
disposeProbe()
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('rejects a second flow occupant at load (single-kind hole)', async () => {
const b = await bench()
b.declare()