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:
@@ -9,7 +9,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co
|
||||
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
|
||||
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
|
||||
- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global.
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier uses a dedicated surrogate proxy target whose immutable filter slot cannot be replaced by a base property pinned before, during, or after construction; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate. Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The captured base filter and the exposed composed filter are invoked through captured JavaScript primordials, and the composed filter's frozen invocation surface cannot be replaced or tampered with. The carrier uses a dedicated surrogate proxy target; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate; defining through the carrier is therefore supported only with an explicit `configurable: true` descriptor, while an omitted or false flag is rejected before the base is touched. Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `Scoped<T>` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
- `scopeHost(ctx, services)` Test/tooling host that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
|
||||
// Capture the invocation primordials once. A carrier holder can reach the
|
||||
// composed Context.filter function, so neither that function's mutable
|
||||
// property surface nor a base filter's own `.call` may choose how isolation
|
||||
// predicates are invoked.
|
||||
const reflectApply = Reflect.apply
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const functionCall = Function.prototype.call
|
||||
|
||||
/**
|
||||
* The identity a scope is keyed by. Opaque and compared by object identity —
|
||||
* never inspected. The harness convention: a live `Agent` is the key of its
|
||||
@@ -194,8 +202,11 @@ function isConstructable(value: (...args: unknown[]) => unknown): boolean {
|
||||
* - its tag IS `key` (a scoped listener seeing exactly its own subject),
|
||||
*
|
||||
* AND `base`'s own filter (a Cordis `Service`'s isolation check) also admits
|
||||
* it. Dispatching with `key === undefined` — a subject-less dispatch, e.g. a
|
||||
* tool call with no calling agent or a bare (agent-less) session's events —
|
||||
* it. Both the captured base filter and the composed filter are invoked
|
||||
* through captured JavaScript primordials, so mutating either function's
|
||||
* public `.call` property cannot bypass either predicate. Dispatching with
|
||||
* `key === undefined` — a subject-less dispatch, e.g. a tool call with no
|
||||
* calling agent or a bare (agent-less) session's events —
|
||||
* admits only untagged listeners: a scoped listener never fires for someone
|
||||
* else's (or nobody's) subject. Listeners registered `{ global: true }`
|
||||
* bypass all filtering (Cordis semantics).
|
||||
@@ -211,7 +222,11 @@ function isConstructable(value: (...args: unknown[]) => unknown): boolean {
|
||||
* subject always travels in the event's arguments. The returned carrier is
|
||||
* branded {@link Scoped} and runtime-marked ({@link isScopeCarrier} /
|
||||
* {@link carrierKeyOf}) so both the type system and the dev invariants can
|
||||
* tell a carrier from a bare subject.
|
||||
* tell a carrier from a bare subject. Defining an ordinary property through
|
||||
* the carrier is supported only when its descriptor explicitly says
|
||||
* `configurable: true`; an omitted or false flag is rejected before touching
|
||||
* `base`, because the extensible surrogate cannot truthfully report a new
|
||||
* non-configurable base property.
|
||||
* @param base - the object the event is dispatched on behalf of (the owning
|
||||
* service, or the subject agent itself); its own `Context.filter` is
|
||||
* preserved and composed.
|
||||
@@ -225,10 +240,19 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
|
||||
throw new TypeError('scope target Context.filter must be a function when present')
|
||||
}
|
||||
const filter = (ctx: Context): boolean => {
|
||||
if (baseFilter && !baseFilter.call(base, ctx)) return false
|
||||
if (baseFilter && !reflectApply(functionCall, baseFilter, [base, ctx])) return false
|
||||
const tag = scopeOf(ctx)
|
||||
return tag === undefined || tag === key
|
||||
}
|
||||
// Cordis invokes a dispatch filter as `filter.call(thisArg, listenerCtx)`.
|
||||
// Pin that property to the captured primordial, then freeze the callable so
|
||||
// a carrier holder cannot replace it with an always-true scope bypass.
|
||||
Object.defineProperty(filter, 'call', {
|
||||
value: functionCall,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
Object.freeze(filter)
|
||||
const overlay: Record<string | symbol, unknown> = {
|
||||
[CordisContext.filter]: filter,
|
||||
[kCarrier]: Object.freeze({ key }),
|
||||
@@ -317,7 +341,7 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
|
||||
return undefined
|
||||
},
|
||||
defineProperty(_target, prop, attributes) {
|
||||
if (Object.hasOwn(overlay, prop)) return false
|
||||
if (Object.hasOwn(overlay, prop) || attributes.configurable !== true) return false
|
||||
return Reflect.defineProperty(base, prop, attributes)
|
||||
},
|
||||
deleteProperty(_target, prop) {
|
||||
|
||||
@@ -189,10 +189,54 @@ describe('scopeTarget dispatch filtering', () => {
|
||||
ctx.emit(scopeTarget(vetoBase, keyA), 'scope-test/ping', 'vetoed')
|
||||
expect(heard).toEqual([])
|
||||
|
||||
// A base whose filter accepts delegates to the scope predicate.
|
||||
const openBase = { [Context.filter]: () => true }
|
||||
// A base whose filter accepts delegates to the scope predicate, with the
|
||||
// real base preserved as its `this` receiver.
|
||||
let baseReceiverWasOpen = false
|
||||
const openBase = {
|
||||
[Context.filter](this: object): boolean {
|
||||
baseReceiverWasOpen = this === openBase
|
||||
return true
|
||||
},
|
||||
}
|
||||
ctx.emit(scopeTarget(openBase, keyA), 'scope-test/ping', 'open')
|
||||
expect(heard).toEqual(['global:open', 'A:open'])
|
||||
expect(baseReceiverWasOpen).toBe(true)
|
||||
|
||||
// A function's public `.call` property is not its invocation semantics.
|
||||
// An always-true replacement must not override the base predicate's veto.
|
||||
const tamperedVeto = (): boolean => false
|
||||
Object.defineProperty(tamperedVeto, 'call', { value: () => true })
|
||||
ctx.emit(scopeTarget({ [Context.filter]: tamperedVeto }, keyA), 'scope-test/ping', 'tampered-veto')
|
||||
expect(heard).toEqual(['global:open', 'A:open'])
|
||||
})
|
||||
|
||||
it('pins the exposed composed filter invocation so a carrier holder cannot bypass isolation', async () => {
|
||||
const ctx = new Context()
|
||||
const keyA = { name: 'A' }
|
||||
const keyB = { name: 'B' }
|
||||
const scopeA = await mintScope(ctx, keyA)
|
||||
const scopeB = await mintScope(ctx, keyB)
|
||||
const heard: string[] = []
|
||||
ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
|
||||
scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
|
||||
scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`))
|
||||
|
||||
const carrier = scopeTarget(ctx, keyA)
|
||||
const exposedFilter: unknown = Reflect.get(carrier, Context.filter)
|
||||
expect(typeof exposedFilter).toBe('function')
|
||||
const filter = exposedFilter as ((ctx: Context) => boolean) & { call: (...args: unknown[]) => unknown }
|
||||
const primordialCall: unknown = Reflect.get(Function.prototype, 'call')
|
||||
expect(Object.getOwnPropertyDescriptor(filter, 'call')).toMatchObject({
|
||||
value: primordialCall,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
expect(Object.isFrozen(filter)).toBe(true)
|
||||
expect(Reflect.set(filter, 'call', () => true)).toBe(false)
|
||||
expect(Reflect.defineProperty(filter, 'call', { value: () => true })).toBe(false)
|
||||
|
||||
ctx.emit(carrier, 'scope-test/ping', 'still-A-only')
|
||||
expect(heard).toEqual(['global:still-A-only', 'A:still-A-only'])
|
||||
})
|
||||
|
||||
it('keeps listener `this` base-shaped through the carrier (waterfall)', async () => {
|
||||
@@ -256,6 +300,14 @@ describe('scopeTarget dispatch filtering', () => {
|
||||
Object.defineProperty(carrier, 'extra', { value: 1, configurable: true })
|
||||
expect((base as typeof base & { extra?: number }).extra).toBe(1)
|
||||
expect(delete (carrier as typeof carrier & { extra?: number }).extra).toBe(true)
|
||||
|
||||
// A non-configurable property cannot be reflected truthfully through the
|
||||
// extensible surrogate. Reject before mutating the delegated base; an
|
||||
// omitted `configurable` has JavaScript's false default and is rejected too.
|
||||
expect(Reflect.defineProperty(carrier, 'sealed', { value: 1, configurable: false })).toBe(false)
|
||||
expect(Object.hasOwn(base, 'sealed')).toBe(false)
|
||||
expect(Reflect.defineProperty(carrier, 'default-sealed', { value: 2 })).toBe(false)
|
||||
expect(Object.hasOwn(base, 'default-sealed')).toBe(false)
|
||||
expect(Reflect.preventExtensions(carrier)).toBe(false)
|
||||
expect(Reflect.setPrototypeOf(carrier, null)).toBe(false)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user