Skip to content

Silent value corruption when an importObject host returns {tag:'ok',val} instead of the raw value for result<T,E> #1668

Description

@zacharywhitley

Summary

When providing host implementations for imported result<T, E> functions (via transpile --instantiation's importObject, and equally via the runtime/RuntimeBindgen path), the host is expected to return the raw success value and throw { payload: <E> } for the error case — jco wraps the return as the ok variant itself.

If a host function instead returns the lifted result shape { tag: 'ok', val } (an easy mistake — that's the shape jco produces on the guest-export side, and the shape some shims/plugins use), jco double-wraps it and then silently produces corrupt values, with no error at the point of the mistake.

What happens

The generated import trampoline does roughly:

try {
  ret = { tag: 'ok', val: hostFn(...) };   // <-- jco wraps the host return
} catch (e) {
  ret = { tag: 'err', val: getErrorPayload(e) };
}
// ...then lowers ret.val as T

So if hostFn returns { tag: 'ok', val: realValue }, then ret.val is the whole { tag, val } object, and lowering reads T's fields off that wrapper. For a record T, every field resolves to undefined and is silently coerced to 0 / NaN. No error is raised — the guest just receives an all-zero record.

The failure only surfaces later, if some field is a type whose coercion throws — e.g. a u64 field hits BigInt([object Object])SyntaxError: Cannot convert [object Object] to a BigInt, with a stack trace far from the actual mistake.

Why it's a footgun

{ tag: 'ok', val } is a natural thing to return because it's exactly how jco represents a result elsewhere (variants, guest exports). There's no validation distinguishing "host returned the value {tag,val}" from "host returned the wrapper", so a one-character convention mismatch becomes silent data corruption that's extremely hard to trace.

Concretely, I hit this with a record handle record { region-id: u16, generation: u16, offset: u32 } returned from an import. It reached the guest all-zero (every field 0) with no error, and I spent a long time misattributing it to record/list marshalling before finding the double-wrap.

Minimal repro (sketch)

interface m {
  record point { x: u32, y: u32 }
  get: func() -> result<point, string>;
}
// importObject host — WRONG (double-wraps): guest sees { x: 0, y: 0 }
{ 'pkg:m/m': { get: () => ({ tag: 'ok', val: { x: 1, y: 2 } }) } }
// CORRECT: return the value, throw for err
{ 'pkg:m/m': { get: () => ({ x: 1, y: 2 }) } }

The wrong version produces zeros with no error.

Suggested fix

Either of:

  1. In the generated import trampoline (at least under a dev/debug build), detect when a host returns an object carrying a tag property for a result-typed import and throw a clear error pointing at the convention.
  2. Document this convention prominently for importObject hosts (return value directly + throw for err), explicitly calling out that returning { tag, val } corrupts silently.

Even a one-line dev-mode assertion would have saved hours here.

Environment

  • jco 1.24.1
  • Reproduces with --instantiation async and --instantiation sync, and via the runtime bindgen path.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions