WASM Shadow Stack

This page explains exactly when the direct WASM backend uses the shadow stack, how frame layout is computed, and how out-slot returns interact with it.

What It Is

RukaLang's direct WASM backend uses a per-call shadow stack frame for aggregate values that should not be heap-allocated for temporary use.

The compile-time decisions are encoded in:

The runtime ABI symbols used to reserve/release the frame are:

Exactly When It Is Used

A local is assigned shadow-stack storage when all of the following are true:

  1. The local is not a function parameter.
  2. Either:
    • the local is a value local whose type is one of Tuple, Struct, or Slice, or
    • the local is a slice place local (RefRo<Slice<_>> or RefMut<Slice<_>>).

This selection logic is implemented by should_shadow_stack_local and is_shadow_stack_aggregate_ty.

Frame Layout and Prologue

Frame construction happens in lower_function.

For each selected local:

  1. Payload bytes are computed via aggregate_payload_bytes.
  2. Slot size is align_up(ARRAY_DATA_OFFSET + payload_bytes, 8).
  3. Offsets are assigned in declaration order, each starting at 8-byte alignment.

After all slots are sized:

  • If frame size is zero, no runtime shadow-stack calls are emitted.
  • If frame size is non-zero, function entry emits one reserve call to __ruka_rt::shadow_stack_reserve(frame_bytes).
  • The returned base pointer is kept in a scratch local.
  • Each shadow local is initialized to frame_base + local_offset.

How Instructions Use Shadow-Stack Locals

Aggregate-producing instructions check whether dst is shadow-backed:

  • lower_aggregate_instr skips heap allocation for tuple/struct/slice destinations and requires those destinations to be shadow-backed.
  • The instruction then writes aggregate fields directly through the local pointer.

For call destinations:

  • lower_call_family_instr checks whether the destination local is shadow-backed and requires out-slot destinations to be shadow-backed.

Out-Slot Returns and Caller/Callee Behavior

Return-type decision:

  • function_returns_via_out_slot currently returns true for tuple/struct/slice return types.
  • Borrowed/reference returns are rejected before signature planning.

Signature shaping:

  • signature_types inserts an i32 out-slot pointer parameter at index 0 when return-via-out-slot is required.
  • In that case, the WASM result list is empty.

Call-site behavior:

  • Caller passes destination pointer as arg 0.
  • If destination local is shadow-backed, that pointer is reused.
  • If destination local is not shadow-backed, lowering fails instead of heap-allocating implicit out-slot storage.

Return behavior:

  • lower_terminator handles Return.
  • For out-slot returns, it copies return bytes from the local storage pointer to the out-slot pointer.
  • For non-out-slot returns, it pushes the value as a normal WASM result.

Release and Lifetime Rules

At every emitted Return path in lower_terminator:

  • If the function reserved a non-zero frame, the backend emits one call to __ruka_rt::shadow_stack_release(frame_bytes) before return.

This gives function-scoped shadow-stack lifetimes:

  • Reserve once in function entry.
  • Reuse slots for all selected locals in that function.
  • Release once on each return path.

Runtime Side Notes

The runtime reserve/release behavior itself is implemented in the wasm32-only runtime module source:

That module currently:

  • lazily allocates one backing region,
  • bumps a shadow-stack pointer on reserve,
  • checks overflow/underflow with assertions,
  • and rewinds the pointer on release.