fix(scope): harden final ownership boundaries
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): 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`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user