fix(scope): harden lifecycle ownership foundation

Make Cordis construction and teardown ownership reentrancy-safe, then carry caller and provider ownership through reservation, setup, publication, quiescence, and sentinel retirement.

Stabilize registry carriers and factory/workflow boundaries, add adversarial lifecycle regressions, and align the rewritten RFC plus generated contracts with the enforced behavior.
This commit is contained in:
Tianyi Cui
2026-07-12 08:57:05 +08:00
parent 197f7237d2
commit c5b1a7941f
39 changed files with 2945 additions and 461 deletions

View File

@@ -740,6 +740,79 @@ describe('SessionStore', () => {
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
})
it('claims an id across Context.filter evaluation before committing the exact session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const id = SessionId('reentrant-enter')
const nested = new Session(id)
const outer = new Session(id)
let nestedError = ''
let attempted = false
Object.defineProperty(outer, Context.filter, {
configurable: true,
get() {
if (!attempted) {
attempted = true
try {
ctx.sessions.enter(nested)
} catch (error: unknown) {
nestedError = String(error)
}
}
return undefined
},
})
const detach = ctx.sessions.enter(outer)
expect(nestedError).toMatch(/already exists/)
expect(ctx.sessions.get(id)).toBe(outer)
detach()
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('revalidates reservation ownership after carrier construction runs caller code', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const id = SessionId('released-during-enter')
const reservation = ctx.sessions.reserve(id)
const session = reservation.prepare()
Object.defineProperty(session, Context.filter, {
configurable: true,
get() {
reservation.release()
return undefined
},
})
expect(() => ctx.sessions.enter(session, reservation)).toThrow(/does not own this prepared session/)
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('rejects when carrier construction attaches the same session to another store', async () => {
const firstCtx = new Context()
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
await secondCtx.plugin(SessionStore)
const session = new Session(SessionId('cross-store-carrier'))
let attempted = false
let detachSecond = (): void => {}
Object.defineProperty(session, Context.filter, {
configurable: true,
get() {
if (!attempted) {
attempted = true
detachSecond = secondCtx.sessions.enter(session)
}
return undefined
},
})
expect(() => firstCtx.sessions.enter(session)).toThrow(/already attached to a store/)
expect(firstCtx.sessions.get(session.id)).toBeUndefined()
expect(secondCtx.sessions.get(session.id)).toBe(session)
detachSecond()
})
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -869,6 +942,49 @@ describe('SessionStore', () => {
expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
})
it('defers a reentrant detach until the creation dispatch unwinds', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const order: string[] = []
const session = ctx.sessions.prepare(SessionId('reentrant-detach'))
const detach = ctx.sessions.enter(session)
ctx.on('session/created', (created) => {
order.push('created:first')
detach()
expect(ctx.sessions.get(created.id)).toBe(created)
})
ctx.on('session/created', (created) => {
order.push('created:second')
expect(ctx.sessions.get(created.id)).toBe(created)
})
ctx.on('session/disposed', (disposed) => {
order.push('disposed')
expect(ctx.sessions.get(disposed.id)).toBeUndefined()
})
ctx.sessions.announce(session)
expect(order).toEqual(['created:first', 'created:second', 'disposed'])
expect(ctx.sessions.get(session.id)).toBeUndefined()
detach()
})
it('rolls back create when its owner unloads from session/created', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let ownerCtx!: Context
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['sessions'] }))
const id = SessionId('create-unload-race')
ctx.on('session/created', (session) => {
if (session.id === id) void owner.dispose()
})
ownerCtx.sessions.create(id)
await owner.dispose()
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('synthesizes a minimal current-version header for a bare-created session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)