Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 14 additions & 15 deletions packages/astrid-sdk/src/capsule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,26 @@ import {
recordInstall,
recordUpgrade,
recordRun,
adoptPending,
defer,
flushDeferred,
} from "./runtime/registry.js";

/**
* Class decorator marking the entry-point of the capsule. The decorated
* class must have a no-arg constructor (state defaults via field initializers).
*
* Runs last (TC39 applies the class decorator after all member decorators),
* so by now every @tool/@install/... has deferred its registration; we flush
* them here against the real constructor.
*
* Exactly one `@capsule` class per compiled WASM module.
*/
export function capsule<T extends CapsuleConstructor>(
target: T,
_context: ClassDecoratorContext<T>,
context: ClassDecoratorContext<T>,
): T {
registerCapsule(target);
adoptPending(target);
flushDeferred(target, context.metadata);
return target;
}
Comment on lines 30 to 37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass context.metadata to flushDeferred to isolate and flush only the deferred registrations belonging to this class.

Suggested change
export function capsule<T extends CapsuleConstructor>(
target: T,
_context: ClassDecoratorContext<T>,
): T {
registerCapsule(target);
adoptPending(target);
flushDeferred(target);
return target;
}
export function capsule<T extends CapsuleConstructor>(
target: T,
context: ClassDecoratorContext<T>,
): T {
registerCapsule(target);
flushDeferred(target, context.metadata);
return target;
}


Expand All @@ -43,10 +48,8 @@ export function install<This extends object>(
if (context.private || context.static) {
throw new Error("@install must be applied to a public instance method.");
}
context.addInitializer(function () {
const ctor = (this as object).constructor as CapsuleConstructor;
recordInstall(ctor, String(context.name));
});
const methodName = String(context.name);
defer(context.metadata, (ctor) => recordInstall(ctor, methodName));
}

/**
Expand All @@ -60,10 +63,8 @@ export function upgrade<This extends object>(
if (context.private || context.static) {
throw new Error("@upgrade must be applied to a public instance method.");
}
context.addInitializer(function () {
const ctor = (this as object).constructor as CapsuleConstructor;
recordUpgrade(ctor, String(context.name));
});
const methodName = String(context.name);
defer(context.metadata, (ctor) => recordUpgrade(ctor, methodName));
}

/**
Expand All @@ -83,8 +84,6 @@ export function run<This extends object>(
if (context.private || context.static) {
throw new Error("@run must be applied to a public instance method.");
}
context.addInitializer(function () {
const ctor = (this as object).constructor as CapsuleConstructor;
recordRun(ctor, String(context.name));
});
const methodName = String(context.name);
defer(context.metadata, (ctor) => recordRun(ctor, methodName));
}
106 changes: 65 additions & 41 deletions packages/astrid-sdk/src/runtime/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@
* @upgrade, @interceptor, @command, @run decorators. The bridge reads from
* this registry to dispatch host export calls.
*
* Decorators run during class evaluation, which happens at module init.
* The bridge is constructed AFTER all decorators have populated the
* registry, so reads are safe.
* Registration timing matters. Method decorators run *before* the class
* decorator (TC39 applies member decorators first, in source order, then the
* class decorator last) and none of them can see the constructor yet. So the
* member decorators defer their registration onto a module-scoped queue; the
* `@capsule` class decorator — which does have the constructor — flushes that
* queue via `flushDeferred(ctor)`. Everything therefore lands in the registry
* synchronously at class-evaluation time, before any instance is constructed
* and before the bridge ever reads it.
*
* (Earlier this was done from `context.addInitializer`, which only runs at
* *construction* time — but the bridge reads the registry before constructing
* anything, so every registration looked empty. See sdk-js#20.)
*/

export type CapsuleConstructor = new () => object;
Expand Down Expand Up @@ -78,89 +87,102 @@ export function registerCapsule(ctor: CapsuleConstructor, description?: string):
}

/**
* Decorators may run before `@capsule` if class fields appear before the
* class itself in source. Buffer pending entries on the constructor so the
* eventual @capsule call picks them up.
* Member decorators (@tool/@interceptor/@command/@install/@upgrade/@run) run
* before the class exists, so they cannot name the constructor. Each defers a
* closure here; `@capsule` runs last and flushes them with the real ctor.
*
* Deferred closures are keyed by the class's shared `context.metadata` object
* (the same reference reaches every decorator of one class), so members of a
* class that is never `@capsule`-decorated cannot leak into the next capsule
* evaluated in the same module. The metadata object is only available under
* TC39 decorator metadata; where the runtime does not provide it (undefined),
* we fall back to a single module queue — which is exactly right for the
* enforced "one @capsule class per module" case.
*/
const pendingByCtor = new WeakMap<CapsuleConstructor, CapsuleRegistration>();
type DeferredRecord = (ctor: CapsuleConstructor) => void;

function ensureRegistration(ctor: CapsuleConstructor): CapsuleRegistration {
if (registration !== undefined && registration.ctor === ctor) {
return registration;
}
let pending = pendingByCtor.get(ctor);
if (pending === undefined) {
pending = newRegistration(ctor, undefined);
pendingByCtor.set(ctor, pending);
let deferred: DeferredRecord[] = [];
let deferredByMetadata = new WeakMap<object, DeferredRecord[]>();

export function defer(metadata: object | undefined, record: DeferredRecord): void {
if (metadata === undefined) {
deferred.push(record);
return;
}
return pending;
}

/** Adopt any decorator entries buffered before @capsule fired. */
export function adoptPending(ctor: CapsuleConstructor): void {
if (registration === undefined || registration.ctor !== ctor) return;
const pending = pendingByCtor.get(ctor);
if (pending === undefined) return;
for (const [name, entry] of pending.tools) registration.tools.set(name, entry);
for (const [topic, entry] of pending.interceptors) registration.interceptors.set(topic, entry);
for (const [name, entry] of pending.commands) registration.commands.set(name, entry);
if (pending.installMethod !== undefined && registration.installMethod === undefined) {
registration.installMethod = pending.installMethod;
let list = deferredByMetadata.get(metadata);
if (list === undefined) {
list = [];
deferredByMetadata.set(metadata, list);
}
if (pending.upgradeMethod !== undefined && registration.upgradeMethod === undefined) {
registration.upgradeMethod = pending.upgradeMethod;
list.push(record);
}

/** Apply the deferred member registrations for this class against its ctor. */
export function flushDeferred(ctor: CapsuleConstructor, metadata?: object): void {
let pending: DeferredRecord[] = [];
if (metadata !== undefined) {
pending = deferredByMetadata.get(metadata) ?? [];
deferredByMetadata.delete(metadata);
}
if (pending.runMethod !== undefined && registration.runMethod === undefined) {
registration.runMethod = pending.runMethod;
if (deferred.length > 0) {
// Fallback bucket (runtimes without decorator metadata).
pending = pending.concat(deferred);
deferred = [];
}
if (pending.description !== undefined && registration.description === undefined) {
registration.description = pending.description;
for (const record of pending) record(ctor);
}
Comment on lines +102 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using a single global deferred queue can cause registrations to leak between classes if multiple classes are evaluated in the same module/runtime context (e.g., base classes, helper classes, or multiple test classes in unit tests). If a class has member decorators but is not decorated with @capsule, its deferred registrations will remain in the global queue and get flushed against the next @capsule class that is evaluated.

To isolate deferred registrations per class, we can leverage context.metadata (which is shared across all decorators of the same class in TC39 Stage 3 decorators) and store the deferred list in a WeakMap keyed by context.metadata, falling back to the global queue if metadata is not available.

type DeferredRecord = (ctor: CapsuleConstructor) => void;
let deferred: DeferredRecord[] = [];
let pendingByMetadata = new WeakMap<object, DeferredRecord[]>();

export function defer(metadata: object | undefined, record: DeferredRecord): void {
  if (!metadata) {
    deferred.push(record);
    return;
  }
  let list = pendingByMetadata.get(metadata);
  if (!list) {
    list = [];
    pendingByMetadata.set(metadata, list);
  }
  list.push(record);
}

/** Apply every deferred member registration against the now-known ctor. */
export function flushDeferred(ctor: CapsuleConstructor, metadata?: object): void {
  let pending: DeferredRecord[] = [];
  if (metadata) {
    pending = pendingByMetadata.get(metadata) ?? [];
    pendingByMetadata.delete(metadata);
  }
  if (deferred.length > 0) {
    pending = pending.concat(deferred);
    deferred = [];
  }
  for (const record of pending) record(ctor);
}


function requireRegistration(ctor: CapsuleConstructor): CapsuleRegistration {
if (registration === undefined || registration.ctor !== ctor) {
throw new Error(
`Internal: @capsule must register ${ctor.name} before recording its members.`,
);
}
pendingByCtor.delete(ctor);
return registration;
}

export function recordTool(ctor: CapsuleConstructor, entry: ToolEntry): void {
const target = ensureRegistration(ctor);
const target = requireRegistration(ctor);
if (target.tools.has(entry.name)) {
throw new Error(`@tool("${entry.name}") declared twice on ${ctor.name}.`);
}
target.tools.set(entry.name, entry);
}

export function recordInterceptor(ctor: CapsuleConstructor, entry: InterceptorEntry): void {
const target = ensureRegistration(ctor);
const target = requireRegistration(ctor);
if (target.interceptors.has(entry.topic)) {
throw new Error(`@interceptor("${entry.topic}") declared twice on ${ctor.name}.`);
}
target.interceptors.set(entry.topic, entry);
}

export function recordCommand(ctor: CapsuleConstructor, entry: CommandEntry): void {
const target = ensureRegistration(ctor);
const target = requireRegistration(ctor);
if (target.commands.has(entry.name)) {
throw new Error(`@command("${entry.name}") declared twice on ${ctor.name}.`);
}
target.commands.set(entry.name, entry);
}

export function recordInstall(ctor: CapsuleConstructor, methodName: string): void {
const target = ensureRegistration(ctor);
const target = requireRegistration(ctor);
if (target.installMethod !== undefined) {
throw new Error(`Only one @install method allowed on ${ctor.name}.`);
}
target.installMethod = methodName;
}

export function recordUpgrade(ctor: CapsuleConstructor, methodName: string): void {
const target = ensureRegistration(ctor);
const target = requireRegistration(ctor);
if (target.upgradeMethod !== undefined) {
throw new Error(`Only one @upgrade method allowed on ${ctor.name}.`);
}
target.upgradeMethod = methodName;
}

export function recordRun(ctor: CapsuleConstructor, methodName: string): void {
const target = ensureRegistration(ctor);
const target = requireRegistration(ctor);
if (target.runMethod !== undefined) {
throw new Error(`Only one @run method allowed on ${ctor.name}.`);
}
Expand All @@ -175,4 +197,6 @@ export function getRegistration(): CapsuleRegistration | undefined {
/** Test-only: reset the registry to a clean state. */
export function __resetRegistry(): void {
registration = undefined;
deferred = [];
deferredByMetadata = new WeakMap();
}
Comment on lines 198 to 202

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Reset the pendingByMetadata WeakMap as well to ensure a clean state between tests.

Suggested change
export function __resetRegistry(): void {
registration = undefined;
deferred = [];
}
export function __resetRegistry(): void {
registration = undefined;
deferred = [];
pendingByMetadata = new WeakMap();
}

32 changes: 16 additions & 16 deletions packages/astrid-sdk/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import {
type CapsuleConstructor,
defer,
recordTool,
recordInterceptor,
recordCommand,
Expand Down Expand Up @@ -48,16 +48,16 @@ export function tool(name: string, options: ToolOptions = {}) {
if (context.private || context.static) {
throw new Error(`@tool("${name}") must be applied to a public instance method.`);
}
context.addInitializer(function () {
const ctor = (this as object).constructor as CapsuleConstructor;
const methodName = String(context.name);
defer(context.metadata, (ctor) =>
recordTool(ctor, {
name,
methodName: String(context.name),
methodName,
mutable: options.mutable === true,
description: options.description,
inputSchema: options.inputSchema,
});
});
}),
);
Comment on lines +51 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass context.metadata to defer to associate this registration with the current class context.

Suggested change
const methodName = String(context.name);
defer((ctor) =>
recordTool(ctor, {
name,
methodName: String(context.name),
methodName,
mutable: options.mutable === true,
description: options.description,
inputSchema: options.inputSchema,
});
});
}),
);
const methodName = String(context.name);
defer(context.metadata, (ctor) =>
recordTool(ctor, {
name,
methodName,
mutable: options.mutable === true,
description: options.description,
inputSchema: options.inputSchema,
}),
);

};
}

Expand All @@ -80,14 +80,14 @@ export function interceptor(topic: string, options: InterceptorOptions = {}) {
if (context.private || context.static) {
throw new Error(`@interceptor("${topic}") must be applied to a public instance method.`);
}
context.addInitializer(function () {
const ctor = (this as object).constructor as CapsuleConstructor;
const methodName = String(context.name);
defer(context.metadata, (ctor) =>
recordInterceptor(ctor, {
topic,
methodName: String(context.name),
methodName,
mutable: options.mutable === true,
});
});
}),
);
Comment on lines +83 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass context.metadata to defer to associate this registration with the current class context.

Suggested change
const methodName = String(context.name);
defer((ctor) =>
recordInterceptor(ctor, {
topic,
methodName: String(context.name),
methodName,
mutable: options.mutable === true,
});
});
}),
);
const methodName = String(context.name);
defer(context.metadata, (ctor) =>
recordInterceptor(ctor, {
topic,
methodName,
mutable: options.mutable === true,
}),
);

};
}

Expand All @@ -107,14 +107,14 @@ export function command(name: string, options: CommandOptions = {}) {
if (context.private || context.static) {
throw new Error(`@command("${name}") must be applied to a public instance method.`);
}
context.addInitializer(function () {
const ctor = (this as object).constructor as CapsuleConstructor;
const methodName = String(context.name);
defer(context.metadata, (ctor) =>
recordCommand(ctor, {
name,
methodName: String(context.name),
methodName,
mutable: options.mutable === true,
});
});
}),
);
Comment on lines +110 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass context.metadata to defer to associate this registration with the current class context.

Suggested change
const methodName = String(context.name);
defer((ctor) =>
recordCommand(ctor, {
name,
methodName: String(context.name),
methodName,
mutable: options.mutable === true,
});
});
}),
);
const methodName = String(context.name);
defer(context.metadata, (ctor) =>
recordCommand(ctor, {
name,
methodName,
mutable: options.mutable === true,
}),
);

};
}

Expand Down