Skip to content

Commit 05244f2

Browse files
alexyakuninclaude
andcommitted
fix(fusion-rpc): server-side invalidation parity (F1, F9, F7, F10)
Align FusionHub's compute-call handling with C# RpcInboundComputeCall / RpcComputeSystemCallSender: - F1: wire server->client invalidation via `computed.whenInvalidated()` (ProcessStage2 parity) instead of the post-invoke onInvalidated subscription, so a computed invalidated mid-computation still sends $sys-c.Invalidate. The send is deferred past the dispatch loop's $sys.Ok so the result precedes the invalidation, matching C#'s ProcessStage1Plus-then-ProcessStage2 ordering (a client drops an Invalidate that arrives before its result). - F9: route the invalidation through a Fusion-extended system-call sender (FusionSystemCallSender.invalidate) using the peer's current connection and serialization format, instead of a hand-rolled JSON serializeMessage on a captured connection. RpcHub gains a `_createSystemCallSender` factory; `_send` is now protected. - F7: thread the inbound message CallType through RpcDispatchContext; a Regular call to a compute method returns its result and skips invalidation tracking (IsRegularCall parity). - F10: `_buildServiceDef` now delegates to `super._buildServiceDef` and only patches callTypeId for compute methods, so decorator-declared noWait / remoteExecutionMode metadata is preserved. Tests: moves the F1 repro into e2e-rpc.test.ts and adds regression tests for F9 (msgpack hub pair: invalidation arrives as a binary frame, no JSON text frame), F7 (regular call sends no $sys-c.Invalidate; compute call does), and F10 (noWait / remoteExecutionMode survive FusionHub registration). Full ts suite: 668 passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDiBQM5rnhyBG1asuP1kDi
1 parent ab121bb commit 05244f2

7 files changed

Lines changed: 301 additions & 38 deletions

File tree

docs/plans/ts-port-audit.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,8 @@ Confidence: confirmed by full source trace. (Second-pass finding.)
796796

797797
### F1. Server→client invalidation is wired with a one-shot `onInvalidated.add()` that misses already-invalidated computeds → client stays stale forever
798798

799+
Status: **closed** — fixed 2026-07-15 (batch fusionrpc1; `whenInvalidated().then(send)` with the send deferred one macrotask to preserve C#'s result-before-invalidation ordering — verified against the synchronous send path).
800+
799801
Confidence: confirmed (re-verified).
800802

801803
- TS: `FusionHub._wrapServerMethod` awaits `cf.invoke(...)`, and only *then* subscribes: `computed.onInvalidated.add(() => ... send($sys-c.Invalidate))` (`fusion-hub.ts:182-193`). `EventHandlerSet` has no replay, and `invalidate()` clears its handlers (K12). If the computed is invalidated *during* computation (mutation while a slow async server method runs), the pending-invalidate path re-invalidates **inside** `cf.invoke` before it resolves — the handler is added to a dead event set and **`$sys-c.Invalidate` is never sent**. The same hole exists for the microtask gap between `cf.invoke` resolving and `.add()` executing.
@@ -857,6 +859,8 @@ Confidence: confirmed (deliberate, test-codified deviation — but with a wastef
857859

858860
### F7. TS server ignores the message's `CallType` — regular calls to compute methods still get invalidation tracking
859861

862+
Status: **closed** — fixed 2026-07-15 (batch fusionrpc1).
863+
860864
Confidence: confirmed.
861865

862866
- TS: `_wrapServerMethod` decides by the server-side `methodDef.callTypeId` only (`fusion-hub.ts:171`); `createRpcClient` produces exactly such regular calls (plain `RpcOutboundCall`, `removeOnOk = true``rpc-client.ts:72-90`).
@@ -877,13 +881,17 @@ Confidence: confirmed.
877881

878882
### F9. Server-side invalidation send bypasses the peer's serialization format and `systemCallSender` (low)
879883

884+
Status: **closed** — fixed 2026-07-15 (batch fusionrpc1; `FusionSystemCallSender.invalidate()` resolving connection + format at fire time).
885+
880886
Confidence: confirmed code path; impact plausible-low. `_wrapServerMethod` hand-rolls `serializeMessage(...)` — JSON-only (`rpc-serialization.ts:31-41`) — and writes to the captured `context.connection` (`fusion-hub.ts:186-192`), while all other responses go through `hub.systemCallSender` with `peer.serializationFormat`. On a msgpack connection the invalidation goes out as a JSON text frame; TS clients tolerate mixed frames, a .NET client would not. Also uses a possibly-dead captured connection.
881887

882888
- **Recommended:** add an `invalidate()` to the (Fusion-extended) system-call sender, sending via the peer's *current* connection and serialization format — `RpcComputeSystemCallSender.Invalidate` parity. Natural companion to F1's fix, same code.
883889
- **Alternative:** keep the inline send but resolve the connection at send time and use the peer's format. Same effect, less structure; fine if F1 is fixed the minimal way.
884890

885891
### F10. `FusionHub._buildServiceDef` drifted from the base implementation (low)
886892

893+
Status: **closed** — fixed 2026-07-15 (batch fusionrpc1; delegates to the base, patches only `callTypeId`).
894+
887895
Confidence: confirmed. The override hardcodes `remoteExecutionMode: Default` and skips the base's `noWait → mode 0` and `meta.remoteExecutionMode` honoring (`fusion-hub.ts:146-160` vs `rpc-hub.ts:244-259`). A decorator-declared custom `remoteExecutionMode` is silently ignored when registered through a `FusionHub`.
888896

889897
- **Recommended:** delegate to `super._buildServiceDef` and only patch `callTypeId` for compute methods — removes the duplicated logic and the drift with it.

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

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,11 @@ import {
4141
RpcServerPeer,
4242
RpcWebSocketConnection,
4343
RpcSystemCallHandler,
44-
serializeMessage,
44+
RpcSystemCallSender,
4545
defineRpcService,
4646
wireMethodName,
47-
RpcType,
48-
RpcRemoteExecutionMode,
4947
type RpcConnection,
48+
type RpcSerializationFormat,
5049
type WebSocketLike,
5150
type RpcServiceDef,
5251
type RpcMethodDef,
@@ -55,7 +54,6 @@ import {
5554
type RpcPeer,
5655
type RpcMessage,
5756
type RpcCallOptions,
58-
getServiceMeta,
5957
getMethodsMeta,
6058
} from '@actuallab/rpc';
6159
import {
@@ -90,6 +88,20 @@ class FusionSystemCallHandler extends RpcSystemCallHandler {
9088
}
9189
}
9290

91+
/** Sends the Fusion $sys-c.Invalidate system call — mirrors .NET's RpcComputeSystemCallSender. */
92+
class FusionSystemCallSender extends RpcSystemCallSender {
93+
invalidate(
94+
conn: RpcConnection,
95+
format: RpcSerializationFormat,
96+
relatedId: number
97+
): void {
98+
this._send(conn, format, {
99+
Method: FUSION_INVALIDATE_METHOD,
100+
RelatedId: relatedId,
101+
});
102+
}
103+
}
104+
93105
/** Creates a compute service definition — all methods default to FUSION_CALL_TYPE_ID. */
94106
export function defineComputeService(
95107
name: string,
@@ -107,13 +119,19 @@ export function defineComputeService(
107119

108120
/** Central coordinator for Fusion + RPC — manages compute services, invalidation wiring. */
109121
export class FusionHub extends RpcHub {
122+
declare readonly systemCallSender: FusionSystemCallSender;
123+
110124
constructor(hubId?: string) {
111125
super(hubId);
112126
this.systemCallHandler = new FusionSystemCallHandler();
113127
// Register Fusion-specific system call for compact format hash resolution
114128
this.registry.register(FUSION_INVALIDATE_METHOD);
115129
}
116130

131+
protected override _createSystemCallSender(): FusionSystemCallSender {
132+
return new FusionSystemCallSender();
133+
}
134+
117135
/** Accept an incoming WebSocket and create a server peer. */
118136
acceptConnection(ws: WebSocketLike): RpcServerPeer {
119137
const ref = `server://${crypto.randomUUID()}`;
@@ -131,36 +149,28 @@ export class FusionHub extends RpcHub {
131149
return peer;
132150
}
133151

134-
/** Override to apply FUSION_CALL_TYPE_ID for compute methods. */
152+
/** Delegate to the base builder, then patch callTypeId for compute methods —
153+
* keeps the base's noWait / remoteExecutionMode handling instead of drifting. */
135154
protected override _buildServiceDef(
136155
// eslint-disable-next-line @typescript-eslint/no-explicit-any
137156
cls: abstract new (...args: any[]) => any
138157
): RpcServiceDef {
139-
const svcMeta = getServiceMeta(cls);
140-
if (svcMeta === undefined)
141-
throw new Error('Contract class missing @rpcService metadata');
142-
158+
const def = super._buildServiceDef(cls);
143159
const methodsMeta = getMethodsMeta(cls) ?? {};
144160
const methods = new Map<string, RpcMethodDef>();
145-
146-
for (const [name, meta] of Object.entries(methodsMeta)) {
147-
const wireArgCount = meta.argCount + 1;
148-
const mapKey = `${name}:${wireArgCount}`;
149-
methods.set(mapKey, {
150-
name,
151-
serviceName: svcMeta.name,
152-
argCount: meta.argCount,
153-
wireArgCount,
154-
callTypeId:
155-
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
156-
(meta as any).compute === true ? FUSION_CALL_TYPE_ID : 0,
157-
stream: meta.returns === RpcType.stream,
158-
noWait: meta.returns === RpcType.noWait,
159-
remoteExecutionMode: RpcRemoteExecutionMode.Default,
160-
});
161+
for (const [mapKey, methodDef] of def.methods) {
162+
const isCompute =
163+
(methodsMeta[methodDef.name] as { compute?: boolean } | undefined)
164+
?.compute === true;
165+
methods.set(
166+
mapKey,
167+
isCompute
168+
? { ...methodDef, callTypeId: FUSION_CALL_TYPE_ID }
169+
: methodDef
170+
);
161171
}
162172

163-
return { name: svcMeta.name, methods };
173+
return { name: def.name, methods };
164174
}
165175

166176
protected override _wrapServerMethod(
@@ -181,14 +191,27 @@ export class FusionHub extends RpcHub {
181191

182192
const computed = await cf.invoke(impl, cleanArgs);
183193

184-
// Wire invalidation → send $sys-c.Invalidate to the client
185-
if (context !== undefined) {
186-
computed.onInvalidated(() => {
187-
const msg = serializeMessage({
188-
Method: FUSION_INVALIDATE_METHOD,
189-
RelatedId: context.callId,
190-
});
191-
context.connection.send(msg);
194+
// IsRegularCall parity (F7): a Regular call to a compute method returns
195+
// the result immediately, with no invalidation tracking.
196+
if (context?.callType === FUSION_CALL_TYPE_ID) {
197+
const { peer, callId } = context;
198+
// ProcessStage2 parity (F1): whenInvalidated() resolves immediately
199+
// for an already-invalidated computed, so an invalidation landing
200+
// during computation still produces the send.
201+
void computed.whenInvalidated().then(() => {
202+
// C# sends the result (ProcessStage1Plus) before the
203+
// invalidation (ProcessStage2). Here the dispatch loop sends
204+
// $sys.Ok after this wrapper returns, so defer past that turn —
205+
// a client drops an Invalidate that precedes its result.
206+
setTimeout(() => {
207+
const conn = peer.connection;
208+
if (conn !== undefined)
209+
this.systemCallSender.invalidate(
210+
conn,
211+
peer.serializationFormat,
212+
callId
213+
);
214+
}, 0);
192215
});
193216
}
194217

0 commit comments

Comments
 (0)