fix(scope): harden final ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 05:13:17 +08:00
parent 36b8370027
commit a9cb70d896
52 changed files with 2839 additions and 514 deletions

View File

@@ -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): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). 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 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).
- `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`.

View File

@@ -171,6 +171,20 @@ export function scopeOf(ctx: Context): ScopeKey | undefined {
return (ctx as Context & { [kScope]?: ScopeKey })[kScope]
}
/** Whether a callable has JavaScript's internal construction capability. */
function isConstructable(value: (...args: unknown[]) => unknown): boolean {
try {
// A Proxy has [[Construct]] iff its target does. Its trap returns before
// the engine invokes `value` or reads `value.prototype`, so a hostile but
// constructable callable cannot be mistaken for a non-constructor.
Reflect.construct(new Proxy(value, { construct: () => ({}) }), [])
return true
} catch {
// The harmless outer trap leaves lack of [[Construct]] as the only failure.
return false
}
}
/**
* Build the dispatch carrier for a scope-filtered event: `base` overlaid with
* a `Context.filter` that admits a listener iff
@@ -206,7 +220,10 @@ export function scopeOf(ctx: Context): ScopeKey | undefined {
* @returns the carrier to pass as the dispatch `thisArg`.
*/
export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {
const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]
const baseFilter: unknown = (base as { [CordisContext.filter]?: unknown })[CordisContext.filter]
if (baseFilter !== undefined && typeof baseFilter !== 'function') {
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
const tag = scopeOf(ctx)
@@ -214,34 +231,57 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
}
const overlay: Record<string | symbol, unknown> = {
[CordisContext.filter]: filter,
[kCarrier]: { key },
[kCarrier]: Object.freeze({ key }),
}
// A hand-rolled proxy, NOT cordis withProps: withProps delegates gets with
// the PROXY as receiver, so a getter on `base` runs with proxy `this` and a
// method call through the carrier gets a proxy receiver — either one throws
// on a native `#private` field of the subject (TypeError: private member
// not declared). Cordis hands the carrier to listeners as `this`, and the
// event declarations type it `Scoped<Agent>` — so subject method calls
// through it are a SUPPORTED shape and must reach the real object: gets
// delegate with `base` as receiver, functions come back bound to `base`,
// and sets land on `base` directly.
return new Proxy(base, {
// Use a dedicated extensible proxy TARGET, never `base` itself. Proxy get
// invariants force a trap to return a base's non-configurable/non-writable
// own value verbatim; if a caller pinned Context.filter during or after
// construction, a base-target proxy would therefore silently replace the
// composed scope predicate with the caller's filter. The surrogate owns the
// two immutable overlay slots, so later descriptor changes on `base` cannot
// affect isolation. It shares the base prototype and delegates ordinary
// reads/writes/keys to preserve the supported transparent shape. Callable
// targets use native bound built-ins so V8 contributes no user-code surface;
// the chosen built-in matches whether `base` has [[Construct]], and the traps
// below delegate the actual call/construction to `base`.
const callableBase = typeof base === 'function'
? base as unknown as (...args: unknown[]) => unknown
: undefined
const constructable = callableBase !== undefined && isConstructable(callableBase)
const target: object = callableBase === undefined
? {}
: constructable
? Object.bind(undefined)
: Math.max.bind(undefined)
Reflect.setPrototypeOf(target, Reflect.getPrototypeOf(base))
Object.defineProperties(target, {
[CordisContext.filter]: {
value: filter,
enumerable: false,
writable: false,
configurable: false,
},
[kCarrier]: {
value: overlay[kCarrier],
enumerable: false,
writable: false,
configurable: false,
},
})
const carrier = new Proxy(target, {
get(target, prop) {
// Proxy get invariants pin what this trap may report for a
// non-configurable OWN property of the base: a non-writable data prop
// must be reported AS-IS (neither overlaid nor bound), a getterless
// accessor as undefined — checked FIRST so even an overlay key
// colliding with a frozen own prop of a (pathological) base yields the
// base's value instead of an engine TypeError. Such a base forgoes
// scope filtering; no production base freezes these keys.
// The callable surrogate has engine-owned pinned properties (`prototype`,
// `caller`, …); honor those target invariants. For object carriers the
// only pinned target properties are the exact overlay values above.
const own = Reflect.getOwnPropertyDescriptor(target, prop)
const pinned = own !== undefined && own.configurable === false
&& own.get === undefined && own.writable !== true
// hasOwn, not `in`: the overlay literal inherits Object.prototype, so
// `in` would claim `toString`/`constructor` and shadow the subject's.
if (!pinned && Object.hasOwn(overlay, prop)) return overlay[prop]
const value: unknown = Reflect.get(target, prop, target)
if (typeof value !== 'function' || pinned) return value
if (pinned) {
const value: unknown = Reflect.get(target, prop, target)
return value
}
const value: unknown = Reflect.get(base, prop, base)
if (typeof value !== 'function') return value
// `constructor` is looked up, never invoked as a subject method — keep
// the real one (withProps special-cases it the same way), so
// `carrier.constructor` still identifies the subject's class.
@@ -249,12 +289,66 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
// `Function.prototype.bind` types as `any`; the value is structurally
// T[prop] and the trap's contract is untyped (`any`), so unknown is the
// honest safe return.
return value.bind(target) as unknown
return value.bind(base) as unknown
},
set(target, prop, value) {
return Reflect.set(target, prop, value, target)
set(_target, prop, value) {
if (Object.hasOwn(overlay, prop)) return false
return Reflect.set(base, prop, value, base)
},
}) as Scoped<T>
has(_target, prop) {
// A Proxy may not hide a non-configurable target key. Configurable
// surrogate-only keys (bound-function name/length) are omitted; the
// base's own/inherited surface remains authoritative.
const own = Reflect.getOwnPropertyDescriptor(target, prop)
return own?.configurable === false || Reflect.has(base, prop)
},
ownKeys(target) {
const requiredTargetKeys = Reflect.ownKeys(target).filter((prop) => {
return Reflect.getOwnPropertyDescriptor(target, prop)?.configurable === false
})
return [...new Set([...requiredTargetKeys, ...Reflect.ownKeys(base)])]
},
getOwnPropertyDescriptor(target, prop) {
const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop)
if (targetDescriptor?.configurable === false) return targetDescriptor
const baseDescriptor = Reflect.getOwnPropertyDescriptor(base, prop)
if (baseDescriptor !== undefined) return { ...baseDescriptor, configurable: true }
// Configurable surrogate-only function metadata is intentionally hidden.
return undefined
},
defineProperty(_target, prop, attributes) {
if (Object.hasOwn(overlay, prop)) return false
return Reflect.defineProperty(base, prop, attributes)
},
deleteProperty(_target, prop) {
if (Object.hasOwn(overlay, prop)) return false
return Reflect.deleteProperty(base, prop)
},
preventExtensions() {
// Keeping the surrogate extensible is required for ownKeys to report
// caller-owned base fields that may change over the carrier's lifetime.
return false
},
setPrototypeOf() {
// The carrier prototype and base delegation must not be split.
return false
},
apply(_target, thisArg, args) {
const callable = callableBase as (...values: unknown[]) => unknown
const result: unknown = Reflect.apply(callable, thisArg, args)
return result
},
construct(_target, args, newTarget) {
const constructor = callableBase as unknown as new (...values: unknown[]) => object
const result: unknown = Reflect.construct(
constructor,
args,
newTarget === carrier ? constructor : newTarget,
)
return result as object
},
})
return carrier as Scoped<T>
}
/**
@@ -266,10 +360,8 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
* @returns true iff `value` came from {@link scopeTarget}.
*/
export function isScopeCarrier(value: unknown): value is Scoped<object> {
if (typeof value !== 'object' || value === null) return false
// A property READ, not an `in` check: the carrier overlays its marks in the
// get trap only (no `has` trap), so `kCarrier in carrier` would fall
// through to the wrapped base and always answer false.
if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return false
// A property read checks the immutable marker owned by the surrogate target.
return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier] !== undefined
}

View File

@@ -232,7 +232,7 @@ describe('scopeTarget dispatch filtering', () => {
expect(detached()).toBe(2)
})
it('delegates sets to the base and leaves frozen own function props unbound (proxy invariant)', () => {
it('delegates the ordinary reflective surface while keeping overlays immutable', () => {
const frozenFn = (): string => 'frozen'
const base: { mutable: number; pinned: () => string; toString: () => string } = {
mutable: 0,
@@ -243,25 +243,158 @@ describe('scopeTarget dispatch filtering', () => {
const carrier = scopeTarget(base, undefined)
carrier.mutable = 7
expect(base.mutable).toBe(7) // sets land on the base, not a detached overlay
// A non-configurable, non-writable own data prop must be reported
// unchanged (binding it would violate the proxy get invariant).
expect(carrier.pinned).toBe(frozenFn)
// The surrogate target frees reads from the base property's proxy
// invariant, so even a frozen own method can be safely bound to the base.
expect(carrier.pinned).not.toBe(frozenFn)
expect(carrier.pinned()).toBe('frozen')
// The overlay literal inherits Object.prototype; hasOwn (not `in`) keeps
// it from shadowing the subject's own prototype-surface members.
expect(String(carrier)).toBe('base-str')
expect('mutable' in carrier).toBe(true)
expect(Object.hasOwn(carrier, 'mutable')).toBe(true)
expect(Object.keys(carrier)).toEqual(['mutable', 'pinned', 'toString'])
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)
expect(Reflect.preventExtensions(carrier)).toBe(false)
expect(Reflect.setPrototypeOf(carrier, null)).toBe(false)
})
it('honors the get invariant even when an overlay key collides with a frozen own prop of the base', () => {
// Pathological but engine-enforced: a base whose own [Context.filter] is
// a non-configurable, non-writable data prop pins what any proxy over it
// may report for that key. The carrier must yield the base's value (an
// overlay there would be a runtime TypeError from the engine, not a
// filtering choice). Such a base forgoes scope filtering by construction.
it('keeps isolation when the base filter is pinned before, during, or after construction', 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 pinnedFilter = (): boolean => true
const base = {}
Object.defineProperty(base, Context.filter, { value: pinnedFilter, writable: false, configurable: false })
const carrier = scopeTarget(base, { name: 'key' })
expect((carrier as Record<symbol, unknown>)[Context.filter]).toBe(pinnedFilter)
const pinnedData = {}
Object.defineProperty(pinnedData, Context.filter, {
value: pinnedFilter,
writable: false,
configurable: false,
})
const pinnedCarrier = scopeTarget(pinnedData, keyA)
ctx.emit(pinnedCarrier, 'scope-test/ping', 'before')
const duringRead = {}
Object.defineProperty(duringRead, Context.filter, {
configurable: true,
get() {
Object.defineProperty(duringRead, Context.filter, {
value: pinnedFilter,
writable: false,
configurable: false,
})
return pinnedFilter
},
})
ctx.emit(scopeTarget(duringRead, keyA), 'scope-test/ping', 'during')
const pinnedAfter = { [Context.filter]: pinnedFilter }
const afterCarrier = scopeTarget(pinnedAfter, keyA)
Object.defineProperty(pinnedAfter, Context.filter, {
value: pinnedFilter,
writable: false,
configurable: false,
})
ctx.emit(afterCarrier, 'scope-test/ping', 'after')
const pinnedGetterless = {}
Object.defineProperty(pinnedGetterless, Context.filter, { set(_value: unknown) {}, configurable: false })
ctx.emit(scopeTarget(pinnedGetterless, keyA), 'scope-test/ping', 'getterless')
expect(heard).toEqual([
'global:before', 'A:before',
'global:during', 'A:during',
'global:after', 'A:after',
'global:getterless', 'A:getterless',
])
expect((pinnedCarrier as Record<symbol, unknown>)[Context.filter]).not.toBe(pinnedFilter)
expect(Reflect.set(pinnedCarrier, Context.filter, pinnedFilter)).toBe(false)
expect(Reflect.defineProperty(pinnedCarrier, Context.filter, { value: pinnedFilter })).toBe(false)
expect(Reflect.deleteProperty(pinnedCarrier, Context.filter)).toBe(false)
expect(() => scopeTarget({ [Context.filter]: 1 }, { name: 'A' })).toThrow(
/Context\.filter must be a function/,
)
})
it('preserves callable and constructable bases', () => {
function Subject(this: { value?: number }, value: number): number {
if (new.target) {
this.value = value
return value
}
return value * 2
}
const carrier = scopeTarget(Subject as typeof Subject & (new (value: number) => { value: number }), {
name: 'callable',
})
const called: unknown = Reflect.apply(carrier, { value: 0 }, [3])
expect(called).toBe(6)
const instance = new carrier(4)
expect(instance).toBeInstanceOf(Subject)
expect(instance.value).toBe(4)
const prototypeDescriptor = Object.getOwnPropertyDescriptor(carrier, 'prototype')
const subjectPrototype: unknown = Reflect.get(Subject, 'prototype')
expect(prototypeDescriptor?.configurable).toBe(true)
expect(prototypeDescriptor?.value).toBe(subjectPrototype)
class Derived extends carrier {}
const derived = new Derived(5)
expect(derived).toBeInstanceOf(Derived)
expect(derived).toBeInstanceOf(Subject)
expect(derived.value).toBe(5)
expect(isScopeCarrier(carrier)).toBe(true)
})
it('matches non-constructable and bound-constructor function shapes', () => {
const arrow = (value: number): number => value + 1
const arrowCarrier = scopeTarget(arrow, { name: 'arrow' })
const arrowResult: unknown = Reflect.apply(arrowCarrier, undefined, [2])
expect(arrowResult).toBe(3)
expect('prototype' in arrowCarrier).toBe(false)
expect(Object.getOwnPropertyDescriptor(arrowCarrier, 'prototype')).toBeUndefined()
expect(() => { Reflect.construct(arrowCarrier, []) }).toThrow(TypeError)
class Subject {
constructor(readonly value: number) {}
}
const bound = Subject.bind(undefined, 7)
const boundCarrier = scopeTarget(bound, { name: 'bound-constructor' })
expect('prototype' in boundCarrier).toBe(false)
expect(Object.getOwnPropertyDescriptor(boundCarrier, 'prototype')).toBeUndefined()
const instance = new boundCarrier()
expect(instance).toBeInstanceOf(Subject)
expect(instance.value).toBe(7)
})
it('detects construction without reading a hostile base prototype', () => {
class Subject {
constructor(readonly value: number) {}
}
let prototypeReads = 0
const hostile = new Proxy(Subject, {
get(target, prop, receiver) {
if (prop === 'prototype') {
prototypeReads += 1
throw new Error('hostile prototype getter')
}
return Reflect.get(target, prop, receiver) as unknown
},
})
const carrier = scopeTarget(hostile, { name: 'hostile-constructor' })
expect(prototypeReads).toBe(0)
const instance: unknown = Reflect.construct(carrier, [9], Subject)
expect(instance).toBeInstanceOf(Subject)
expect(instance).toMatchObject({ value: 9 })
expect(prototypeReads).toBe(0)
})
it('keeps the real constructor: class identity survives the carrier', () => {