README.md raw

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

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

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

arguments are copied values, not references to caller state.

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

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

parent selects on it to detect death.

and each lifecycle channel closes leaf-to-root.

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

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

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.

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

dead child while the parent survives.

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

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