Moxie-style spawn run model for TinyGo wasm apps
git clone https://git.smesh.lol/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.
main is an orchestrator. It spawns top-level domains, wires their channels,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.
spawn returns a lifecycle channel. It closes when the handler returns; theparent selects on it to detect death.
and each lifecycle channel closes leaf-to-root.
messages on its loop.
These are the invariants that make the TinyGo layout survive the Moxie port.
caches, registries, connection pools, or init-built state. Constants and
immutable configuration are fine.
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.
channels created after start, no dynamic registration.
worker its own channel and rotates; it does not let N workers read one shared channel.
state. Do not pass functions or interfaces. Data arguments should be plain values or types whose ownership is transferred and never touched again.
select loop untilits stop breaker fires. It is a server, not a one-shot function call.
recover. The parent decides whether childdeath 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.
A long computation with no channel operation or runtime.Gosched() starves
every other domain in the instance.
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.
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
}
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.
| Moxie | tiny-spawn |
|---|---|
spawn(fn, data, chans) | tinyspawn.Spawn(fn, data, chans) |
chan T (duplex) | tinyspawn.NewDuplex[T]() pair |
close(breaker) | breaker.Close() |
lifecycle channel from spawn | returned <-chan struct{} |
select loop | select loop |
| static function entry | named func(D, C) |
| Codec copy at the boundary | Go 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.
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.
tinygo test -target=wasip1 ./...
tinygo test -target=wasm ./...
go test ./...
AGPL-3.0-or-later