fix(scope): close remaining ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 03:51:55 +08:00
parent 3dca90261c
commit 36b8370027
79 changed files with 3957 additions and 817 deletions

View File

@@ -12,7 +12,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co
- `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).
- `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 whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.
- `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`.
## Design contract

View File

@@ -320,6 +320,8 @@ export interface ScopeHost {
* can never be satisfied RESOLVES its fiber await without ever running the
* callback — a silent no-op host. This helper fails LOUD instead: when the
* callback did not run, it names the absent services and disposes the host.
* The service list is copied before plugin activation so caller mutation
* across the await cannot change dependency resolution or diagnostics.
* @param ctx - the context to mount the host under.
* @param services - the service names scopes minted through this host reach
* (the host plugin's `inject` list).
@@ -328,16 +330,20 @@ export interface ScopeHost {
* Cordis dead end.
*/
export async function scopeHost(ctx: Context, services: string[]): Promise<ScopeHost> {
// The inject list crosses an await before missing-service diagnostics run.
// Detach it now so caller mutation cannot change either Cordis dependency
// resolution or the names reported by this helper.
const requiredServices = [...services]
let hostCtx: Context | undefined
// A named function statement (not Object.assign({name}) — Function.name is
// read-only) so diagnostics read `scopeHost`.
function scopeHostPlugin(inner: Context): void { hostCtx = inner }
const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: services }))
const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: requiredServices }))
await fiber
if (hostCtx === undefined) {
// Dependency-pending: cordis resolves the await without running the
// callback. Name the absentees and unwind the pending fiber.
const missing = services.filter(name => ctx.get(name) === undefined)
const missing = requiredServices.filter(name => ctx.get(name) === undefined)
await fiber.dispose()
/* v8 ignore next -- the '(unknown)' fallback is defensive: a pending
* fiber with zero absent services cannot occur (an all-present inject

View File

@@ -367,6 +367,16 @@ describe('scopeHost', () => {
.rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
})
it('snapshots missing-service diagnostics across the host activation await', async () => {
const ctx = new Context()
const services = ['tools', 'systemPrompt']
const pending = scopeHost(ctx, services)
services.splice(0)
await expect(pending)
.rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
})
it('names a single absent service in the singular', async () => {
const ctx = new Context()
await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available')