Skip to content

Fix TS Result serializer using the ok payload type for err - #5972

Open
captain-mirage wants to merge 1 commit into
clockworklabs:masterfrom
captain-mirage:fix/ts-result-err-serializer
Open

captain-mirage wants to merge 1 commit into
clockworklabs:masterfrom
captain-mirage:fix/ts-result-err-serializer

Conversation

@captain-mirage

Copy link
Copy Markdown

Description of Changes

SumType.makeSerializer's Result fast path builds the err serializer from the
ok variant's type:

crates/bindings-typescript/src/lib/algebraic_type.ts:655-662 (on 1906706)

const serializeOk = AlgebraicType.makeSerializer(
  ty.variants[0].algebraicType,
  typespace
);
const serializeErr = AlgebraicType.makeSerializer(
  ty.variants[0].algebraicType,   // <- should be variants[1]
  typespace
);

The matching makeDeserializer already reads err with ty.variants[1].algebraicType
(algebraic_type.ts:764-771), so the two halves of the codec disagree: a
Result<Ok, Err> whose Err payload type differs from its Ok payload type is
written with the ok encoder and read with the err decoder.

Minimal reproduction:

import { AlgebraicType, BinaryReader, BinaryWriter, Result } from 'spacetimedb';

const ty = Result.getAlgebraicType(AlgebraicType.U8, AlgebraicType.String);
const w = new BinaryWriter(1024);
AlgebraicType.serializeValue(w, ty, { err: 'boom' });

w.getBuffer();
// actual:   Uint8Array [ 1, 0 ]
// expected: Uint8Array [ 1, 4, 0, 0, 0, 98, 111, 111, 109 ]

AlgebraicType.deserializeValue(new BinaryReader(w.getBuffer()), ty);
// RangeError: Offset is outside the bounds of the DataView

Nothing throws at encode time, which is what makes this unpleasant. 'boom' goes through
writeU8, coerces to NaN, and lands as the single byte 0x00; the payload is simply
gone. The decode side then reads a u32 string length out of bytes that were never
written. With a product Err it is quieter still: for
Result<string, { code: i32 }>, { err: { code: 42 } } is encoded as the UTF-8 string
"[object Object]" (15 bytes) and decodes without complaint into whatever those bytes
happen to mean as an i32.

Results whose ok and err types coincide are unaffected, which is presumably why this
has survived: the two serializers are then the same function. Mixed-type Results are not
exotic, though — modules/sdk-test-ts/src/index.ts:422-453 declares six tables built on
them (resultI32String is t.result(t.i32(), t.string()), resultStringI32 is the
mirror image, plus Result<Identity, string>, Result<SimpleEnum, i32>,
Result<EveryPrimitiveStruct, string> and Result<Vec<i32>, string>), matching the Rust,
C# and C++ sdk-test modules. A TypeScript module inserting an err value into any of
those tables writes a row that this library's own reader cannot decode. The client-api
protocol has the same shape in OneOffQueryResult.result
(src/sdk/client_api/types.ts:80, Result<QueryRows, string>); that one only travels
server → client, so it is decode-only on this side and is not corrupted today.

The fix is one character — use ty.variants[1].algebraicType for serializeErr, matching
the deserializer.

I checked for a second instance of the same slip. Besides line 660, variants[0] appears
eight times in algebraic_type.ts and all eight are right: 635/639 and 742/746 are the
Option fast path, whose payload genuinely is variant 0; 652 and 761 are the 'ok' name
guards; 656 and 765 are the ok serializer and deserializer themselves.
src/server/views.ts:303 also indexes variants[0], but that is the Option-returning
view path. The C# and Rust bindings derive Result BSATN per concrete type at codegen
time rather than from a runtime variant list, so they have no analogous site;
crates/bindings-cpp matches on variants[0].name == "ok" only to recognise a
Result, and reads each variant's own type thereafter.

API and ABI breaking changes

None. The wire format is unchanged — this makes the encoder produce the BSATN that the
existing decoder, the Rust/C# implementations and the SATS spec already define for a
Result sum. The only observable difference is that err payloads whose type differs
from the ok payload's stop being corrupted.

If anyone has worked around this by pre-encoding err values to match the ok type, that
workaround will now double-encode; I would be surprised if such code exists, since the
corrupted bytes are not decodable by this library's own reader.

Rollback safety impact

n/a — no ControlDB table or reducer, system table, or on-disk format is written, changed,
or made unsupported by this PR.

Expected complexity level and risk

  1. One index change in one function, plus three round-trip tests. The Result fast path is
    self-contained; the Option fast path above it and the generic sum path below it are
    untouched.

Testing

  • Added three round-trip tests to crates/bindings-typescript/tests/serde.test.ts, in
    the existing "it correctly serializes and deserializes algebraic values" style
    (explicit expected byte arrays, then a decode back to the original value):
    Result<u8, string> with an ok payload (the case that already worked, kept as a
    regression guard), the same type with an err payload, and
    Result<string, { code: i32 }> with a product err payload.
  • Confirmed the two new err tests fail on master before the change — [1, 0]
    instead of [1, 4, 0, 0, 0, 98, 111, 111, 109], and the "[object Object]"
    encoding for the product case — and pass after it.
  • pnpm test in crates/bindings-typescript: 30 files, 319 tests, all passing.
  • pnpm build (tsup + tsc -p tsconfig.build.json) and pnpm lint
    (eslint . + prettier . --check) clean.
  • Reviewer: the Result tables in modules/sdk-test-ts look like the natural place
    for end-to-end coverage of the err direction across the SDK test suite — happy to
    add it here if you'd like it in the same PR, but it needs a running host so I left it
    out.
  • Reviewer: a sanity check that no deployed module has persisted rows encoded with the
    old, wrong bytes. Those rows are not decodable by the current reader either, so I
    believe there is nothing to migrate, but you have visibility I don't.

`SumType.makeSerializer`'s Result fast path built both the `ok` and the
`err` serializer from `ty.variants[0].algebraicType`, so a `Result<Ok, Err>`
whose `Err` payload type differs from `Ok` was written with the `ok`
encoder. `makeDeserializer` already reads `err` with `variants[1]`, so the
two halves disagree: the value is encoded as `Ok` and decoded as `Err`.

The failure is silent whenever the wrong encoder still produces bytes the
`err` decoder accepts. For `Result<u8, string>`, `{ err: 'boom' }` encodes
as the single byte `0x00` (a `string` coerced through `writeU8`); for a
`Result<string, { code: i32 }>`, `{ err: { code: 42 } }` encodes as the
UTF-8 string `"[object Object]"`. Nothing throws at encode time; the
corruption surfaces later as a wrong value or a confusing decode error.

Use `variants[1].algebraicType` for `serializeErr`, matching the
deserializer.
@CLAassistant

CLAassistant commented Sep 22, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

This branch has not been deployed

No deployments
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.

2 participants