tiny-spawn

Moxie-style spawn run model for TinyGo wasm apps

git clone https://git.smesh.lol/tiny-spawn

tiny-spawn

A work-alike of Moxie's spawn for TinyGo wasm apps, built on tiny-actor.

The purpose is to fix the run model before native Moxie exists. The app is a tree of domains. Each domain is one goroutine running one event-processing loop, owning its own state, and communicating only over channels that were bound when it was spawned. On TinyGo wasm those domains are cooperative goroutines in one instance. On native Moxie the same layout becomes processes (and on wasm, Web Workers) that actually run in parallel.

The API is intentionally smaller than go: there is no closure capture, no channel creation after start, and no shared mutable scope to reach into.

Model

  • main is an orchestrator. It spawns top-level domains, wires their channels,

routes messages, and supervises exits. It holds no business state.

  • Every handler is a domain. A "request handler" is not a callback inside the

caller's loop; it is a long-lived domain with its own select loop.

  • Every domain owns its state. State is created inside the domain. Data

arguments are copied values, not references to caller state.

  • All channels are fixed at spawn. A domain can never receive a new channel

handle after it starts; everything it will ever use is in chans.

  • Each channel direction has one producer and one consumer. A second caller

gets a second duplex pair, never a second writer on the same channel.

  • spawn returns a lifecycle channel. It closes when the handler returns; the

parent selects on it to detect death.

  • Shutdown is a breaker: close it, every domain holding it wakes, each returns,

and each lifecycle channel closes leaf-to-root.

  • Domains are long-lived. No per-job spawns; a worker processes jobs as

messages on its loop.

Rules for spawned code

These are the invariants that make the TinyGo layout survive the Moxie port.

  1. Package-scoped variables are read-only. No mutable globals, counters,

caches, registries, connection pools, or init-built state. Constants and immutable configuration are fine.

  1. Do not reach outside your own package for state. A domain may share

value/message types, but it must not import another app package to read or write that package's variables. If two domains need a common vocabulary, put the message types in a package with no mutable state.

  1. All channels are bound at spawn. No channel handles in messages, no

channels created after start, no dynamic registration.

  1. One writer and one reader per channel direction. A pool gives each

worker its own channel and rotates; it does not let N workers read one shared channel.

  1. State lives inside the domain. Do not pass a pointer to caller-owned

state. Do not pass functions or interfaces. Data arguments should be plain values or types whose ownership is transferred and never touched again.

  1. Handlers are event loops. A spawned function runs a select loop until

its stop breaker fires. It is a server, not a one-shot function call.

  1. Panic is death. There is no recover. The parent decides whether child

death means respawn, degrade, or propagate. On TinyGo wasm there is no process isolation, so a panic is not contained the way it is on native Moxie; keep the loop panic-free.

  1. CPU-bound work must yield. The TinyGo wasm scheduler is cooperative.

A long computation with no channel operation or runtime.Gosched() starves every other domain in the instance.

API

func Spawn[D, C any](fn func(D, C), data D, chans C) <-chan struct{}

Starts fn(data, chans) in a new domain and returns its lifecycle channel. fn should be a named top-level function. data is copied into the domain; chans is the fixed channel set.

func NewDuplex[T any]() (Duplex[T], Duplex[T])
func NewBufferedDuplex[T any](buffer int) (Duplex[T], Duplex[T])

type Duplex[T any] struct {
    Tx chan<- T
    Rx <-chan T
}

A bidirectional pair of simplex channels, one per direction. The two returned ends are the two peers. Send blocks, TrySend does not, and Recv returns the receive side for select.

type Breaker chan struct{}

func NewBreaker() Breaker
func (b Breaker) Close()
func (b Breaker) Done() <-chan struct{}
func (b Breaker) Closed() bool

Cancellation by close, broadcast to every holder. A nil breaker never fires.

Example

package main

import (
    "fmt"

    "git.smesh.lol/tiny-spawn"
)

type ping struct {
    N int32
}

type chans struct {
    Link tinyspawn.Duplex[ping]
    Stop tinyspawn.Breaker
}

func echo(_ struct{}, ch chans) {
    for {
        select {
        case v := <-ch.Link.Recv():
            ch.Link.Send(v)
        case <-ch.Stop.Done():
            return
        }
    }
}

func main() {
    parent, child := tinyspawn.NewDuplex[ping]()
    stop := tinyspawn.NewBreaker()
    done := tinyspawn.Spawn(echo, struct{}{}, chans{Link: child, Stop: stop})

    parent.Send(ping{N: 1})
    fmt.Println(<-parent.Recv())

    stop.Close()
    <-done
}

Relationship to tiny-actor

Spawn uses tiny-actor's Lifecycle/Go for the domain lifecycle. tiny-actor's Func, Query, Proc, Signal, and Inbox are intra-domain patterns: they assume per-call reply channels, which are legal inside a domain but cannot cross a Moxie spawn boundary. Use them inside a domain's select loop; use Duplex for the fixed channels between domains.

Mapping to Moxie

Moxietiny-spawn
spawn(fn, data, chans)tinyspawn.Spawn(fn, data, chans)
chan T (duplex)tinyspawn.NewDuplex[T]() pair
close(breaker)breaker.Close()
lifecycle channel from spawnreturned <-chan struct{}
select loopselect loop
static function entrynamed func(D, C)
Codec copy at the boundaryGo value copy (see limits)

On native Moxie, swap Spawn for spawn at the domain boundary and keep the loop, state placement, channel wiring, and message flow. That is the whole point of this repository.

Limits

  • No address-space isolation. A TinyGo wasm instance is one memory. The

package-root and cross-package rules above are discipline, not enforcement.

  • No codec enforcement. Data arguments and messages are Go values. Slices,

maps, and pointers still alias. Pass ownership and do not mutate afterward, or use only scalar/struct values. Moxie serializes at the boundary; TinyGo cannot without a codec layer.

  • No preemption and no parallelism. Every domain shares one thread and

switches only at blocking operations. Native Moxie domains run in parallel.

  • No panic containment. TinyGo cannot turn a panicking goroutine into a

dead child while the parent survives.

  • `select` priority differs. Moxie checks cases top-to-bottom. TinyGo's

selection among ready cases should not be treated as a priority mechanism.

  • One outstanding call per peer. A duplex pair has one writer per

direction. A caller that blocks on a reply is that writer; concurrent calls need separate peers or a dispatcher domain.

Testing

tinygo test -target=wasip1 ./...
tinygo test -target=wasm ./...
go test ./...

Family

License

AGPL-3.0-or-later

files