Skip to content

fix(runtime): register decorators at class-evaluation time, not construction#21

Open
eren-karakus0 wants to merge 2 commits into
astrid-runtime:mainfrom
eren-karakus0:fix/decorator-registry-timing
Open

fix(runtime): register decorators at class-evaluation time, not construction#21
eren-karakus0 wants to merge 2 commits into
astrid-runtime:mainfrom
eren-karakus0:fix/decorator-registry-timing

Conversation

@eren-karakus0

Copy link
Copy Markdown

Fixes #20.

Problem

The member decorators (@tool / @interceptor / @command / @install / @upgrade / @run) recorded their registrations from inside context.addInitializer(...). Per the TC39 decorators proposal, those initializers only run during instance construction — but createBridge() reads the registry before constructing anything. So on a stock capsule built with the published packages, every lifecycle/tool/run registration looked empty:

  • astridInstall() saw installMethod === undefined@install hooks silently no-op'd;
  • run() saw runMethod === undefined → returned immediately, so the kernel reported "run loop exited before signaling ready" and health-restarted the capsule;
  • tool_describe / hook dispatch saw empty tools / interceptors / commands maps.

Only @capsule worked, because the class decorator registers the constructor at class-evaluation time — which is exactly why the failure looked like "the capsule is fine but empty".

Fix

Register at class-evaluation time instead of construction time. Member decorators can't name the constructor yet (they run before the class exists), so each defers a small closure onto a module-scoped queue; the @capsule class decorator runs last, already knows the constructor, and flushes the queue against it:

// member decorator
const methodName = String(context.name);
defer((ctor) => recordTool(ctor, { name, methodName, ... }));

// @capsule class decorator
registerCapsule(target);
flushDeferred(target);

Registration now lands synchronously at class-evaluation time — before any construction and before the bridge reads it — and runs exactly once per class rather than once per instance. This lets the previous pendingByCtor / ensureRegistration / adoptPending buffering machinery go away.

As a bonus this also resolves the per-instance re-registration flagged by the audit in #18: previously a second new Capsule() re-ran the initializers and threw Only one @install method allowed.

Validation

Compiled with the package's own tsc (the same emit ComponentizeJS consumes) and run in Node — reading the registry the way the bridge does, without constructing the capsule:

Before (published code):

[PASS] a capsule is registered
[PASS] ctor is Demo
[FAIL] @install recorded
[FAIL] @run recorded
[FAIL] @tool status recorded
...
Error: Only one @install method allowed on Demo.   ← on the 2nd construction (#18)

After (this PR):

[PASS] @install recorded
[PASS] @upgrade recorded
[PASS] @run recorded
[PASS] @tool status recorded
[PASS] @tool play recorded (mutable)
[PASS] @interceptor recorded
[PASS] @command recorded (mutable)
[PASS] construction does not re-register (tools 2 -> 2)
ALL CHECKS PASSED — registry populated at decoration time
  • Full-package tsc --noEmit passes (all strict flags: exactOptionalPropertyTypes, verbatimModuleSyntax, noUncheckedIndexedAccess, …).
  • On top of the published 0.1.0, the equivalent patch let a capsule's @install hook run a full HTTP session inside the kernel sandbox and kept @run + runtime.signalReady() healthy on astrid 0.9.0.

The diff is limited to capsule.ts, tool.ts, and runtime/registry.ts; bridge.ts is unchanged (its "the registry is populated before I read it" assumption is simply true again).

Happy to fold the before/after check into a proper unit test if you'd like to seed the test surface the CI's npm test step is reserved for — just say the word and I'll add it (vitest or your harness of choice).

…ruction

Member decorators (@tool/@interceptor/@command/@install/@upgrade/@run)
recorded their registrations from context.addInitializer, which per the
TC39 proposal only runs when an instance is first constructed. But the
runtime bridge reads the registry BEFORE constructing anything, so every
lifecycle/tool/run registration looked empty: @install hooks silently
no-op'd, @run loops reported "exited before signaling ready", and tool
dispatch never reached the capsule.

Defer each member registration onto a module-scoped queue instead; the
@capsule class decorator (which runs last and knows the constructor)
flushes the queue. Registration now lands synchronously at class-evaluation
time -- before any construction and before the bridge reads it -- and runs
exactly once per class rather than once per instance.

Fixes astrid-runtime#20. Also resolves the per-instance re-registration flagged in astrid-runtime#18
(a second construction previously threw "Only one @install method allowed").

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the decorator registration timing in the Astrid SDK. Instead of using context.addInitializer (which runs at construction time), member decorators now defer their registration onto a module-scoped queue, which is then flushed by the @capsule class decorator during class evaluation. The reviewer pointed out that using a single global deferred queue can cause registrations to leak between classes if multiple classes are evaluated in the same runtime context. To resolve this, the reviewer suggested isolating deferred registrations per class by leveraging context.metadata and storing the deferred list in a WeakMap keyed by the metadata object, providing specific code suggestions across all decorators and the registry.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +94 to +106
type DeferredRecord = (ctor: CapsuleConstructor) => void;
let deferred: DeferredRecord[] = [];

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);
}
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;
}
if (pending.upgradeMethod !== undefined && registration.upgradeMethod === undefined) {
registration.upgradeMethod = pending.upgradeMethod;
}
if (pending.runMethod !== undefined && registration.runMethod === undefined) {
registration.runMethod = pending.runMethod;
}
if (pending.description !== undefined && registration.description === undefined) {
registration.description = pending.description;
export function defer(record: DeferredRecord): void {
deferred.push(record);
}

/** Apply every deferred member registration against the now-known ctor. */
export function flushDeferred(ctor: CapsuleConstructor): void {
const pending = deferred;
deferred = [];
for (const record of pending) record(ctor);
}

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);
}

Comment on lines 30 to 37
export function capsule<T extends CapsuleConstructor>(
target: T,
_context: ClassDecoratorContext<T>,
): T {
registerCapsule(target);
adoptPending(target);
flushDeferred(target);
return target;
}

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;
}

Comment thread packages/astrid-sdk/src/capsule.ts Outdated
Comment on lines +51 to +52
const methodName = String(context.name);
defer((ctor) => recordInstall(ctor, methodName));

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) => recordInstall(ctor, methodName));
const methodName = String(context.name);
defer(context.metadata, (ctor) => recordInstall(ctor, methodName));

Comment thread packages/astrid-sdk/src/capsule.ts Outdated
Comment on lines +66 to +67
const methodName = String(context.name);
defer((ctor) => recordUpgrade(ctor, methodName));

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) => recordUpgrade(ctor, methodName));
const methodName = String(context.name);
defer(context.metadata, (ctor) => recordUpgrade(ctor, methodName));

Comment thread packages/astrid-sdk/src/capsule.ts Outdated
Comment on lines +87 to +88
const methodName = String(context.name);
defer((ctor) => recordRun(ctor, methodName));

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) => recordRun(ctor, methodName));
const methodName = String(context.name);
defer(context.metadata, (ctor) => recordRun(ctor, methodName));

Comment on lines +51 to +60
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,
});
});
}),
);

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,
}),
);

Comment on lines +83 to +90
const methodName = String(context.name);
defer((ctor) =>
recordInterceptor(ctor, {
topic,
methodName: String(context.name),
methodName,
mutable: options.mutable === true,
});
});
}),
);

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,
}),
);

Comment on lines +110 to +117
const methodName = String(context.name);
defer((ctor) =>
recordCommand(ctor, {
name,
methodName: String(context.name),
methodName,
mutable: options.mutable === true,
});
});
}),
);

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,
}),
);

Comment on lines 171 to 174
export function __resetRegistry(): void {
registration = undefined;
deferred = [];
}

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();
}

Address review: a single module-scoped queue could leak a non-@capsule
class's member registrations into the next @capsule class evaluated in the
same module. Key the deferred closures by the class's shared
`context.metadata` object (stored in a WeakMap) so each class only flushes
its own. Where the runtime does not expose decorator metadata (no
`Symbol.metadata`, e.g. the current ComponentizeJS engine) `context.metadata`
is undefined and we fall back to the single module queue — correct for the
enforced one-@capsule-per-module case, i.e. no worse than before.

__resetRegistry clears the WeakMap too.
@eren-karakus0

Copy link
Copy Markdown
Author

Good catch — addressed in a62abf0. The single module queue could indeed let a non-@capsule class's members leak into the next capsule evaluated in the same module.

Deferred closures are now keyed by the class's shared context.metadata in a WeakMap, so each class flushes only its own. One nuance worth calling out: context.metadata is only populated when the runtime exposes Symbol.metadata, and the current ComponentizeJS/StarlingMonkey engine (and Node ≤ 22) does not — there context.metadata is undefined, so I kept the single module queue as a fallback. That path is exactly the enforced "one @capsule class per module" case, so it's no worse than before, and the per-class isolation kicks in automatically on engines that do provide metadata.

Verified by compiling with the package's own tsc and reading the registry the way the bridge does (no construction): registrations land at decoration time, a second construction doesn't re-register, and — exercising the metadata routing directly with explicit metadata objects, since this host lacks Symbol.metadata — interleaved classes stay isolated and an un-flushed stray class leaves nothing behind for the next. Full-package tsc --noEmit stays green.

Happy to add these as real unit tests if you'd like to seed the npm test surface.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Runtime bridge reads the decorator registry before any construction - all lifecycle/tool/run registrations appear empty on published 0.1.0

1 participant