Skip to content

Poison component instances when cabi_realloc traps during argument lowering #1

Description

@JairusSW

Problem

Instance.invokeEntered can execute guest code while lowering string/list/spilled arguments through the canonical ABI realloc function, but an error from that path returns without poisoning the component instance.

Current flow:

  1. invokeEntered builds realloc := cachedReallocOf(ctx, be).
  2. lowerParams may call the guest's configured realloc while writing arguments into guest memory.
  3. Any resulting error is returned directly:
coreArgs, err := in.lowerParams(be, args, mem, memAvailable, realloc, exportName, *coreArgsPtr)
if err != nil {
    coreValueSlicePool.Put(coreArgsPtr)
    return nil, err
}

Only failures from be.coreFn.CallWithStack and be.postReturnFn.CallWithStack set in.poisoned = true. The nearby comment therefore incorrectly treats those as the only two places guest code runs; the code immediately above also notes that guest cabi_realloc can run during lowering.

Relevant code:

return err
}
// invokeEntered is invoke's actual call body, split out purely for
// readability at the poisoning boundary (see the two explicit in.poisoned =
// true sites below, at the ONLY two points this export's own guest code
// actually runs: be.coreFn.CallWithStack and be.postReturnFn.CallWithStack).
//
// Deliberately NOT poisoning on every error here (an earlier version used a
// single defer to poison on ANY non-nil err, which is closer to the
// reference's literal try/except scope around the whole call but proved too
// broad in practice: real_resource_test.go's TestRealResource calls
// [method]counter.get on a handle it just dropped, EXPECTS that one call to
// fail (lowerParams -> resolveArgHandles rejects the stale handle before
// core code ever runs), and then keeps calling OTHER, still-valid handles on
// the SAME instance -- broad poisoning permanently broke every later call.
// A host-side ABI/argument validation failure (lowerParams, resolveArgHandles,
// the coreArgs-count static check, liftResult) never actually enters guest
// code, so -- unlike a real trap escaping a CallWithStack -- it must not
// poison; matches builtin-trap-poisons-instance's own two poisoning cases
// (an `unreachable` and a busy-stream host-builtin trap), both of which
// surface AS be.coreFn.CallWithStack failing, so this narrower rule still
// covers everything that suite (or the spec) requires here.
func (in *Instance) invokeEntered(ctx context.Context, be *boundExport, exportName string, args []abi.Value) ([]abi.Value, error) {
if in.syncTaskNeeded {
// The reference's canon_lift constructs a Task for EVERY call,
// including a not-opts.async_ sync lift (definitions.py:2144-2202);
// current_task() always resolves (:315-316). Install/restore around
// the whole body (before lowerParams: a guest cabi_realloc invoked
// during lowering may legally call context.get) so a nested
// guest->guest sync call on this same Instance
// (invokeEntered -> delegatingHostImport.fn -> invoke ->
// invokeEntered) still resolves the innermost task, and the caller's
// task is restored on return -- including through the two poisoning
// error returns below, via defer.
t := &task{inst: in, be: be, state: taskStarted, syncImplicit: true}
prevActive, prevBase := in.activeTask, in.syncBase
in.activeTask, in.syncBase = t, t
defer func() { in.activeTask, in.syncBase = prevActive, prevBase }()
}
mem, memAvailable := memoryBytesOf(be.mod)
realloc := cachedReallocOf(ctx, be)
// coreArgsPtr's buffer, like stack below, is pure scratch local to this
// call (lowerParams only ever appends into it; nothing downstream of
// invoke retains it), so it's fetched from the pool empty (len 0, cap
// from a prior call) and handed to lowerParams to append into directly,
// instead of lowerParams allocating its own backing array.
coreArgsPtr := coreValueSlicePool.Get().(*[]abi.CoreValue)
*coreArgsPtr = (*coreArgsPtr)[:0]
coreArgs, err := in.lowerParams(be, args, mem, memAvailable, realloc, exportName, *coreArgsPtr)
if err != nil {
coreValueSlicePool.Put(coreArgsPtr)
return nil, err
}
*coreArgsPtr = coreArgs
if len(coreArgs) != len(be.coreParamsWant) {
putCoreValueSlice(coreArgsPtr)
return nil, fmt.Errorf("component/instance: export %q: parameter list flattens to %d core value(s) but the core signature expects %d; whole-parameter-list spilling to memory is not supported by this milestone", exportName, len(coreArgs), len(be.coreParamsWant))
}
// stack is pure scratch: it only exists to hand coreArgs' bits to
// be.coreFn.Call as a []uint64, and the native engine's callEngine.Call
// copies params into its own buffer before doing anything else (see

Impact

A component can expose an export taking a string/list (or otherwise requiring memory lowering), have its guest realloc trap, and then remain enterable. A later export call proceeds even though a guest trap escaped the instance. That violates the same sticky Store-poisoning invariant enforced for the main core call and post-return.

This is distinct from host-side argument validation failures: stale handles, malformed values, and flattening errors should remain non-poisoning. Only a failure after actually entering the guest realloc needs to poison.

Suggested fix

Route all guest core-function execution through one helper that marks the owning instance poisoned when a guest call traps, including:

  • the main lifted core function;
  • post-return;
  • canonical ABI realloc calls made during lowering.

Alternatively, make the realloc adapter return a distinguishable guest-execution error and set in.poisoned before propagating it.

Regression test

Add a component with:

  • an exported function taking a string;
  • a canonical realloc that executes unreachable;
  • a second otherwise-valid export.

After the first call traps during argument lowering, invoking the second export should fail with cannot enter component instance rather than entering guest code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions