Skip to content

Commit 636ef08

Browse files
alexyakuninclaude
andcommitted
fix(rpc-ts): align RPC wire layer with .NET V5/ExceptionInfo contracts
Batch "rpcwire" fixes from docs/plans/ts-port-audit.md: - R1: parse V5/V5Compact headers AFTER the argument data (per RpcByteMessageSerializerV5), reading argData right after the 4-byte length prefix and counting header bytes in bytesRead. - R2 (D3): send JS errors to .NET peers as RemoteException — TypeRef 'ActualLab.Serialization.RemoteException, ActualLab.Core' with Message "{name}: {message}" from $sys.Error and stream $sys.End. - R6: unregister mempack6/mempack6c; the resolver now throws a clear "MemoryPack formats are not supported by the TS client" error. - R7 (partial): export readPolymorphismMarker (\0\0 fast path, NullValue, derived-type markers throw) as a building block — it is NOT wired into arg decoding because TS has no per-argument polymorphism signal (in .NET, needsPolymorphism comes from method metadata, not the wire). Instead, $sys.Ok/I/B handlers guard on value arity (these calls carry a fixed count; marker bytes decode as extra msgpack values), so a polymorphic payload now rejects the call / errors the stream with a clear "polymorphic payloads are not supported" error instead of silently resolving marker bytes as the result. - R15: key $sys.End error detection on TypeRef presence (fallback to Message), carrying the TypeRef's type name into the Error's name. Adds regression tests for R1 (V5 + V5Compact + headerless + two headers in a multi-message frame), R2, R6, R7 (marker parsing + arity guards), R15; removes the temporary ts-port-audit-repro suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDiBQM5rnhyBG1asuP1kDi
1 parent 877178f commit 636ef08

11 files changed

Lines changed: 537 additions & 58 deletions

docs/plans/ts-port-audit.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,8 @@ Confidence: confirmed. Every failure is pushed to a plain array; only manual `di
414414

415415
### R1. V5/V5C binary format: header block parsed at the wrong position
416416

417+
Status: **closed** — fixed 2026-07-15 (batch rpcwire; byte-level parity re-verified against the .NET writer, incl. multi-header and multi-message frames).
418+
417419
Confidence: confirmed (byte-level; re-verified).
418420

419421
- TS: `rpc-serialization.ts:293-321` (`deserializeBinaryMessage`) reads the 4-byte argLen, then skips headers, then reads argData. Same in the compact variant at `:473-495`.
@@ -425,6 +427,8 @@ Confidence: confirmed (byte-level; re-verified).
425427

426428
### R2. `$sys.Error` sent without `TypeRef` permanently hangs .NET callers
427429

430+
Status: **closed** — fixed 2026-07-15 (batch rpcwire, per D3).
431+
428432
Confidence: confirmed.
429433

430434
- TS: `rpc-system-call-sender.ts:152-165``error()` sends `[{ Message }]`, no `TypeRef`.
@@ -469,6 +473,8 @@ Confidence: confirmed (code path; failure against .NET verified by its handshake
469473

470474
### R6. `mempack6` / `mempack6c` registered as MessagePack formats
471475

476+
Status: **closed** — fixed 2026-07-15 (batch rpcwire).
477+
472478
Confidence: confirmed.
473479

474480
- TS: `rpc-serialization-format.ts:239-242, 249-252``MemoryPackV6`/`MemoryPackV6C` are constructed as msgpack formats and resolvable via `f=` URL keys.
@@ -479,6 +485,8 @@ Confidence: confirmed.
479485

480486
### R7. Binary polymorphism markers (`msgpack6`) not implemented
481487

488+
Status: **closed (partial)** — 2026-07-15 (batch rpcwire). Polymorphic payloads now fail loudly ("polymorphic payloads are not supported") via an arity guard on `$sys.Ok`/`I`/`B` value counts, and `readPolymorphismMarker` ships as a verified building block. Actual decoding of polymorphic payloads stays unsupported until TS method metadata gains a polymorphism flag — there is no wire signal to key on (`needsPolymorphism` is .NET-side method metadata).
489+
482490
Confidence: confirmed in code; triggers only for polymorphic types.
483491

484492
- TS: args are encoded/decoded as bare concatenated msgpack values (`rpc-serialization.ts:209-257, 309-321`), no type-marker handling.
@@ -559,6 +567,8 @@ Confidence: confirmed code; failure plausible.
559567

560568
### R15. `$sys.End` error detection keyed on `Message` truthiness, not `TypeRef`
561569

570+
Status: **closed** — fixed 2026-07-15 (batch rpcwire).
571+
562572
Confidence: confirmed. TS: `rpc-system-call-handler.ts:146-163``error = msg ? new Error(msg) : null`. C#: `error.IsNone` = empty `TypeRef` (`RpcSystemCalls.cs:180-187`, `ExceptionInfo.cs:32`). A stream terminated by an exception with an empty `Message` looks like clean completion.
563573

564574
- **Recommended:** key error detection on `TypeRef` presence (fall back to `Message`), and carry the `TypeRef` into the constructed `Error`'s name for fidelity.

ts/packages/rpc/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {
2222
createBinaryEncoder,
2323
defaultBinaryEncoder,
2424
defaultBinaryDecoder,
25+
readPolymorphismMarker,
2526
} from './rpc-serialization.js';
2627

2728
export type {
@@ -56,7 +57,7 @@ export {
5657
} from './rpc-call-tracker.js';
5758

5859
export { RpcCallStage } from './rpc-call-stage.js';
59-
export { RpcError } from './rpc-error.js';
60+
export { RpcError, REMOTE_EXCEPTION_TYPE_REF, toExceptionInfo } from './rpc-error.js';
6061
export { IncreasingSeqCompressor } from './increasing-seq-compressor.js';
6162

6263
export { RpcSystemCallHandler } from './rpc-system-call-handler.js';

ts/packages/rpc/src/rpc-error.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,16 @@ export class RpcError extends Error {
99
this.typeName = typeName;
1010
}
1111
}
12+
13+
// Assembly-qualified TypeRef of ActualLab's RemoteException — the type every
14+
// ActualLab peer can reconstruct from a single (string message) ctor. TS uses
15+
// it whenever it reports a JS error to a .NET peer ($sys.Error, stream $sys.End),
16+
// folding the JS error name into the message for provenance (see decision D3).
17+
export const REMOTE_EXCEPTION_TYPE_REF = 'ActualLab.Serialization.RemoteException, ActualLab.Core';
18+
19+
/** Wire shape of .NET `ExceptionInfo` for a JS error sent to a .NET peer. */
20+
export function toExceptionInfo(error: unknown): { TypeRef: string; Message: string } {
21+
const name = error instanceof Error ? error.name : 'Error';
22+
const message = error instanceof Error ? error.message : String(error);
23+
return { TypeRef: REMOTE_EXCEPTION_TYPE_REF, Message: `${name}: ${message}` };
24+
}

ts/packages/rpc/src/rpc-serialization-format.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,7 @@ export abstract class RpcSerializationFormat {
7575
static NewtonsoftJsonV5: RpcSerializationFormat;
7676
static NewtonsoftJsonV5NP: RpcSerializationFormat;
7777
static MessagePackV6: RpcSerializationFormat;
78-
static MemoryPackV6: RpcSerializationFormat;
7978
static MessagePackV6C: RpcSerializationFormat;
80-
static MemoryPackV6C: RpcSerializationFormat;
8179
static All: readonly RpcSerializationFormat[];
8280

8381
/** Convenience shortcut for RpcSerializationFormatResolver.Default.get(key). */
@@ -126,7 +124,7 @@ export class RpcJsonSerializationFormat extends RpcSerializationFormat {
126124
}
127125

128126
/**
129-
* MessagePack binary format — "msgpack6" / "mempack6" (V5 wire format).
127+
* MessagePack binary format — "msgpack6" (V5 wire format).
130128
*/
131129
export class RpcMessagePackSerializationFormat extends RpcSerializationFormat {
132130
readonly key: string;
@@ -171,7 +169,7 @@ export class RpcMessagePackSerializationFormat extends RpcSerializationFormat {
171169
}
172170

173171
/**
174-
* MessagePack compact binary format — "msgpack6c" / "mempack6c" (V5Compact).
172+
* MessagePack compact binary format — "msgpack6c" (V5Compact).
175173
* Uses 4-byte method hash instead of full method name.
176174
* Registry is passed via parameters — format instance is immutable.
177175
*/
@@ -236,20 +234,19 @@ RpcSerializationFormat.NewtonsoftJsonV5 = new RpcJsonSerializationFormat('njson5
236234
RpcSerializationFormat.NewtonsoftJsonV5NP = new RpcJsonSerializationFormat('njson5np');
237235
// Binary: MessagePack (non-compact)
238236
RpcSerializationFormat.MessagePackV6 = new RpcMessagePackSerializationFormat('msgpack6');
239-
RpcSerializationFormat.MemoryPackV6 = new RpcMessagePackSerializationFormat('mempack6');
240237
// Binary: MessagePack (compact)
241238
RpcSerializationFormat.MessagePackV6C = new RpcMessagePackCompactSerializationFormat('msgpack6c');
242-
RpcSerializationFormat.MemoryPackV6C = new RpcMessagePackCompactSerializationFormat('mempack6c');
243239

240+
// The mempack6* formats are intentionally NOT registered: .NET's mempack6 uses
241+
// MemoryPack argument serialization (a different byte format), which the TS
242+
// client does not implement. Resolving one throws via isMemoryPackKey below.
244243
RpcSerializationFormat.All = [
245244
RpcSerializationFormat.SystemJsonV5,
246245
RpcSerializationFormat.SystemJsonV5NP,
247246
RpcSerializationFormat.NewtonsoftJsonV5,
248247
RpcSerializationFormat.NewtonsoftJsonV5NP,
249248
RpcSerializationFormat.MessagePackV6,
250-
RpcSerializationFormat.MemoryPackV6,
251249
RpcSerializationFormat.MessagePackV6C,
252-
RpcSerializationFormat.MemoryPackV6C,
253250
];
254251

255252
// ============================================================
@@ -284,6 +281,9 @@ export class RpcSerializationFormatResolver {
284281
}
285282

286283
get(key: string): RpcSerializationFormat {
284+
if (isMemoryPackKey(key))
285+
throw new Error(`Serialization format '${key}': MemoryPack formats are not supported by the TS client.`);
286+
287287
const format = this.tryGet(key);
288288
if (!format)
289289
throw new Error(`No format with key '${key}'.`);
@@ -292,6 +292,13 @@ export class RpcSerializationFormatResolver {
292292

293293
tryGet(key: string): RpcSerializationFormat | undefined {
294294
if (!key) key = this.defaultFormatKey;
295+
if (isMemoryPackKey(key))
296+
throw new Error(`Serialization format '${key}': MemoryPack formats are not supported by the TS client.`);
295297
return this.formats.get(key);
296298
}
297299
}
300+
301+
/** True for .NET MemoryPack format keys (mempack5, mempack6, mempack6c, …). */
302+
function isMemoryPackKey(key: string): boolean {
303+
return key.startsWith('mempack');
304+
}

ts/packages/rpc/src/rpc-serialization.ts

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,46 @@ function readVarUint(
168168

169169
// --- Binary helpers ---
170170

171+
// Skips `headerCount` V5 headers (key: L1Memory, value: LVarSpan) starting at
172+
// `pos`, returning the position past them. The TS client never emits headers,
173+
// but .NET does (e.g. Activity injection), and per the V5 layout they follow
174+
// the argument data — so they must be accounted for in `bytesRead`.
175+
function skipHeaders(data: Uint8Array, pos: number, headerCount: number): number {
176+
for (let h = 0; h < headerCount; h++) {
177+
const keyLen = data[pos++];
178+
pos += keyLen;
179+
const valLen = readVarUint(data, pos);
180+
pos += valLen.bytesRead + valLen.value;
181+
}
182+
return pos;
183+
}
184+
185+
/**
186+
* Parses a polymorphism marker (as written by .NET `ByteTypeSerializer`) at
187+
* `offset`. The msgpack6 format prefixes every *polymorphic* argument/result
188+
* with a 2-byte little-endian length:
189+
* - 0 → expected (non-polymorphic) type — the `\0\0` fast path
190+
* - 1 → NullValue (the value itself is absent)
191+
* - >= 2 → a derived type (2-byte length + 2-byte hash + UTF-8 name)
192+
*
193+
* Returns how many bytes the marker occupies and whether the value is a
194+
* NullValue. Derived-type markers are unsupported by the TS client and throw a
195+
* clear error instead of letting the marker bytes corrupt the msgpack frame.
196+
*/
197+
export function readPolymorphismMarker(
198+
data: Uint8Array,
199+
offset: number
200+
): { bytesRead: number; isNull: boolean } {
201+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
202+
const length = view.getUint16(offset, true);
203+
if (length === 0)
204+
return { bytesRead: 2, isNull: false };
205+
if (length === 1)
206+
return { bytesRead: 2, isNull: true };
207+
208+
throw new Error('polymorphic payloads are not supported by the TS client');
209+
}
210+
171211
function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
172212
let totalLength = 0;
173213
for (const arr of arrays) totalLength += arr.length;
@@ -290,22 +330,11 @@ export function deserializeBinaryMessage(
290330
const method = textDecoder.decode(methodBytes);
291331
pos += methodLen.value;
292332

293-
// ArgData length as fixed 4-byte LE
333+
// ArgData length as fixed 4-byte LE, immediately followed by argData (V5
334+
// writes headers AFTER the argument data — see RpcByteMessageSerializerV5).
294335
const argDataLen = view.getInt32(pos, true);
295336
pos += 4;
296337

297-
// Skip headers (if any — TS client doesn't use them)
298-
if (headerCount > 0) {
299-
for (let h = 0; h < headerCount; h++) {
300-
// L1Memory: 1-byte length prefix + key bytes
301-
const keyLen = data[pos++];
302-
pos += keyLen;
303-
// LVarSpan: VarUint length + value bytes
304-
const valLen = readVarUint(data, pos);
305-
pos += valLen.bytesRead + valLen.value;
306-
}
307-
}
308-
309338
// Deserialize arguments from argData — multiple concatenated MessagePack values
310339
const args: unknown[] = [];
311340
const argEnd = pos + argDataLen;
@@ -317,8 +346,13 @@ export function deserializeBinaryMessage(
317346
for (const decoded of decoder.decodeMulti(argSlice)) {
318347
args.push(decoded);
319348
}
320-
pos = argEnd;
321349
}
350+
pos = argEnd;
351+
352+
// Headers (if any) follow the argument data. The TS client ignores their
353+
// content but must skip them so `bytesRead` covers the whole message.
354+
if (headerCount > 0)
355+
pos = skipHeaders(data, pos, headerCount);
322356

323357
const message: RpcMessage = {
324358
Method: method,
@@ -470,20 +504,11 @@ export function deserializeCompactBinaryMessage(
470504
registry?.getName(methodHash | 0) ??
471505
`<hash:0x${methodHash.toString(16).padStart(8, '0')}>`;
472506

473-
// ArgData length as fixed 4-byte LE
507+
// ArgData length as fixed 4-byte LE, immediately followed by argData
508+
// (V5Compact writes headers AFTER the argument data, same as V5).
474509
const argDataLen = view.getInt32(pos, true);
475510
pos += 4;
476511

477-
// Skip headers (if any)
478-
if (headerCount > 0) {
479-
for (let h = 0; h < headerCount; h++) {
480-
const keyLen = data[pos++];
481-
pos += keyLen;
482-
const valLen = readVarUint(data, pos);
483-
pos += valLen.bytesRead + valLen.value;
484-
}
485-
}
486-
487512
// Deserialize arguments
488513
const args: unknown[] = [];
489514
const argEnd = pos + argDataLen;
@@ -492,8 +517,12 @@ export function deserializeCompactBinaryMessage(
492517
for (const decoded of decoder.decodeMulti(argSlice)) {
493518
args.push(decoded);
494519
}
495-
pos = argEnd;
496520
}
521+
pos = argEnd;
522+
523+
// Headers (if any) follow the argument data.
524+
if (headerCount > 0)
525+
pos = skipHeaders(data, pos, headerCount);
497526

498527
const message: RpcMessage = {
499528
Method: method,

ts/packages/rpc/src/rpc-stream-sender.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { PromiseSource, RingBuffer } from '@actuallab/core';
22
import { getLogs } from './logging.js';
3+
import { toExceptionInfo } from './rpc-error.js';
34
import type { RpcObjectId, IRpcObject } from './rpc-object.js';
45
import { RpcObjectKind } from './rpc-object.js';
56
import type { RpcPeer } from './rpc-peer.js';
@@ -212,7 +213,7 @@ export class RpcStreamSender<T> implements IRpcObject {
212213
// .NET ExceptionInfo is a non-nullable value type, so we must always
213214
// emit a valid map shape (empty TypeRef+Message for the "no error" case).
214215
const errorInfo = error
215-
? { TypeRef: 'System.Exception', Message: error.message }
216+
? toExceptionInfo(error)
216217
: { TypeRef: '', Message: '' };
217218
this.peer.hub.systemCallSender.end(
218219
conn, this.peer.serializationFormat, this.id.localId, this._nextIndex, errorInfo,

0 commit comments

Comments
 (0)