Silk

Effect suspension and stack-safe recursion

Effect.suspend is an explicit stack-safety boundary for Effect execution. It transfers one deferred child through private execution machinery so a covered recursive Effect cycle can use bounded native and WebAssembly machine stack.

Suspension is not async execution. It does not park a task, let another task run, wait for a wakeup, or introduce a scheduler. It also does not make ordinary recursion stack safe automatically.

Explicit owner-controlled parking and later resumption are defined separately by independently resumable Effect executions. Allocation-backed dynamic local state shared by those owners is defined by local shared ownership. The current source-level scheduler and Fiber policy built over that substrate is defined by single-threaded schedulers and Fibers.

The intended public contract is:

effect fn suspend<A, E, ?R>(
  deferred: once Effect<A ! E ? R>
) -> A ! E ? R

The compiler and standard library preserve that contract exactly. Coroutine frames use private execution-stack storage; they never select a source Allocator or add OutOfMemoryError to an Effect.

Rule numbers follow the accepted proposal. SUSP-009, SUSP-010, SUSP-012, and SUSP-014 constrain private lowering rather than programmer-observable Silk, so their details remain in the proposal instead of appearing as standalone language-reference rules.

Terms

  • Deferred child — the owned Effect passed to Effect.suspend. Constructing the suspension does not execute the child body.
  • Suspension origin — the explicit Effect.suspend boundary that transfers execution.
  • Suspendable caller — a concrete Effect execution path that can reach a suspension origin. It may still complete along another branch without suspending.
  • Resume — continue the logical caller with the child's success or typed failure. It does not imply a thread, task, or scheduler.
  • Execution stack — finite compiler-owned storage for logically active suspended calls. It is distinct from the source-selected Allocator service and from the physical machine stack.
  • Parking — leave execution dormant until an external wakeup condition occurs. Effect.suspend does not provide parking.
  • Independent Execution — an affine lazy Effect package with an owner outside its body. The owner selects each legal activation.
  • Wake — one affine authority for one parked generation. Signaling or dropping it consumes that authority.

Static execution facts

Semantic inspection records one normalized suspension summary for every reachable exact executable. Direct execution has no mode bit. NestedTransfer means the selected call graph can reach Effect.suspend; ExternalPark is reserved for the sealed external-wake primitive. Complete specializations include ordinary helper calls and selected provider implementations. An unresolved generic executable conservatively retains the modes permitted by its declared exact bound, while an unavailable executable remains unavailable rather than being reported as direct.

This is a target-neutral semantic substrate. The ordinary silk.execution module supplies safe construction, drive, and park operations over sealed compiler identities. An explicit Execution construction is the propagation delimiter. Its erased body keeps its complete summary. An ordinary owner-side drive caller does not inherit the body's ExternalPark mode.

Two compiler-owned properties may refine one exact Effect or callable representation bound:

  • Intrinsic.Detached proves that the executable retains no external lexical or provider loan. It is independent of the success and failure payload spellings and of execution affinity; an owned local shared handle may be detached while remaining local.
  • Intrinsic.NonParking proves that the specialized transitive graph cannot reach external parking. Nested transfer remains allowed.

These properties are static, witness-free obligations rather than interfaces or services. A failed concrete application reports the property obligation at the application and retains its stable capture/provider or reachability path.

Intrinsic.Execution<A> is an opaque affine, non-copyable, initially non-thread-transferable local identity. Its logical lifecycle is Initial, InitialReady, Running, Dormant, Notifying, Eligible, Completed, and Destroyed; these are semantic states, not a promised backend tag layout. The owner may drive only Initial, InitialReady, or Eligible, and driving a dormant or notifying execution is a fatal intrinsic-state trap. Execution-internal stable loans may cross parking, but construction cannot retain caller loans and completion cannot return a loan into package-owned storage.

Independent execution and external parking

This section summarizes the relationship to suspension. The complete public lifecycle, Wake, cancellation, ownership, and scheduling-policy rules are in independently resumable Effect executions.

Execution.make allocates one combined package through the caller-selected Allocator. The package owns the lazy body and its fixed readiness endpoint. Construction returns an Initial Execution and does not start the body. Package allocation refusal is a typed construction failure. Later growth of the private execution stack is a fatal trap outside the typed failure channel.

The owner may call Execution.notifyInitial after storing an Initial Execution. This changes it to InitialReady and invokes the fixed readiness endpoint without starting the body. The owner calls Execution.drive for an Initial, InitialReady, or Eligible activation. A nested Effect.suspend transfers directly to a child and can return during the same drive. Execution.park instead relinquishes the running Execution. Its registration callback receives one affine Wake for that parked generation. Signaling the Wake makes the Execution eligible and invokes its fixed readiness endpoint at most one time.

Dropping a dormant Execution cancels its Wake and cleans the suspended values exactly once. If an external owner still retains that Wake, the Wake keeps the complete inert package allocation alive. Signaling or dropping the cancelled Wake releases the final package authority and cannot publish readiness.

Schedulers, deferred values, timers, coroutine ports, ready queues, and cancellation policies are ordinary source actors built over this narrow seam. The shipped silk.scheduler, silk.fiber, and silk.local_scheduler modules follow this rule: the language does not select those actors, and the compiler does not recognize their source names.

Public contract and recursion

SUSP-001 — Suspension is an explicit boundary

Status: Confirmed

Silk makes an Effect execution path stack safe only where source explicitly calls Effect.suspend. The compiler does not insert suspension boundaries automatically.

effect fn count(value: i32) -> i32 {
  if value == 0 {
    return 0
  }
  let inner = run Effect.suspend(count(value - 1))
  return inner + 1
}

Boundary: Merely declaring effect fn or calling a recursive Effect does not request the bounded-machine-stack path.

Diagnostics: A valid explicit boundary produces no diagnostic. Missing boundaries remain valid under SUSP-019 rather than becoming a compiler error.

Evidence: sealed intrinsic boundary, suspension implementation contract.

SUSP-002 — Running a suspended Effect produces the child's ordinary outcome

Status: Confirmed

Running Effect.suspend(child) eventually produces the child's one success value or typed failure. It does not expose a nested Effect, pending result, continuation, or resume token.

let value = run Effect.suspend(effect { return 42 })

value is i32, not Effect<i32> or a continuation handle.

Boundary: Suspension does not flatten an Effect returned as the child's declared success value. If the child succeeds with Effect<i32>, one run still produces that nested Effect value under the ordinary one-layer rule.

Diagnostics: Incompatible use of the child's success or failure receives the ordinary Effect, return, or type diagnostic.

Evidence: one-layer execution.

SUSP-003 — Every recursive cycle must cross suspension for bounded machine stack

Status: Confirmed

A terminating self-recursive or mutually recursive Effect graph uses bounded native and Wasm machine stack when every possible recursive cycle crosses an explicit suspension origin.

effect fn even(value: i32) -> bool {
  if value == 0 {
    return true
  }
  return run odd(value - 1)
}

effect fn odd(value: i32) -> bool {
  if value == 0 {
    return false
  }
  return run Effect.suspend(even(value - 1))
}

The mutual cycle crosses the boundary in odd; every individual call edge need not suspend.

Boundary: A suspension on an unrelated branch does not cover a recursive cycle that can avoid that branch.

Diagnostics: The compiler does not reject an uncovered cycle. The language service may explain which cycle lacks a boundary under SUSP-019.

Evidence: suspendability analysis.

SUSP-004 — Suspension does not cover unrelated recursion

Status: Confirmed

Effect.suspend gives no stack-safety guarantee to ordinary function recursion, Effect recursion whose cycle does not cross the boundary, recursive Drop, or arbitrary recursive data traversal.

effect fn count(value: i32) -> i32 {
  if value == 0 {
    return 0
  }
  return 1 + run count(value - 1)
}

Boundary: A target may happen to optimize a particular recursive call. That optimization does not become a Silk guarantee and cannot be relied on for correctness.

Diagnostics: These recursive programs remain valid and receive no mandatory compiler diagnostic solely because their depth is unbounded.

Evidence: suspendability analysis.

SUSP-005 — Suspension preserves success, failure, and requirement channels exactly

Status: Confirmed

For Effect.suspend<A, E, ?R>, the result keeps exactly A, E, and R. Suspension adds no allocation error or allocator service to the public contract.

effect fn protected() -> i32 ! ProblemError ? &Clock {
  return run Effect.suspend(work())
}

If work() has i32 ! ProblemError ? &Clock, protected has the same three channels.

Boundary: Exhausting private execution storage is a fatal trap under SUSP-006, not a hidden member of E.

Diagnostics: Effect.suspend produces no allocator-provision or storage-failure diagnostic. Existing child-channel mismatches retain their ordinary codes.

Evidence: Effect channels, suspension composition tests.

SUSP-006 — Execution-stack exhaustion is a fatal trap

Status: Confirmed

Exhausting the finite compiler-owned execution stack terminates with a fatal trap outside the typed failure channel, like exhausting the ordinary machine stack.

Boundary: Effect.catch, catchAll, result, or another typed-failure combinator cannot recover execution-stack exhaustion. Execution.make and the source-level Fiber scheduler have their own declared allocation and publication failures while constructing owned execution packages, but later growth of the compiler-private execution stack remains fatal and does not change Effect.suspend.

Diagnostics: No failure member or requirement is inferred. A reached exhaustion reports a fatal runtime trap according to the program-termination rules.

Evidence: fatal traps, execution-storage requirements.

Execution and composition

SUSP-007 — The child starts only after a complete transfer

Status: Confirmed

Constructing Effect.suspend(child) does not execute the child. When the suspended Effect is run, the current invocation first completes the state and ownership transition required to resume later; only then may the child begin.

let deferred = Effect.suspend(effect {
  return observe()
})

observe() does not run until deferred is run.

Boundary: The child cannot observe a half-moved parent state or begin before live parent values have one valid owner for later resumption.

Diagnostics: Premature construction causes no runtime work. Invalid captures or transfers receive their ordinary ownership diagnostic.

Evidence: Effect construction, Effect suspension standard-library tests.

SUSP-008 — Ordinary combinators are suspension-transparent

Status: Confirmed

Ordinary source-defined Effect combinators compose with suspendable children without inspecting a pending state or changing their public signatures.

fn increment(value: i32) -> i32 {
  return value + 1
}

let deferred = Effect.suspend(effect { return 41 })
let answer = run (move deferred |> Effect.map(increment))

answer is 42; Effect.map need not expose continuation machinery.

Boundary: A combinator still obeys its ordinary ownership, failure, and requirement contract. Suspension transparency does not authorize it to duplicate or retain a take-once child.

Diagnostics: Invalid composition receives ordinary callable, Effect-channel, or ownership diagnostics rather than a suspension-specific error.

Evidence: Effect composition, suspension composition tests.

Ownership and lifecycle

SUSP-011 — Ordinary ownership and loans continue across suspension

Status: Confirmed

Copy values may copy, affine values move into one later-execution owner, and shared or exclusive loans retain their ordinary access and lifetime rules while the logical caller is suspended. A referent borrowed across suspension keeps a stable logical location for the loan's lifetime.

effect fn inspect(value: &Record) -> i32 {
  return run Effect.suspend(effect { return value.count })
}

The shared loan remains a shared loan for the complete suspended call; suspension neither consumes the referent nor permits overlapping mutation.

Boundary: Suspension introduces no public Pin type or permission to move a borrowed referent. The compiler's private representation must adapt to source-valid borrows, not invalidate them.

Diagnostics: Invalid duplication, movement, overlapping access, or escaping loans report the ordinary ownership and borrowing diagnostics at the responsible source operation.

Evidence: ownership rules, suspension ownership tests.

SUSP-013 — Suspension preserves exact structured cleanup

Status: Confirmed

On success or typed failure, every value retained across suspension is moved onward or cleaned exactly once. A fatal trap retains Silk's general no-unwind and no-cleanup guarantee.

effect fn useOwned(resource: Resource) -> () ! UseError {
  run Effect.suspend(effect { consume(move resource) })
}

resource has one owner throughout the transfer and is consumed or cleaned exactly once.

Boundary: Suspension does not cause Drop, ensuring, or other cleanup to run after a trap. It also cannot skip ordinary structured cleanup merely because execution resumed through a driver.

Diagnostics: Invalid source cleanup ownership receives its ordinary diagnostic. No additional cleanup operation appears in source merely because a function is suspendable.

Evidence: Effect lifecycle, trap cleanup.

SUSP-015 — Suspension creates no special allocator implementation rule

Status: Confirmed

Because Effect.suspend does not select or call a source allocator, it imposes no special recursion, bootstrap, or self-hosting restriction on ordinary Allocator implementations.

struct SuspendingAllocator {}

effect fn allocate(
  self: &mut SuspendingAllocator,
  layout: Layout
) -> Allocation ! OutOfMemoryError {
  return run Effect.suspend(effect {
    return run Intrinsic.systemAllocationAcquire(move layout)
  })
}

impl Allocator for SuspendingAllocator {
  allocate: SuspendingAllocator.allocate
}

If this implementation satisfies the ordinary Allocator contract, its use of suspension adds no extra conformance rule.

Boundary: An allocator operation may independently be recursive or effectful and follows its own declared contract. Execution.make and Fiber task preparation define their fallible allocation contracts separately; those failures belong to owned execution construction, not to Effect.suspend.

Diagnostics: No allocator-specific conformance or recursion diagnostic applies merely because an allocator is reachable from suspendable code.

Evidence: service rules, allocator-independence requirements.

Engines, limits, and tooling

SUSP-016 — Suspended invocations still count toward logical CallDepth

Status: Confirmed

An invocation remains a live logical source call while its child runs through suspension. It therefore continues to count toward the evaluator's CallDepth limit. Compiler-generated helpers do not add logical calls.

let result = evaluate(count(10_000), limits: { CallDepth: 100 })

Suspension can keep the physical machine stack bounded without making this logical depth smaller.

Boundary: Logical depth is not physical JavaScript, native, or Wasm stack depth. Suspension may bound the physical stack while the logical depth continues growing honestly.

Diagnostics: Reaching an evaluator CallDepth limit reports the existing deterministic evaluation-limit outcome and the active logical source frames, not private helper frames.

Evidence: evaluation limits, suspension evaluation tests.

SUSP-017 — Evaluation, native, and Wasm preserve the same semantics

Status: Confirmed

The evaluator, native execution, and direct Wasm produce the same typed outcome, retained ownership, and cleanup order for a suspended program. Native and Wasm additionally guarantee bounded machine stack for cycles covered by SUSP-003.

For the count example in SUSP-001, every engine must produce the same integer or typed failure; engine-specific execution machinery cannot become part of that result.

Boundary: Engines may use different private execution representations or storage growth policies. Those differences cannot change source-visible results or cleanup.

Diagnostics: A valid program receives no engine-selection diagnostic. A target that cannot honor the suspension contract is unavailable for that reachable executable closure.

Evidence: target availability, native suspension tests, Wasm suspension tests.

SUSP-018 — Non-suspending call graphs pay no coroutine cost

Status: Confirmed

A closed executable call graph that cannot reach the suspension intrinsic contains no coroutine frame transformation, private suspension driver, pending branch, scheduler, or widened Effect channels merely because another program can suspend.

pub fn main() -> i32 {
  return 42
}

This program acquires no suspension machinery.

Boundary: A graph that can reach suspension may need a suspendable private path even when one runtime branch completes before reaching the boundary.

Diagnostics: No source diagnostic applies. Artifact inspection and pay-for-use tests verify the absence of unreachable machinery.

Evidence: pay-for-use runtime rule, suspension MIR tests.

SUSP-019 — Uncovered recursive Effects remain valid

Status: Confirmed

A recursive Effect cycle without Effect.suspend is valid Silk. The compiler does not reject it merely because sufficiently deep execution may exhaust the target machine stack.

effect fn count(value: i32) -> i32 {
  if value == 0 { return 0 }
  return 1 + run count(value - 1)
}

Boundary: The language service may warn when it finds an uncovered cycle and offer to insert an explicit suspension boundary. That assistance cannot silently edit source or claim every execution will overflow.

Diagnostics: No mandatory compiler diagnostic applies. Any LSP warning is non-blocking and must identify the uncovered cycle and the explicit nature of the suggested change.

Evidence: suspendability analysis.

SUSP-020 — Suspension promises no async or scheduler behavior

Status: Confirmed

Effect.suspend provides no parking, wakeup, fairness, parallelism, interruption, cancellation, or scheduler semantics. It remains deterministic single-execution Effect composition.

let value = run Effect.suspend(readNext())

This transfers stack-safe execution of readNext; it does not wait for another task to publish a value unless readNext already has some separately defined synchronous way to complete.

Boundary: Runtime parking and owner-controlled resumption use the separate Execution and Wake lifecycle. The shipped single-threaded scheduler and Fiber APIs add ready queues, child tasks, observation, and structured cancellation as ordinary source policy over that lifecycle. Those facilities still do not turn suspension into parking or establish a general async-I/O model.

Diagnostics: Suspension alone adds no Scheduler, Executor, or concurrency service requirement. An unowned park-capable executable, an invalid Execution lifecycle transition, or an unresolved Scheduler requirement receives that facility's own diagnostic or trap rather than changing Effect.suspend.

Evidence: no ambient runtime facilities, independent execution lifecycle, single-threaded scheduler and Fiber policy, suspension implementation scope.

Private lowering model

These are compiler architecture rules, not additional source obligations:

  • One concrete suspendable invocation owns one reusable coroutine frame. Repeated suspension by that invocation changes its resume state; it does not allocate another continuation record.
  • Every resume state names only the values needed after that transfer. One statically known maximum layout covers all mutually exclusive states, including compiler-generated temporaries.
  • A suspendable specialization's frame identity includes its complete specialization key, including the normalized provider contract row. Two otherwise identical specializations that bind different provider rows cannot share or select each other's frame layout.
  • The parent completes its ownership and state transition before the deferred child begins. A live value therefore has one owner throughout transfer, execution, resumption, and cleanup.
  • Evaluation keeps frames in its activation machine. Native uses non-moving segmented private storage. Direct Wasm uses a private, non-overlapping linear-memory region. Growth failure follows SUSP-006 on all three engines.

The complete-key frame invariant is exercised by the coroutine-frame lookup and the contract-row suspension parity corpus.

The complete architecture contract remains in the archived suspension implementation design.

On this page