Skip to content

Commit a389c01

Browse files
alexyakuninclaude
andcommitted
fix(ts/rpc): correct dispatch receiver, decorator metadata isolation, and wire arity
R18: RpcServiceHost now stores the implementation receiver beside each method entry and dispatches via entry.fn.call(receiver, ...), so regular, no-wait, and stream methods read the correct `this` (including #private state). addService passes the original impl as the receiver; the unbound function is preserved so FusionHub's server-method wrapper keeps working. R19: stage-3 decorator metadata is no longer mutated through the prototype chain. New shared helper ownMetadata (@actuallab/core) clones an inherited metadata record into an own property before writing, so base contracts keep their name/method set and sibling derived contracts no longer contaminate each other. Used by rpcService, rpcMethod, and computeMethod. R20: rpcMethod and computeMethod accept an explicit argCount option; when absent, resolveArgCount (@actuallab/core) scans the parameter list for default (`=`) / rest (`...`) parameters and throws a clear declaration-time error rather than trusting the unreliable Function.length. Tests: end-to-end receiver tests (regular/no-wait/stream, ordinary and #private state) plus base/derived/sibling metadata and arity tests in both the rpc and fusion decorator packages. Full suite: 572 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDiBQM5rnhyBG1asuP1kDi
1 parent 7267af9 commit a389c01

10 files changed

Lines changed: 388 additions & 23 deletions

File tree

docs/plans/ts-port-audit.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,8 @@ Confidence: confirmed (documented omission). TS removes the tracker entry only (
564564

565565
### R18. Regular service dispatch loses the implementation receiver
566566

567+
Status: **closed** — fixed 2026-07-15 (batch rpcdeco).
568+
567569
Confidence: confirmed and executable-probe verified. (Second-pass finding.)
568570

569571
- TS stores only the extracted function (`rpc-service-host.ts:54-61`), then calls `entry.fn(...args)`
@@ -584,6 +586,8 @@ Confidence: confirmed and executable-probe verified. (Second-pass finding.)
584586

585587
### R19. Decorator metadata is shared and mutated across base and derived classes
586588

589+
Status: **closed** — fixed 2026-07-15 (batch rpcdeco; shared `ownMetadata` helper in `@actuallab/core`).
590+
587591
Confidence: confirmed and executable-probe verified. (Second-pass finding.)
588592

589593
- Stage-3 decorator metadata for a derived class inherits from the base metadata object. Both `rpcService` and
@@ -609,6 +613,8 @@ Confidence: confirmed and executable-probe verified. (Second-pass finding.)
609613

610614
### R20. Decorator wire arity is wrong after a default parameter and for rest parameters
611615

616+
Status: **closed** — fixed 2026-07-15 (batch rpcdeco; explicit `argCount` option, ambiguity throws at declaration time; `computeMethod` became dual-mode — bare or `@computeMethod({ argCount })`).
617+
612618
Confidence: confirmed and executable-probe verified. (Second-pass finding.)
613619

614620
- `rpcMethod` records `target.length` (`rpc-decorators.ts:90-95`); `computeMethod` does the same

ts/packages/core/src/decorators.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Helpers shared by the stage-3 decorators of @actuallab/rpc and @actuallab/fusion.
2+
3+
// Returns the record stored under `key` in `metadata`, guaranteeing it is an OWN
4+
// property. Stage-3 decorator metadata of a derived class inherits from the base
5+
// class's metadata via the prototype chain, so writing through an inherited record
6+
// would silently mutate the base contract. When the record is inherited (or missing)
7+
// it is shallow-cloned into an own property first — base entries stay visible on the
8+
// derived contract while derived writes stay local to it.
9+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- callers pick the record shape
10+
export function ownMetadata<T extends object>(metadata: object, key: symbol): T {
11+
const bag = metadata as Record<symbol, T>;
12+
if (!Object.hasOwn(metadata, key))
13+
bag[key] = { ...bag[key] };
14+
15+
return bag[key];
16+
}
17+
18+
// Resolves the fixed argument count of a decorated method for the RPC wire name.
19+
// Function.length silently stops at the first default parameter and ignores rest
20+
// parameters, so an inferred count would be wrong; when the parameter list is
21+
// ambiguous the caller must pass an explicit `argCount` or this throws at declaration
22+
// time (the erased count cannot be recovered reliably at runtime).
23+
export function resolveArgCount(
24+
fn: (...args: never[]) => unknown,
25+
explicit: number | undefined,
26+
methodName: string,
27+
): number {
28+
if (explicit !== undefined)
29+
return explicit;
30+
if (hasAmbiguousArity(fn))
31+
throw new Error(
32+
`Cannot infer the argument count of "${methodName}": its parameter list uses ` +
33+
`default and/or rest parameters, for which Function.length is unreliable. ` +
34+
`Pass an explicit argCount to the decorator.`,
35+
);
36+
37+
return fn.length;
38+
}
39+
40+
// Private methods
41+
42+
function hasAmbiguousArity(fn: (...args: never[]) => unknown): boolean {
43+
const params = parameterListText(fn);
44+
return params !== undefined && (params.includes('=') || params.includes('...'));
45+
}
46+
47+
function parameterListText(fn: (...args: never[]) => unknown): string | undefined {
48+
const src = Function.prototype.toString.call(fn);
49+
const open = src.indexOf('(');
50+
const arrow = src.indexOf('=>');
51+
// A parenless single-identifier arrow (`x => ...`) has no default/rest parameters.
52+
if (open < 0 || (arrow >= 0 && arrow < open))
53+
return undefined;
54+
55+
let depth = 0;
56+
for (let i = open; i < src.length; i++) {
57+
const ch = src[i];
58+
if (ch === '(')
59+
depth++;
60+
else if (ch === ')') {
61+
depth--;
62+
if (depth === 0)
63+
return src.slice(open + 1, i);
64+
}
65+
}
66+
return undefined;
67+
}

ts/packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,6 @@ export {
5555
} from './retry-delayer.js';
5656
export type { RetryDelaySchedule } from './retry.js';
5757
export { retry, catchErrors } from './retry.js';
58+
59+
// Decorator helpers
60+
export { ownMetadata, resolveArgCount } from './decorators.js';

ts/packages/fusion/src/compute-method.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-this-alias */
1+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-this-alias */
2+
import { ownMetadata, resolveArgCount } from '@actuallab/core';
23
import { ComputeFunction } from './compute-function.js';
34
import { ComputedRegistry } from './computed-registry.js';
45

@@ -17,24 +18,53 @@ export function getMethodsMeta(
1718
return (cls as any)[Symbol.metadata]?.[METHODS_META];
1819
}
1920

20-
/** Method decorator — wraps a method to route through ComputeFunction for caching and dependency tracking. */
21+
export interface ComputeMethodOptions {
22+
argCount?: number;
23+
}
24+
25+
type ComputeMethodDecorator<This, Args extends unknown[], Return> = (
26+
target: (this: This, ...args: Args) => Return,
27+
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
28+
) => (this: This, ...args: Args) => Return;
29+
30+
/**
31+
* Method decorator — routes a method through ComputeFunction for caching and dependency
32+
* tracking. Usable bare (`@computeMethod`) or with options (`@computeMethod({ argCount })`).
33+
*/
34+
export function computeMethod<This, Args extends unknown[], Return>(
35+
target: (this: This, ...args: Args) => Return,
36+
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
37+
): (this: This, ...args: Args) => Return;
38+
export function computeMethod<This, Args extends unknown[], Return>(
39+
options?: ComputeMethodOptions
40+
): ComputeMethodDecorator<This, Args, Return>;
2141
export function computeMethod<This, Args extends unknown[], Return>(
42+
targetOrOptions?: ((this: This, ...args: Args) => Return) | ComputeMethodOptions,
43+
context?: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
44+
): unknown {
45+
if (typeof targetOrOptions === 'function')
46+
return computeMethodImpl(targetOrOptions, context!, {});
47+
48+
const options = targetOrOptions ?? {};
49+
return (target: (this: This, ...args: Args) => Return, ctx: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>) =>
50+
computeMethodImpl(target, ctx, options);
51+
}
52+
53+
function computeMethodImpl<This, Args extends unknown[], Return>(
2254
target: (this: This, ...args: Args) => Return,
2355
context: ClassMethodDecoratorContext<
2456
This,
2557
(this: This, ...args: Args) => Return
26-
>
58+
>,
59+
options: ComputeMethodOptions
2760
): (this: This, ...args: Args) => Return {
2861
const methodName = String(context.name);
2962

30-
// Store metadata
31-
const methods: Record<string, MethodMeta> = ((context.metadata as any)[
32-
METHODS_META
33-
] ??= {});
63+
const methods = ownMetadata<Record<string, MethodMeta>>(context.metadata, METHODS_META);
3464
methods[methodName] = {
3565
...methods[methodName],
3666
compute: true,
37-
argCount: target.length,
67+
argCount: resolveArgCount(target, options.argCount, methodName),
3868
};
3969

4070
// ONE ComputeFunction per class×method — created at decoration time
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { computeMethod, getMethodsMeta } from '../src/index.js';
3+
4+
describe('computeMethod metadata isolation across inheritance (R19)', () => {
5+
it('derived methods do not leak into the base contract', () => {
6+
class Base {
7+
@computeMethod
8+
base(_a: string): number { return 0; }
9+
}
10+
class Derived extends Base {
11+
@computeMethod
12+
derived(_a: string): number { return 0; }
13+
}
14+
15+
expect(Object.keys(getMethodsMeta(Base) ?? {})).toEqual(['base']);
16+
expect(Object.keys(getMethodsMeta(Derived) ?? {}).sort()).toEqual(['base', 'derived']);
17+
});
18+
19+
it('sibling derived contracts do not contaminate one another', () => {
20+
class Base {
21+
@computeMethod
22+
base(_a: string): number { return 0; }
23+
}
24+
class DerivedA extends Base {
25+
@computeMethod
26+
a(_a: string): number { return 0; }
27+
}
28+
class DerivedB extends Base {
29+
@computeMethod
30+
b(_a: string): number { return 0; }
31+
}
32+
33+
expect(Object.keys(getMethodsMeta(Base) ?? {})).toEqual(['base']);
34+
expect(Object.keys(getMethodsMeta(DerivedA) ?? {}).sort()).toEqual(['a', 'base']);
35+
expect(Object.keys(getMethodsMeta(DerivedB) ?? {}).sort()).toEqual(['b', 'base']);
36+
});
37+
});
38+
39+
describe('computeMethod wire arity (R20)', () => {
40+
it('infers argCount for a plain parameter list', () => {
41+
class Svc {
42+
@computeMethod
43+
m(_a: string, _b: number): number { return 0; }
44+
}
45+
expect(getMethodsMeta(Svc)!['m'].argCount).toBe(2);
46+
});
47+
48+
it('throws for a default parameter without an explicit argCount', () => {
49+
expect(() => {
50+
class Svc {
51+
@computeMethod
52+
m(_a: string, _b = 1): number { return 0; }
53+
}
54+
return Svc;
55+
}).toThrow(/argCount/);
56+
});
57+
58+
it('throws for a rest parameter without an explicit argCount', () => {
59+
expect(() => {
60+
class Svc {
61+
@computeMethod
62+
m(..._args: unknown[]): number { return 0; }
63+
}
64+
return Svc;
65+
}).toThrow(/argCount/);
66+
});
67+
68+
it('uses the explicit argCount for a default parameter', () => {
69+
class Svc {
70+
@computeMethod({ argCount: 2 })
71+
m(_a: string, _b = 1): number { return 0; }
72+
}
73+
expect(getMethodsMeta(Svc)!['m'].argCount).toBe(2);
74+
});
75+
});

ts/packages/rpc/src/rpc-decorators.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
// RpcServiceDef. .NET determines this via the service interface hierarchy
2626
// (IComputeService marker interface).
2727

28+
import { ownMetadata, resolveArgCount } from '@actuallab/core';
29+
2830
const METHODS_META = Symbol.for('actuallab.methods');
2931
const SERVICE_META = Symbol.for('actuallab.service');
3032

@@ -65,15 +67,17 @@ export function rpcService(serviceName: string) {
6567
_target: T,
6668
context: ClassDecoratorContext<T>,
6769
): void {
68-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -- decorator metadata requires untyped access
69-
const meta: ServiceMeta = ((context.metadata as any)[SERVICE_META] ??=
70-
{} as ServiceMeta);
70+
const meta = ownMetadata<ServiceMeta>(context.metadata, SERVICE_META);
7171
meta.name = serviceName;
7272
};
7373
}
7474

7575
/** Method decorator — stores RPC method metadata (argCount, returns, remoteExecutionMode). Does NOT wrap the method. */
76-
export function rpcMethod(options?: { returns?: symbol; remoteExecutionMode?: number }) {
76+
export function rpcMethod(options?: {
77+
returns?: symbol;
78+
remoteExecutionMode?: number;
79+
argCount?: number;
80+
}) {
7781
return function <This, Args extends unknown[], Return>(
7882
target: (this: This, ...args: Args) => Return,
7983
context: ClassMethodDecoratorContext<
@@ -82,14 +86,10 @@ export function rpcMethod(options?: { returns?: symbol; remoteExecutionMode?: nu
8286
>,
8387
): (this: This, ...args: Args) => Return {
8488
const methodName = String(context.name);
85-
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -- decorator metadata requires untyped access */
86-
const methods: Record<string, MethodMeta> = ((context.metadata as any)[
87-
METHODS_META
88-
] ??= {});
89-
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
89+
const methods = ownMetadata<Record<string, MethodMeta>>(context.metadata, METHODS_META);
9090
methods[methodName] = {
9191
...methods[methodName],
92-
argCount: target.length,
92+
argCount: resolveArgCount(target, options?.argCount, methodName),
9393
returns: options?.returns,
9494
remoteExecutionMode: options?.remoteExecutionMode,
9595
};

ts/packages/rpc/src/rpc-hub.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export class RpcHub {
172172
? this._wrapServerMethod(methodDef, fn, impl as object)
173173
: fn;
174174
}
175-
this.serviceHost.register(def, wrappedImpl);
175+
this.serviceHost.register(def, wrappedImpl, impl as object);
176176
this.registry.registerService(def.name, def.methods);
177177
}
178178

ts/packages/rpc/src/rpc-service-host.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,17 @@ export interface RpcDispatchContext {
4848
export class RpcServiceHost {
4949
private _methods = new Map<
5050
string,
51-
{ def: RpcMethodDef; fn: (...args: unknown[]) => unknown }
51+
{ def: RpcMethodDef; fn: (...args: unknown[]) => unknown; receiver: object }
5252
>();
5353

54-
register(def: RpcServiceDef, impl: RpcServiceImpl): void {
54+
register(def: RpcServiceDef, impl: RpcServiceImpl, receiver: object = impl): void {
5555
for (const methodDef of def.methods.values()) {
5656
const fn = impl[methodDef.name];
5757
if (!fn) continue;
5858
this._methods.set(wireMethodName(methodDef), {
5959
def: methodDef,
6060
fn,
61+
receiver,
6162
});
6263
}
6364
}
@@ -77,9 +78,9 @@ export class RpcServiceHost {
7778
// Pass context as the last arg only for custom call types (e.g., compute) — their
7879
// wrapped functions use it; regular methods should not see it to avoid polluting ...args.
7980
if (context !== undefined && entry.def.callTypeId !== 0) {
80-
return await entry.fn(...args, context);
81+
return await entry.fn.call(entry.receiver, ...args, context);
8182
}
82-
return await entry.fn(...args);
83+
return await entry.fn.call(entry.receiver, ...args);
8384
}
8485

8586
getMethodDef(wireMethod: string): RpcMethodDef | undefined {

0 commit comments

Comments
 (0)