# tiny-spawn A work-alike of Moxie's `spawn` for TinyGo wasm apps, built on [tiny-actor](https://git.smesh.lol/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. 2. **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. 3. **All channels are bound at spawn.** No channel handles in messages, no channels created after start, no dynamic registration. 4. **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. 5. **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. 6. **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. 7. **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. 8. **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 ```go 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. ```go 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`. ```go 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 ```go 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 | 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. ## 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 ```shell tinygo test -target=wasip1 ./... tinygo test -target=wasm ./... go test ./... ``` ## Family - [tiny-actor](https://git.smesh.lol/tiny-actor) - intra-domain actor channels - [actor](https://git.smesh.lol/actor) - the Go original - [pickle](https://git.smesh.lol/pickle) - Rust - [anneal](https://git.smesh.lol/anneal) - TypeScript ## License AGPL-3.0-or-later