Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Snail-Scheme

Snail-Scheme is a language and application toolkit built around Snail-Core, a typed functional language with Scheme syntax that compiles to WebAssembly with garbage collection. The compiler is written in Snail-Core and compiles itself.

  • Snail-Core: the language, its types, libraries, compiler and application model.
  • Tutorials: small complete programs to read and run. Start here to write something.

As an extra, Siegework is a colony game written entirely in Snail-Core. Play it in a browser with WebGPU.

Find the source in the repository, and how to install the tools in its getting started guide. The book describes implemented contracts and marks proposals explicitly; GitHub issues track open work.

Snail-Core

Snail-Core is a typed, pure-by-default functional language with Scheme syntax. It compiles to WasmGC. Scheme does not compile directly: its macros and libraries expand it into Snail-Core, and checking, specialization and code generation all happen there, so Scheme ships as libraries over the typed language. The compiler is a Snail-Core program and compiles itself. Erasing Snail-Core’s typed forms leaves an R7RS program with the same behavior, which is how stage 0 runs that source; Bootstrap explains it.

The chapters describe implemented behavior and mark proposed toolchain changes:

Terms

Programs and libraries

A program is a file of import forms followed by definitions and expressions, which run in order. A library is an R7RS define-library with import, export and begin sections, stored at a path that matches its name: (sample geometry) lives in sample/geometry.sld under a search directory. Import sets may use only, except, prefix and rename.

(import
 (snail-core base)
 (snail-core io))

(: fibonacci (-> I64 I64))
(define (fibonacci n)
  (if (< n 2) n (+ (fibonacci (- n 1)) (fibonacci (- n 2)))))

(display (fibonacci 30))
(newline)

src/ is always on the search path. ./scripts/snail-tool run -I DIR program.sld adds more.

A library file may instead hold a generate-library recipe: a program whose value is the library’s define-library form, as a Syntax from (snail-core syntax). The compiler builds and runs it first:

(generate-library (sample table)
  (import
   (snail-core base)
   (snail-core syntax))
  (library-syntax (list "sample" "table")
                  (list "squares")
                  (list (list "snail-core" "base"))
                  (list (form "define" (list (SymbolSyntax "squares")
                                             (form "list" (map (lambda (i) (IntegerSyntax (* i i))) (iota 6))))))))

The program writes the syntax as bytes that the compiler decodes, so no text is printed or parsed in between, and a BytesSyntax reaches the compiler as the bytes it holds. datum->syntax from (snail-core datum) turns a Scheme datum into syntax. Every generated form is located at the recipe.

Forms

FormMeaning
(define name value), (define (name param ...) body ...)Definition.
(lambda (param ...) body ...)Function with a fixed number of parameters.
(if test then [else])test is Bool; without else the result is Unit.
(begin form ...)A body: definitions and expressions in order.
(let ((name value) ...) body ...), (let loop ((name value) ...) body ...)Local bindings and loops.
(match value (pattern body ...) ...)Pattern matching, checked for exhaustiveness and unreachable clauses.
(: name type)Signature for the following definition.
(ann expression type)Static type annotation.
(type-convert expression type)Converts to or from Any; a downcast is checked at run time.
(type-test expression type)Whether an Any holds a value of type, without trapping.
(struct (Name param ...) ((field type) ...))Record type.
(enum (Name param ...) (Case [((field type) ...)]) ...)Tagged sum.
(type (Name param ...) type)Transparent alias.
(typeclass (Class param ...) (: method type) ...)Class of types with methods.
(instance (Class type ...) [(requires constraint ...)] (define ...) ...)Instance.
(effect Name ((operation type) ...) [(host "module" ((operation "symbol") ...))])Effect with operations and optional host defaults.
(handle Name ((operation implementation) ...) body ...)Handles an effect for the extent of body.
(extern name "module" "symbol" type)Wasm import, asserted total.
(expose name "symbol")Exports a monomorphic top-level function to the host; its arguments and result must be unrestricted.
(define-syntax name (syntax-rules (literal ...) (pattern template) ...))Hygienic macro.

(snail-core base) adds let*, letrec, cond, case, when, unless, and, or, do, list, string and a variadic string-append as macros.

Bodies and scope

Library and program top levels see all of their definitions, so top-level functions may be mutually recursive. A local body is sequential: a definition is visible to the forms after it, and a local function is also visible to itself. Write mutually recursive helpers at the top level.

Functions have fixed arity. Where Scheme uses variadic procedures, Snail-Core uses macros (list, string-append) or lists.

Toolchain terms

These names distinguish source organization, compilation and execution. Project descriptions, builds and the native snail dev editor are implemented. The application process and persistence workflow remains proposed.

TermMeaning
ProjectOne directory of source files and its project description. A plain directory can be edited before a description is added.
WorkspaceA collection of projects. The tools support one project; they do not introduce a workspace object.
Build targetA named description of what to build. It produces one output directory, which may contain multiple files.
Artifact bundleThe complete output of a browser build target, resolved by name during compilation.
Run targetA named command for running a program build with its arguments.
Build attemptOne invocation of project building, with an ID, diagnostics and a success/failure result.
Compiler sessionCached expansion, checking and emitted code retained between builds.
Development sessionA running snail dev: file watching, persistent compilation and the editor’s services. Application control remains planned.
Application processAn OS process running a program. A browser frontend is a client of a server process, not a run target.
RuntimeThe scheduler and effect bindings executing a program’s tasks. It is distinct from both the compiler session and the OS process.
HostThe platform environment, such as Node or a browser, providing APIs to the runtime.
WorkerA separate execution agent used for CPU work. Here a native compiler worker keeps its own compiler state; it is not a browser Service Worker or a Snail-Core task.
Working stateIn-memory values used by the running application, including changes not yet saved.
Persisted dataThe application’s chosen representation committed to storage independently of the running code. It need not encode every transient resource or cache.

Incremental compilation reuses compiler work. Restart starts a fresh application process; the application can reconstruct working state from persisted data. Hot reload (also called live reload) replaces running code while preserving application data under a stated contract. Today’s browser uses state handover; the proposal uses durable typed tables and disposable cached views, recomputed on demand after ordinary startup. Recompiling incrementally does not by itself preserve application state, and moving execution into a worker does not supply a preservation contract.

Types

Types

Scalars are U8, I32, I64, F32, F64, Bool, Char and Unit. Any is a value of any type, used with type-convert. String is an immutable sequence of Unicode scalars. (Vector T) is an immutable sequence and (Array T) a mutable one; (Ref T) is a mutable cell. Bytes is (Vector U8), stored packed. A byte vector literal, #u8(137 80 78 71), compiles to a data segment of exactly its bytes, so a program can carry large binary data, such as images; it cannot be a pattern. (include-bytes "pixel.png") is another spelling of the literal holding a file’s bytes, with the path relative to the file it is written in. It is replaced when the source is read, so a macro cannot compute the path, and nothing else may be named include-bytes.

A function type is (-> Param ... Result), optionally followed by an effect row: (-> String Unit ! console). A polymorphic signature quantifies type and row variables and lists its class requirements:

(: map (All (A B e) (-> (-> A B ! e) (List A) (List B) ! e)))
(: sum (All (T) (requires (Num T)) (-> (List T) T T)))

Integer literals check against any integer or float type and are range-checked; float literals check against F32 or F64. A literal whose type nothing determines defaults to I64 or F64. Values never convert implicitly: use i64->i32, i32->f64 and the other conversions. Integer arithmetic wraps.

Unannotated top-level functions are generalized over the type variables no constraint mentions. Other unannotated definitions, and functions whose types involve a class constraint, keep one type, which later uses in the same library may decide. A library is checked before the libraries that import it, so their uses never decide its types: (define key-left 1) is an I64 wherever it is imported, and a value meant as an I32 elsewhere needs the signature (: key-left I32). Polymorphic definitions with constraints, and polymorphic values that are not functions, need signatures.

Data

(struct (Vec3 T)
  ((x T)
   (y T)
   (z T)))

(enum Shape
  (Circle ((radius F64)))
  (Polygon ((points (List (Vec3 F64)))))
  (Empty))

A struct Name defines the constructor Name, accessors Name-field and pure replacements Name-field-set. An enum defines a constructor and accessors per case. Cases are constructors, not types, and are always applied: (Empty).

Patterns are _, variables, literals (numbers, characters, strings, booleans) and constructor patterns, nested freely. (List T) is an ordinary enum with cases Nil and Cons.

Typeclasses

A typeclass declares methods over its parameters; instances may be generic and may require other instances:

(typeclass (Describe T)
  (: describe (-> T String)))

(instance (Describe (List T))
  (requires (Describe T))
  (define (describe items)
    (string-join (map describe items) ", ")))

The checker chooses each instance when it checks a use and records that choice; monomorphization copies the chosen method for each type, so no dictionary exists at run time. Instance heads must not overlap. Classes have no superclasses: a signature lists every class it needs. Unbound names in an instance head are its type variables.

Arithmetic and comparison are the classes Num, Ord, Integral and Fractional; Show converts values to text.

Transfer and sharing

(Send T) permits transferring ownership of a value between threads; (Sync T) permits shared access across threads. Both are compiler-owned marker typeclasses from (snail-core base). Write them as ordinary requirements:

(: transferable (All (T) (requires (Send T)) (-> T T)))
(define (transferable value) value)

The compiler derives evidence for scalars, text, immutable vectors and declared data whose arguments and fields satisfy the same requirement. Recursive data is checked structurally. Aliased Ref and Array, opaque Any, raw Funcref, and ordinary function arrows do not satisfy either marker. A procedure’s arrow does not describe its captures. Generic fields need the corresponding evidence in the enclosing signature.

There are no user-defined instances for these two markers. Their meanings differ even though this initial set of representations has the same proofs for both. No data is serialized or copied by a marker constraint. These checks apply on today’s single-threaded runtime as part of the language’s multithreaded contract.

Effects

Ordinary handler implementations must be reusable and satisfy Send + Sync, including their captures and the globals reachable through helper functions. The compiler checks concrete handler installations after specialization. Capturing an immutable value or a known safe procedure is allowed. Capturing a mutable cell, an alias of a procedure that reaches one, or an opaque callback whose environment is unknown is rejected. The handler may allocate fresh mutable storage on each invocation and operate on its arguments. An arrow alone is not a certificate for an unknown callback; higher-order handler factories may need to construct their implementation at the binding site so its environment remains visible to the compiler. Incremental builds recheck cached handler code against current helper bodies; an unchanged function signature does not preserve a proof about captures.

Every function type has an effect row: the effects calling it may perform. A function without ! is total. mut covers reading and writing Ref and Array; every other effect is declared with operations:

(effect Log
  ((log (-> String Unit))))

(: quietly (All (A e) (-> (-> A ! Log e) A ! e)))
(define (quietly action)
  (handle Log
      ((log (lambda (message) (unit))))
    (action)))

Rows are inferred by unification, Koka-style. A call requires its callee’s effects to fit the current row. With a shared quantified tail, e fits console e, and fs e fits console fs e; the reverse inclusion does not hold. This permission check does not change equality of function types. A handler removes its effect. Handler implementations return to the operation’s caller exactly once, and they run in the handler’s outer context, so an operation they perform goes to the next handler out. A handler names operations within its effect, so they need not be imported. Effects with host defaults, such as console and fs from (snail-core io), need no handler at the top level of a program, and a test can handle fs to serve files from memory.

extern declares a total foreign function; the declaration is the boundary’s unsafe assumption.

A foreign call may take a final String or Bytes argument. The compiler copies it into the runtime’s staging buffer just before the call. A String arrives as its Unicode scalar count, with four bytes per scalar in staging. Bytes arrive as an i32 address: a little-endian u32 length followed by that many packed bytes. The host reads the current memory buffer and copies any bytes it retains before returning or awaiting; later calls reuse staging. Imports are leaves and must not call back into Snail-Core while reading staging.

An exposed function passes scalars directly and managed values as opaque engine references. JavaScript can retain those references and pass them to synchronous Wasm constructors/projectors, but cannot inspect their fields or substitute a JavaScript object, string or typed array. Exposed String and Bytes are managed arrays; the staging convention above applies to calls into foreign imports. Affine values, including records that contain them, cannot cross this unrestricted boundary. Exposed signatures remain monomorphic and their effects need host bindings. The host must respect the declared Snail-Core types: Wasm representation checks do not prove nominal identity or permit treating immutable Bytes as a mutable Array.

An exposed (Option Any) crosses as null for None or the opaque Any payload for Some. The wrapper converts at the boundary; internal Options keep their normal enum representation. Plain Any remains non-nullable. This exception applies only to exposed parameters/results; a host import returning (Option Any) still expects its ordinary enum. Constructors for a typed host operation should return its whole result, such as (Result Bytes E). Resolve helpers from their library instance in separate builds and the combined instance in static builds. After a wait, the runtime must reacquire the execution permit before invoking a constructor or returning to Wasm. Programs export their dynamic handler globals as handler.NAME; the scheduler preserves each task’s bindings across suspension. See scheduling.

A function whose signature has no mut may still mutate storage that it allocates and that nothing keeps after it returns:

(: sum (-> (List I64) I64))
(define (sum items)
  (let ((total (ref 0)))
    (for-each (lambda (item) (ref-set! total (+ (ref-get total) item))) items)
    (ref-get total)))

The checker accepts such a body when nothing the function can reach from outside can hold mutable storage: not its parameters, not its result, and not any name it reads, including effect operations. Holding storage means being a Ref, an Array or an Any, data with such a field, or a function that performs mut. Primitives, constructors and field operations are always allowed. A helper defined elsewhere that mutates its argument is therefore rejected even when the argument is local; define it inside the function. unsafe-total runs a thunk and discards all its effects without a check, for the cases this rule rejects; each use says in a comment why nothing escapes. unsafe-cast is the other unchecked primitive: it turns a Funcref, a function another module exports, into a function of whatever type the context expects ((snail-core wasm)).

Traps and non-termination are not tracked.

Dynamic values

Any holds an unrestricted value together with its type’s identity. Affine values cannot cross this boundary. (type-convert e Any) wraps a value; (type-convert e T) unwraps an Any, panicking if it does not hold a T, and (type-test e T) asks whether it does. A type’s identity describes its structure, its fields and cases, so a struct whose fields change, even under the same name, is another type: a program relinked with that change cannot take an old value for a new one. Converting a value to the type it already has does nothing, and a value converted to Any and straight back is never wrapped, so conversions can be inserted liberally where typed code meets dynamic code.

(define items (list (type-convert 42 Any) (type-convert "text" Any)))
(+ 1 (type-convert (car items) I64))

Macros and Scheme libraries

Macros are hygienic syntax-rules transformers, defined with define-syntax at the top level or in a local body. Surface forms such as let*, cond, case and the variadic list are macros in (snail-core base), and they expand away before checking, so the checker sees only the core.

A library file may hold a generate-library recipe instead of a define-library: the compiler builds and runs that program first, and compiles the library syntax it returns, which the program writes as bytes the compiler decodes rather than as text.

Scheme is a library too. (scheme base) gives R7RS names (cons, car, vector, char=?, …) over the typed language, and (scheme compiler) translates R7RS programs into Snail-Core libraries, run by a generate-library recipe, with (scheme runtime) for the standard procedures. Its character procedures are Unicode’s, from (snail-core text): char-upcase maps the full repertoire, char-numeric? and digit-value hold for any script’s decimal digits, and string-ci=? compares case-folded text, so "Straße" and "STRASSE" are equal. The Scheme on Snail-Core tutorial runs a Scheme program.

That compatibility rests on Snail-Core’s core being built so that erasing its typed forms leaves an R7RS program — which is also how Chibi Scheme runs the compiler’s own source. Bootstrap and self-hosting sets out the erasure property and the stages.

Full Scheme. (scheme compiler) is a Snail-Core library: a reader plus a translator from R7RS source to the syntax of a Snail-Core library. Every Scheme value is a Datum, the type of Scheme data in the dynamic libraries (datum, number, read, write), so matching on one enum replaces run-time type tests. Scheme code calls no typed Snail-Core code yet. Proposed: wherever generated code meets typed Snail-Core code, the translator inserts (type-convert e T), liberally and without proving it necessary:

  • Upcast. Converting a typed value to Any is checked statically and injects the value with its TypeId.
  • Downcast. Converting an Any to a specific type checks the TypeId at run time and traps on a mismatch; (type-test e T) asks first, without trapping.
  • Redundancy. The compiler removes conversions it proves redundant, such as a downcast of a value just upcast from the same type.

On Chibi, type-convert erases to its expression. A generate-library recipe runs the translator:

(generate-library (legacy program)
                  (import
                   (snail-core base)
                   (snail-core io)
                   (scheme compiler))
                  (scheme->snail-core-library (list "legacy" "program") (read-file "program.scm")))

Bootstrap and self-hosting

The compiler is a Snail-Core program that compiles itself, and Chibi Scheme runs that same source as stage 0. Both are possible because of one property of Snail-Core’s core: delete its typed forms, replacing them with untyped R7RS equivalents, and the result is an R7RS program with the same behavior whenever the Snail-Core program does not trap or overflow.

Snail-CoreR7RS after erasure
(struct (Vec3 T) ((x T) (y T) (z T)))(define-record-type Vec3 (Vec3 x y z) Vec3? (x Vec3-x) (y Vec3-y) (z Vec3-z))
(enum (Option T) (Some ((value T))) (None))One record type per case (Some, None) and a predicate Option? that tests the cases.
(: ...), (type ...)Removed, including effect rows.
(effect Log ((log T)) ...)A parameter holding a record of implementations and the outer record, and a procedure per operation that calls the current implementation with the parameter rebound to the outer record. The shim supplies host defaults.
(handle Log ((log impl)) body ...)(parameterize ((Log (make-Log impl))) body ...)
(ann e T), (type-convert e T)e
(match e ((Some v) a) ((None) b))(let ((x e)) (cond ((Some? x) (let ((v (Some-value x))) a)) ((None? x) b)))
Typeclass methodA generic procedure that dispatches on its first argument’s record predicate.
I64, F64 literalsExact integers and flonums.

Each define-record-type creates a fresh runtime type, so struct identities and enum cases stay distinct after erasure.

The compiler is written in that core. No host-specific code appears in the passes. Hosts differ only in library implementations, which is ordinary in Scheme, where every implementation provides its own (scheme base):

  • On Snail-Core. The typed forms are built in, and the compiler’s libraries, (snail-core base), (snail-core table) and (snail-core io), are Snail-Core libraries backed by WASI.
  • On Chibi. host/chibi/ implements the same three libraries with Chibi’s own procedures, and (snail-core forms) defines :, struct, enum, type and match as erasing macros. The compiler defines no typeclass instances of its own, so nothing dispatches at run time.

The shared driver reads its arguments and files through (snail-core io), and both entries publish missing-input requests after unwinding.

Stage 0’s only search root is bootstrap/chibi-include/: one symlink per library the compiler’s own load closure reaches, and nothing else, so no other library under src/ can enter the bootstrap by accident. Reaching for one fails with couldn't find import, which is how make test-chibi and make bootstrap catch a compiler module that starts importing the standard library.

The hosts disagree on integer overflow, argument evaluation order and the order of table keys, so the compiler’s output must not depend on them. One place still does: generated names are numbered from a counter the two hosts consume in different orders, so the two texts differ in their names even though no instruction differs. Until that is fixed, make bootstrap cannot compare one host’s output against the other’s — issue 495 measures it.

Bootstrap.

  1. Stage 0 is the previous compiled compiler, or, the first time, the compiler’s source running on Chibi.
  2. Stage 0 compiles the source to Wasm, giving stage 1.
  3. Stage 1 compiles stage 2.
  4. Stage 2 must reproduce its own text exactly.

The driver repeats this whenever the compiler’s source changes, so a pinned Wasm build bootstraps without Chibi. Chibi remains the host of the first bootstrap and of make test-chibi.

Libraries

Standard library

LibraryContents
(snail-core base)Forms, numeric classes, Option, Result, Pair, lists, vectors, arrays, strings, Show.
(snail-core io)The console and fs effects: text output, lines of input, and files as text or bytes.
(snail-core task)One-shot local dispatch, cooperative yield and timer waits through the runtime-owned tasks effect.
(snail-core table)Immutable tables from strings to values.
(snail-core math)Elementary functions on F64.
(snail-core text)Unicode character properties, case mapping and decimal digit values, from the runtime’s tables. (scheme runtime) is built on it, so Scheme’s character procedures are Unicode’s.
(snail-core random)A deterministic generator as a value.
(snail-core wire)Serialize, Deserialize and the byte encoding.
(snail-core app)Applications, frames, pictures, materials and sprites, and the screen effect.
(snail-core wasm)Loading WebAssembly modules while a program runs, and unsafe-cast.
(snail-core sprite)Spritebooks: animations on sheets of equal cells, posed as sprites facing the camera.
(snail-core format)Indenting and formatting Lisp source as scripts/format-scheme does.
(snail-core integer), (snail-core rational), (snail-core number)Exact integers of any size, rationals, and Scheme numbers.
(snail-core datum), (snail-core read), (snail-core write)Scheme data and its external representation.
(snail-core syntax)The syntax a generate-library recipe returns, and the bytes its producer writes.
(snail-core explore)Bounded model checking of a total state machine’s invariant.
(scheme base)R7RS names (cons, car, vector, char=? …) over the typed language.
(scheme compiler), (scheme runtime)The R7RS translator, and the standard procedures its output calls.

Wire types

(deriving Serialize Deserialize) at the end of a struct or enum generates instances of (snail-core wire)’s classes. serialize writes a value as bytes and deserialize reads it back:

(struct Point
  ((x I64)
   (y I64))
  (deriving Serialize Deserialize))

(ann (deserialize (serialize (Point 1 2))) Point)

Each struct and enum case has a 32-bit TypeId hashed from its library, name and field schema. Every struct or enum value is written as its case’s TypeId followed by its fields, so reading data written for another schema fails at the first changed type instead of misreading it.

HTTP protocol

(snail-core http) implements a pure, bounded request-head parser and response serializer, importing only (snail-core base). (snail-core http exchange) reads one complete request, calls application code and writes its response over the concrete socket effects. Applications own accepting and scheduling connections; the library supplies no router or server framework.

This is a deliberate HTTP/1.1 subset. Full HTTP/1.1 also requires features this module rejects, including chunked decoding and absolute-form targets. RFC 9112

Request heads

(parse-request-head bytes)
;; Result (Option ParsedHead) HttpError

Ok None means the supplied prefix needs more bytes. Ok (Some parsed) contains a RequestHead and the offset of the first body byte. Bytes after that offset are not inspected, so binary content or a large body cannot invalidate a short head. The exchange reader reports EOF before a complete head as TruncatedHead, including an empty connection. The parser itself does not establish body receipt. An incomplete prefix is not a certificate of valid syntax: some errors are decided once a complete head is available.

The head limit is 16,384 bytes, including the terminating empty line. Input uses strict CRLF and ASCII, with tab allowed in field values. The method is a token retained exactly; the target must be origin-form, beginning with /. URI characters and percent escapes are checked, but spelling is preserved: there is no decoding, path normalization, filesystem lookup or origin authorization. CONNECT, other target forms and other HTTP versions are unsupported.

Each Header contains a lowercase name and a value with surrounding spaces/tabs removed. Ordinary occurrences retain their order and are not merged. header-values takes the parsed headers and a case-insensitive name, returning all matching values. It expects the normalized names produced by the parser.

Exactly one Host is required. This implementation accepts a nonempty name made of ASCII letters, digits, ., - and _, optionally followed by a decimal port from 0 through 65,535. This is a syntax restriction, not DNS validation. IPv6 literals are unsupported. Exact Host and Origin policy remains with the caller.

A missing Content-Length means body length zero. A single unsigned decimal length may specify at most 8,388,608 bytes; the parser checks that bound before arithmetic or body allocation. Duplicate or comma-list lengths and combined Transfer-Encoding/Content-Length are malformed. Other transfer codings and every Expect field are unsupported, decided before a caller waits for any body.

HttpError distinguishes Malformed, Unsupported, HeadTooLarge, BodyTooLarge and InvalidResponse. The reason/feature strings are diagnostics; the codec does not choose HTTP error responses. Public records remain ordinary constructible Snail-Core values. The parser guarantees its own outputs, rather than making a claim about records created elsewhere.

Response parts

(encode-response method status headers representation)
;; Result ResponseParts HttpError

ResponseParts-head and ResponseParts-body are separate immutable byte vectors. This avoids copying a representation into one combined response buffer and leaves a clear boundary for future streaming. No bytes are written by this call.

The serializer accepts final statuses 200–599, with an empty reason phrase. Successful CONNECT responses are unsupported. It validates ASCII header names and values and checks the 16,384-byte head bound before building the output buffer. Caller-supplied Content-Length, Transfer-Encoding, Connection, Trailer and Upgrade fields are rejected. It generates Connection: close and owns content framing; header strings cannot inject another field or status line.

ResponseGenerated Content-LengthReturned body
204 or 304OmittedEmpty
2050Empty
HEAD, otherwiseRepresentation lengthEmpty
Other supported responseRepresentation lengthOriginal bytes

Status rules take precedence over HEAD. Response representations are not subject to the request-body bound. The caller still has to write all head/body bytes and close appropriately; successful encoding is not delivery.

One exchange

Import (snail-core http exchange) and call serve-one with an exclusively used Connection, an I32 grace period in milliseconds, and an application function:

(: serve-one
   (All (E e)
     (-> Connection I32
         (-> Request (Result Response E) ! e)
         (Result Unit (ExchangeError E)) ! tcp e)))

Request contains head : RequestHead and body : Bytes. The callback returns Response, containing status : I32, headers : List Header and body : Bytes, or an application error of its chosen type E. It may suspend for other effects; the surrounding computation retains those requirements. The callback receives the request, not the connection.

The reader scans incoming bytes once for the head delimiter and parses the completed head once. It collects exactly the declared body length, including any prefix that arrived with the head. Fragment lists are copied once into the final vectors; one-byte fragmentation does not repeatedly copy the accumulated input. Unneeded pipelined bytes are discarded. The serializer validates the complete response before any output, then the writer sends head and body separately, advancing by each accepted count. HEAD and bodyless statuses use the codec’s rules. No handler call occurs before the complete request body arrives. Each write offers at most twice the previous accepted count, capped at 65,536; this bounds slice copying even when an intercepted write makes tiny progress.

ExchangeError E distinguishes protocol failure (HttpError), transport failure (SocketError), TruncatedHead, TruncatedBody with expected/received counts, and ApplicationFailure E. Every returned failure closes the connection while preserving the original error if close also fails. Empty successful reads or invalid write counts report transport EIO, rather than looping without progress. Protocol/application errors do not generate automatic HTTP error responses.

Success ends with graceful completion through finish-connection: it ends output, discards input and awaits physical closure with a bounded grace period. Unread incoming data can otherwise cause a reset that loses the response; see RFC 9112 §9.6. This grace is for completion only. There is no request-idle deadline yet.

Exclusive use is a calling convention, not affine ownership. The caller must not use another alias during the exchange. Cancellation of active native socket I/O closes that socket. An uncatchable trap or cancellation while the callback waits on an unrelated effect cannot execute the Snail-Core cleanup branch; runtime shutdown cleans the remaining native resource. serve-one does not introduce a task scope or stronger unwinding guarantee.

Remaining integration

Artifact serving, explicit streaming/SSE, connection scheduling and deadline policy remain networking work. Existing project file, status and origin policies remain in the native project service. Persistent connections, pipelining and streaming uploads are not implemented.

Projects and targets

A project is one directory described by an inert project.datum. The file contains data, not Scheme code. Parsing it performs no file access or build; the command chooses when to compile and run its named targets.

((build-target client
   (browser
    (entry "client/main.sld")
    (library-roots ("client/src"))
    (public "client/public")))
 (build-target server
   (program
    (entry "server/main.sld")
    (library-roots ("server/src" "shared"))))
 (run-target dev
   (run server
    (args ("--port" "8080")))))

Build targets describe programs or browser bundles. Run targets name program builds and their arguments. One server can embed several browser bundles; a browser bundle is a dependency, not a directly launched process. Applications supply the server and routes; project metadata does not infer them.

./snail build --release apps/siegework site
python3 -m http.server 4191 --directory apps/siegework/build/site
./snail check apps/siegework
./snail dev apps/siegework

snail build [--release] PROJECT BUILD-TARGET publishes one selected target and its required artifacts. snail run PROJECT RUN-TARGET [ARGUMENT ...] builds and runs a named program. snail check PROJECT checks all declared entries. snail dev [--port PORT] PROJECT retains compiler sessions and serves the editor. The toolchain describes command lifetimes, checking and development, including the editor’s application controls. Durable application data and browser reload integration remain in progress.

Description format

The file contains exactly one proper list of explicit build-target and run-target forms. Every field is exactly (key value), including list values such as (library-roots ("src" "shared")) and (args ()).

Both build kinds require one literal entry path and accept an optional library-roots list. Browser builds also accept an optional public directory. Omitted roots mean an empty list; the toolchain supplies its standard library. Run targets name a program build and accept an optional args list of literal strings, preserving order and empty strings. Runs may precede their builds. Configured arguments precede arguments supplied on the command line.

Names use the compiler reader’s identifier syntax and must fit one path component: no separators, colon, NUL, .. or reserved .snail-* names. Unicode and punctuation such as café.v2 are allowed. Build and run names have separate namespaces. Duplicate names within a namespace and duplicate fields are errors, including repeated empty lists. Unknown forms, unknown fields and executable metadata are rejected. An empty project list is valid.

Paths are nonempty strings relative to the project directory. They use / separators and reject absolute paths, empty components, ./.. components, backslashes, colons and NUL. The whole spelling "." may name a directory but cannot be an entry file. This lexical rule is not a filesystem sandbox: parsing does not resolve symlinks, check existence or read source files.

(snail-core project) uses the compiler’s located reader on Chibi and Wasm. parse-project takes text and its filename and returns ordered typed build and run lists. A resolved RunTarget contains a ProgramBuild; callers need not recheck its existence or kind. Builds are collected before runs are resolved, without a separate pending syntax tree. Paths and declarations retain locations; duplicate target errors identify both declarations, duplicate fields the second.

Output directories

Each build target produces one directory at PROJECT/build/TARGET. A static program contains program.wasm. A browser build adds the shared browser host and recursively copies public, if specified. Supply index.html there; no page is generated. Browser pages load ./program.wasm through host.mjs.

Public files cannot overwrite generated files or the root .snail-target ownership marker. .snail-publish is reserved at every depth. Public trees must contain only regular files and directories, include no project build output, and have no symlink components, including in the path to the public directory.

The public output is a relative symlink to a completed generation beside it. Rebuilds replace that link atomically after compilation and packaging succeed. Previous generations remain available to readers; automatic reclamation is not implemented. Resolve the link once before reading several files as one artifact. An ordinary file, directory or unrelated link at the output path is rejected and never removed by the builder.

The builder completes every selected target before returning a private candidate. Compilation or packaging failure changes no public outputs, including dependencies already built. Publication checks all destinations and prepares temporary links before renaming any. Each rename is atomic; several links are not one transaction. A later rename failure retains the published prefix and removes only unpublished generations. Publication consumes the candidate even on failure; callers must not discard it again. These guarantees concern complete outputs, not an atomic source snapshot or power-loss durability.

Development builds contain per-library modules, modules.json and runtime.wasm instead of linked program.wasm. Each generation is self-contained. Existing browser pages receive the module-manifest meta tag understood by host.mjs; the builder still does not invent a page.

Artifact bundles

(import
 (snail-core base)
 (snail-core artifact))

(: frontend ArtifactBundle)
(define frontend (artifact-bundle "client"))

;; A lookup in embedded data, without file IO or a runtime build.
(artifact-ref frontend "index.html")

The name must be a literal string naming a browser target in the same project. The macro constructs an ordinary ArtifactBundle containing ArtifactFile records with relative path and immutable bytes. artifact-ref returns Option Bytes; record accessors expose the file list. MIME types, routes and transport policy belong to serving code. No special backend representation or runtime artifact service is involved.

Requests arise during expression expansion, including macro uses, generated libraries and compilation of their producers. An unused macro template makes no request. Constructor names retain their definition scope, so importing only the macro, renaming it or shadowing constructors does not change its meaning.

The builder discovers dependencies from these requests. It traverses serially, reuses completed targets within one invocation and reports an active target as a cycle, such as client -> admin -> client. Missing or non-browser targets fail at the requesting source point. There is no source scanner or second dependency graph. Standalone source commands and the older development loop cannot resolve project artifacts.

Packaging and enumeration finish before a generation can supply an artifact. The inventory contains canonical relative paths sorted by path, including hidden and nested files but excluding the root .snail-target marker. Unreadable directories, symlinks and nonregular entries are rejected. Inventories name files in the completed generation directly, never through the replaceable public link. Repeated requests therefore use the same bytes throughout one invocation.

The compiler takes immutable snapshots of those bytes. Its input and cache boundary compares exact evidence before reusing an expansion; it never rereads backing files during expansion. Each new project attempt regenerates producer output and obtains current artifacts. Successful compiler caches may survive a discarded candidate because they retain evidence rather than paths into its output directory.

Compiler architecture

The compiler is a pipeline of five passes. Each pass consumes the previous pass’s output and nothing earlier:

text ──read──▶ located syntax
     ──expand─▶ core program      unique names, resolved types, entity tables
     ──check──▶ typed program     every expression typed, instances chosen
     ──mono───▶ monomorphic program
     ──wasm───▶ WasmGC text ──assemble──▶ module, linked with the Rust runtime

Static debug and release builds compile the whole program and merge the Rust services into one module; release increases optimization. Development builds compile one library per module and assemble with the wat crate in process.

Read examples/fibonacci.sld, run ./scripts/snail-tool wat examples/fibonacci.sld to see the WebAssembly it becomes, then read the passes in order. The language chapters describe the surface they implement.

The passes

All compiler modules live in src/snail-core/compiler/ and are written in typed Snail-Core: they import only (snail-core base), (snail-core table) and (snail-core io), so the compiler can compile itself. Chibi Scheme runs the same source through host/chibi/, which implements those three libraries and erases the typed forms.

Tables are immutable, so a pass can thread its state as a value rather than mutate it. How far each pass has gone differs: checking is a pure function of a program, while the expander and the backend still keep what grows over a pass in a Ref. Where a pass does thread state, its type says so — the checker’s check-library has no mut in its row — and where it does not, the Ref is allocated by the pass and never escapes it.

  • read.sld turns text into Syntax: atoms, identifiers and forms, each with a Loc. It is a recursive descent over a vector of characters; a line table recovers line and column only when a location is needed. It also decodes the bytes a generate-library producer writes, the encoding (snail-core syntax) defines, straight into Syntax. compile-error preserves the source point and message at terminal failure; the resident caller resets and consumes this private diagnostic cell.

  • core.sld defines the data the later passes share: Type (with effect rows), Scheme, class Evidence, the Expr tree, Patterns, declared entities (DataType, Class, Instance, Effect), the Program, the primitive table, TypeIds for wire cases and Any values from one FNV-1a hash, and the strongly connected components that the checker and the backend share. A Program is a value: the expander builds it in one cell, and the later passes read it without effects.

  • expand.sld is the only pass that knows binding forms. It loads libraries, replacing each include-bytes with the byte vector literal of its file, resolves every identifier through environments, and runs syntax-rules. Hygiene is renaming: each identifier a template introduces gets a mark that is unique to the expansion and, when unbound, resolves in the macro’s own environment. Top levels expand in two passes so definitions may refer to later ones; local bodies are sequential. Declarations become entities in the program tables, derived wire instances are generated as ordinary instance syntax, and expressions become core Exprs with unique names. Each library’s build key, the text of its declarations with its imports’ interfaces, lets a session replay it.

  • check.sld is Hindley-Milner inference with effect rows. Unannotated top-level definitions are checked in dependency order (core’s strong-components). Each instantiation records its type arguments and an evidence hole per class constraint. While items are checked, constraints are only recorded; a group of functions quantifies the metavariables nothing else still mentions. Libraries are checked one at a time; when a library is checked, its literals default, and each class constraint is solved once, filling its hole. Rows unify as sets with open tails. Matches are checked with Maranget’s usefulness algorithm.

    The pass holds no mutable state. A Checker is plain data, threaded through: each step takes one and hands back the one it learned in, so check-library’s type is a pure function from a program to a typed one. Its fields separate scope a sub-check may replace and the caller gets back — locals, givens, the confining row — from what it learned, which flows forward. One resumed says which is which.

    It also decides where a function may mutate at all: a function whose type forbids mut may still mutate storage it allocates, provided nothing it can reach from outside — its parameters, its result, or a name it reads — can hold mutable storage.

  • capabilities.sld establishes structural Send and Sync evidence from types and declared fields. The checker resolves these compiler-owned markers; user instances cannot override them. Ordinary arrows carry no proof about their captures.

  • handlers.sld checks that a handler’s retained environment is reusable and satisfies Send + Sync. It follows closure aliases, named helpers and selected method evidence to their captures and globals, while keeping invocation arguments and fresh local storage separate. Unknown callback provenance is rejected.

  • mono.sld starts from the program’s statements and values and copies each reachable definition once per ground instantiation. A method reference becomes the instance method its evidence names, and definitions that only rename another, such as (define + i64-add), are replaced by their target.

  • ownership.sld consumes affine locals once per path after specialization. Branches fork and intersect their live owners; a path drops a value when it has no remaining use. Only one-shot closures can own affine captures. Concrete records inherit their fields’ grade; existing generic parameters remain unrestricted.

  • wasm.sld chooses representations and emits WebAssembly text. Scalars are Wasm numbers. An enum whose cases have no fields is an i32; other data are GC structs, and enum cases are final subtypes of a base holding the tag. Sequences are GC arrays. Functions are closures: a struct whose first field is a typed function reference, with captured variables in a subtype. Tail calls become return_call. Each effect has a global handler stack. Each GC type sits in the smallest recursion group it can, ordered by name, so modules that declare the same type declare the same group. Compiled one library at a time, a module exports its monomorphic definitions under their global names and imports other libraries’ definitions. At the host boundary it stages final String and Bytes arguments and wraps exposed (Option Any) as a nullable opaque handover. Other exposed managed values keep their normal representation; the checker rejects affine parameters/results at that boundary. Dynamic handler globals are exported so the runtime can restore each task’s context across suspension.

  • session.sld keeps the compiler running between builds: the expander replays libraries whose declarations and imports’ interfaces are unchanged, and only libraries whose expansion changed are checked, specialized and emitted again. Cached emissions keep their monomorphic items so handler capture proofs can be rechecked against current helper bodies without regenerating unchanged Wasm text. Input generators and immutable artifact inputs are passed to each build; sessions retain only compilation evidence.

  • compiler.sld composes the passes, from a program’s path to WebAssembly text, or to one module’s text per library.

Separate compilation

The unit of compilation is a library (compile->modules in src/snail-core/compiler.sld). In its module:

  • each monomorphic definition is exported under its global name, as in snail-core.base:map;
  • each generic definition and alias the library uses is copied in, specialized at the library’s own types, as C++ instantiates a template in every translation unit that uses it;
  • each monomorphic definition of another library is imported from that library’s module;
  • each effect’s stack of handlers is a global that the declaring library’s module exports and the others import.

The checker settles each library’s types before it checks the libraries that import it, so a definition’s type comes from its own library and not from how an importer happens to use it.

A session (src/snail-core/compiler/session.sld) compiles a program again and again, as the development loop does, keeping everything in memory:

  • The expander replays a library, adding what it added before, when its declarations read the same and its imports show the same interfaces.
  • A library’s interface is what importers depend on: its imports and exports, every declaration except the bodies of definitions whose signatures are monomorphic, and its imports’ interfaces, since its generic code carries theirs into importers. It is hashed, as GHC uses interface hashes to avoid recompilation.
  • Each library’s checked items, type schemes and module text are kept under the expansion they came from. A build checks, specializes and emits only the libraries whose expansion changed.

Sessions retain compilation evidence, not a build’s providers or files. The input boundary defines how current inputs validate reuse.

Linking

A module imports each function of another library by the library’s name, and the loader (host/browser/load.mjs) instantiates the runtime, then the modules in program order, so every module comes after those it imports from. Calls across libraries are direct calls to imported functions.

WebAssembly treats two GC types as the same only when their whole recursion groups match. The backend therefore puts each type in the smallest recursion group it can have: the strongly connected component of the graph of references between types, ordered by name, with the groups it refers to emitted first. Every module that uses a type declares the identical group, and the engine’s canonicalization makes them one type across modules. A case of an enum without fields declares no type of its own, since only the modules that build or match it would declare one.

Snail-Core types are checked at compile time, each library against the interfaces it imports. Nothing checks them at load time: every module of a link comes from one build of one session, which recompiles importers whenever an interface changes. The engine still checks the WebAssembly type of every import, and a value that crosses as Any keeps its own check at type-convert.

Replacing a library’s module means linking it and every module after it in program order again (relinkProgram in load.mjs); the modules before it cannot import from it. That costs the instantiation of a few modules, and keeps debug code free of the indirection that a table of mutable globals, like ELF’s global offset table, would add to every call across libraries.

Loading modules at run time

(snail-core wasm) is dlopen and dlsym. It loads a module from a file, linked against the runtime, the program’s own modules and the modules loaded before it, by name. A function the module exports comes out as a Funcref, and unsafe-cast makes it a function of the type the context expects:

(let* ((cell (load-module "cell" "build/cell.wasm"))
       (run (ann (unsafe-cast (module-export cell "cell:run")) (-> I64 I64))))
  (run 41))

unsafe-cast is a primitive. It wraps the reference in a closure whose code casts it to the WebAssembly type of a function of the expected type and calls it. The cast is the only check: the engine traps when the WebAssembly types differ, and nothing compares Snail-Core types, so the unsafe assumption is visible where it is made. tests/programs/wasm.sld loads a module it writes itself.

The Node runner provides the wasm effect for both builds. A statically linked program exports nothing of its runtime, so what it loads can link only against the host. Browser hosts do not provide it yet: loading is synchronous, and a page reads files asynchronously.

Compiler inputs

The compiler entry points and build-session take the current generated-library provider and an ArtifactInputs value. make-session takes only library directories; a retained session owns evidence, never a provider closure:

(Table (List (Pair String Bytes)))

The table maps target names to ordered file snapshots. The native builder establishes unique, canonical relative paths sorted by path, with bytes loaded from one completed immutable output. Artifact expansion performs only memory lookups; it has no filesystem capability. Inputs belong to the current invocation, not the retained session. no-artifacts is an empty input table.

The project implementation owns target lookup, browser-kind checking, serial traversal and cycle detection. A missing input records a typed request and aborts the invocation. The native builder obtains the request only after the compiler stack has unwound.

Each library’s expansion records its direct requests and the exact file values embedded. Ordinary source/import eligibility is checked before replaying those requests, so an edited body can remove an obsolete dependency. A cache hit still looks up every recorded input and compares paths and every byte using the reader’s byte comparison. A missing input fails the build; changed contents cause fresh expansion. No lookup rereads a backing file.

An artifact-bearing expansion receives a fresh revision stamp from the session’s existing expansion counter. Replaying it preserves that stamp. Its compiled key is constructed once with its source key and stamp; its interface also includes the stamp, so changed bytes also invalidate generic importers that embed specialized bodies. These stamps are local to one session lineage, not persistent content identities. Failed builds publish no replacement session. This keeps bundle bytes out of string keys without trusting a content checksum.

Generated syntax is obtained before deciding whether to replay its library. The native project builder uses the same target traversal while compiling producer programs. Persistent producer caching remains separate work; current producer output lasts only for its enclosing compile.

Missing inputs and retry

The compiler cannot start a producer or build another project target. It records one pending CompilerRequest in memory and exits immediately. Artifact expansion and request signaling have no filesystem effects; library loading and generated-source IO still do. The host consumes the request after the compiler stack has unwound.

Standalone source builds service generated-library requests and reject artifact requests with their source location: an artifact needs a project build. Chibi and Node compiler entries publish the pending request in request.datum after unwinding. (snail-core compiler inputs) owns this grammar:

(generate "123456")
(artifact "client" "server/main.sld" 12 8)

Each attempt deletes the preceding request before starting the compiler. Only exit status 1 permits a request. Publication failures use status 2; any other exit or signal cannot fulfill work from a leftover file. Success with a pending request and a request already answered are errors. Scratch directories are created exclusively, so stale files cannot become inputs. Stderr carries only diagnostics. Standalone builds have no artifact resolver or reply accumulation.

The resident compiler returns the pending request in CompilerFailure.request; no request file is written or parsed. Each invocation clears pending request and diagnostic state before work. Trusted exits preserve the instance but return no successor Session. Both cold project builds and dev use this boundary.

Only host/native/project.mjs resolves named browser targets, including during producer compilation. It passes each completed target’s canonical file inventory to addArtifact, which reads the bytes once and returns an opaque immutable input table. A failed read returns no replacement; the preceding table remains usable. There is no artifact reply manifest or second parsing step.

Each logical compilation retains its table through retries and passes it to check, static or modules. Nested producers own separate inputs and scratch while sharing the builder’s active/completed target traversal. These opaque values belong to their originating compiler instance, like Sessions. Successful Sessions retain only the per-library evidence already used for cache reuse. Applications embed the bytes and need no runtime file access.

Resident compiler boundary

bootstrap/compiler.sld exposes this compiler to a resident native caller. host/native/compiler.mjs owns one Wasm instance and returns parsed project and input-request records, static text, or modular text with a successor Session. JavaScript retains Sessions and immutable artifact inputs as opaque typed references; it cannot inspect them and must keep them with their originating compiler instance. Each operation consumes its staged arguments before doing work, and projected records become visible only on success. Module text crosses directly through the runtime’s existing String staging buffer, alongside the other record fields; no temporary WAT files stand between the resident compiler and assembler. The host copies each field synchronously before another call can reuse the buffer. Generated-library scratch remains private to its compilation.

Each native call starts with empty diagnostic storage. The trusted compiler’s code-1 diagnostic exits follow resource cleanup and allow another call with the last successful Session. Arbitrary Wasm or imported-function traps retire the instance. This distinction comes from the runtime exit path, not from finding a familiar string or a diagnostic record. Failure to read diagnostic storage is terminal too: the worker cannot reuse a retired compiler. compile-error keeps its Loc and message in a compiler-private cell before the existing terminal panic. This explicitly unsafe bridge avoids adding an abort effect to every compiler pass; it does not prove the mutation pure. The serial native caller resets the request and diagnostic cells before entry, then takes and projects each after a trusted exit, before compiling a producer. Reset/projection traps retire the instance too. Module initialization precedes the first reset.

Native failures carry {severity, message, source}; source is null or {file, line, column}. Lines and Unicode scalar columns start at one. IO and producer-input requests have no source record; internal no-location sentinels are unlocated. An artifact wrapper preserves a failing child’s origin. Printed producer programs report their actual scratch source, while decoded generated syntax retains the recipe location already established by expansion. No source mapping is invented. CLI panic text is unchanged, and the compiler still stops at its first error. These operations are implemented and tested.

The resident check entry has no output filename. It expands, checks and specializes one source, including concrete handler and ownership checks, then discards the IR. It returns no emitted code or publishable candidate.

Supporting modules

PathResponsibility
src/snail-core/compiler/driver.sld, bootstrap/compile.sldTyped source invocation shared by both hosts; compilation and post-exit request publication are separate entries.
host/chibi/compile.scm, host/native/run-compiler.mjsProcess unwind boundaries; publish missing-input requests only after trusted exit.
host/chibi/snail-core/build.sld, scripts/link.scmBootstrap, standalone generated libraries, tool builds and Binaryen/runtime linking policy. Each compile has private scratch; rebuilding shared tools holds build/lock.
host/chibi/snail-core/process.sldSynchronous tool capture through private output files and a constant shell script with positional arguments. The parent reaps before reading or deleting files; stdin is EOF. This avoids Chibi 0.12 pipe-finalizer and sequential pipe-draining failures.
crates/runtime/Rust scalar services: math, Unicode, float text, console and file IO, panics. External resources use explicit handles; managed references remain engine-traced.
crates/assembler/, host/native/assemble.mjsThe wat crate compiled to Wasm for in-process text assembly. Programs do not carry the assembler.
host/native/run-snail.mjsNode/WASI program execution, in a worker with a tuned GC heap, plus module loading and the task scheduler. JSPI stacks remain finite.
src/, host/chibi/snail-core/, bootstrap/chibi-include/Every library that ships with the language, the compiler among them, and the separate stage-0 implementations of the three it imports. Stage 0’s only search root is bootstrap/chibi-include/, one symlink per library the compiler reaches.

Project traversal and publication belong to the project builder, watching and editor services to the development toolchain, and suspension/resource ownership to the runtime.

Following and checking a change

make test exercises emitted Wasm; make test-chibi runs the compiler source on Chibi. make bootstrap rebuilds from Chibi alone and compares the compiled compiler’s successive Wasm stages. Keep both hosts working: differences in integer overflow, table iteration or evaluation order must not change behavior. Backend changes need execution tests, not only successful compilation. make check checks formatting; mdbook build builds the book. The getting-started guide lists pinned tools and the Rust/browser checks.

Application model

(snail-core app) defines interactive browser applications as total functions over a state:

FunctionMeaning
init : seed width height -> StateThe first state.
update : State Input -> StateApplies one input: a key, a pointer, a HUD button, the screen’s size, or the milliseconds since the last display frame.
view : State -> FrameA frame of items, meshes and sprites, under a camera and a light, with the pictures their textures use.
hud : State -> (List Panel)The HUD’s panels of labels, texts, buttons, dials, links and images, shown again after each update.
save : State -> SaveWhat to store, with a wire encoding; it may be a record of how to rebuild the state.
restore : Save -> StateThe state again.

These functions operate on immutable state. define-application runs them inside one loop and the binder returned by reset:

(define-application Game
  (reset: default-reset)
  (init: new-game)
  (update: step)
  (view: draw)
  (hud: panels)
  (save: save-game)
  (restore: restore-game))

default-reset simply runs its argument. A custom reset returns a binder that installs effect handlers around startup and the whole loop; its result and effect row are quantified on the reset function, so the returned binder is monomorphic. The loop exports app_main : (Option Any) -> (Option Any). The task scheduler calls it through WebAssembly.promising; next-event drains the host queue and suspends through WebAssembly.Suspending when it is empty. FrameEvent carries elapsed milliseconds, InputEvent carries an Input, Hidden saves without stopping, and Reload saves and returns the state. The Frame type continues to describe drawing data.

DOM listeners enqueue input. The host keeps at most one pending display frame and coalesces consecutive pointer moves and resizes without crossing other input. Storage suspends for a whole load or store, copying save bytes before awaiting the browser. The loop saves every five seconds and when the page becomes hidden. Only a complete successful event commits its state; a trap leaves the preceding checkpoint available through app_state. Because time is input, an application decides how time passes and can stop it at any frame. Browser applications and headless tests need JSPI; the pinned Node 26 runs both packaging modes.

The loop and locally dispatched work share one runtime. The reset binder may dispatch background tasks; the root admits the tasks effect as well as mut, screen, events and storage. Suspensions preserve each task’s handlers. Reload lets the loop save and return, then cancels and drains remaining work before relinking. Module globals still initialize synchronously; dispatch application work from the reset binder or the running entry. See runtimes.

Frames are built from batches of items. The host keeps a batch until its version changes, so a still scene costs nothing to send; each item also carries where it was, and the host moves it toward where it is as the frame’s motion goes from 0 to 1. Batches may be solid, translucent, or revealed within a growing disc. The camera is perspective or orthographic.

Meshes and sprites are both items, drawn by one shader in the metallic-roughness model: a Material gives an optional texture, the albedo of a dielectric or the specular color of a metal, with metalness and roughness defaulting to 0 and 1. Textures are regions of Pictures, which the host composes from layers of encoded image files the program carries, recoloring each by swapping colors, and keeps until their version changes. A sprite is a textured quad, standing upright toward the camera in a 2.5D scene or lying flat under an orthographic camera looking straight down in a 2D one, and a texture region can be a strip of frames the host steps through by the clock. (snail-core sprite) poses figures from spritebooks of any sheet, and src/lpc uses it for the Liberated Pixel Cup character art.

HUD panels, built from widgets such as those label and button make, stack in columns from the screen’s corners, appear as dialogs, or sit in a banner across the top that lets input through but for its links. A widget’s tone can mark it suggested, the control to use next. The host keeps elements by key, so hover and focus survive updates, and reports presses and hovering back as input. recognize turns raw pointer events into points, taps, drags and pinches.

Siegework is the complete example, and Hearsay’s prototypes draw their residents as LPC characters on a 2D map.

Hot reload

This section describes the current browser application implementation. snail dev runs named program targets as subprocesses. The proposed durable reload workflow requires server and browser data to survive code replacement through durable records. The browser handover implementation below has not yet changed.

./scripts/snail-tool serve APP-DIRECTORY builds an application one library at a time, serves it at http://127.0.0.1:8000/, and builds it again whenever the application’s directory, the standard library or the browser host changes, compiling only the libraries the change reaches. Each page it serves relinks the modules that changed, and those after them, without reloading. Their globals start over; the application’s state crosses to the new program:

  • as it is, when type-test finds its type unchanged;
  • through save and restore when its layout changed. That is the contract a page reload already relies on, so an application whose save is a journal, as Siegework’s is, replays the journal under the new code;
  • not at all when the save cannot be restored either: the old program keeps running, and the page offers to start the new one over.

A failed build is shown over the page, and the old program keeps running. A changed stylesheet is fetched again; a changed page or host script reloads the page after saving. The event and reload boundaries below define checkpoint recovery, acceptance and manifest reconciliation.

./scripts/snail-tool serve does not build generated-library recipes. The project builder used by snail build, snail dev and make site does. Program targets run through the editor’s application controls; browser bundle/reload integration remains in progress.

./scripts/snail-tool watch SOURCE is the same loop for a program: it builds and runs the program again whenever its sources change, stopping a run still in progress once a new build succeeds. The development toolchain describes how builds stay this fast, and the browser editor that subscribes to the native project development service.

Task lifetime

Each invocation owns a task runtime. Normal root completion after initial presentation lets children finish. Root failure or return before that acceptance boundary closes the runtime. Explicit reload waits for the root’s checkpoint handover before closing it. Repeated stop requests share one handover result. This is application lifecycle policy; ordinary task completion does not cancel its children.

Event waits and storage use the same suspension bridge as task waits. Their cancellation signals belong to the invocation that began the call. Non-abortable storage may delay teardown until it finishes. An old completion cannot write into a replacement invocation.

Only one loop consumes application events. The runtime buffers input and coalesces frames; it does not own an implicitly shared current world. The root task owns event consumption, boot acknowledgement and checkpoint storage. Calling these operations from a detached task fails before any input or saved bytes change. A child can use task waits and separately bound I/O, but cannot replace the application’s checkpoint while its loop saves. Read-only seed and canvas dimensions remain available to all tasks.

Event and reload boundaries

host/browser/runtime.mjs owns an invocation’s queue and storage bindings; host.mjs retains the canvas, renderer, pictures and HUD across invocations. DOM listeners enqueue input rather than calling Wasm. Consecutive moves for the same pointer and consecutive resizes coalesce without crossing other input. Consuming a pending frame requests the next animation frame, so slow work does not accumulate ticks. HUD values update at most every 100 milliseconds and periodic saves occur every five seconds.

Event waits and complete storage operations suspend through JSPI. Save imports copy borrowed byte arguments before awaiting IndexedDB; a failed persistence operation still leaves an owned snapshot for migration. GPU drawing currently uses synchronous screen bindings. Only Snail-Core calls suspending imports: the Rust scalar services keep a linear-memory stack that JSPI does not switch.

Reload fences the old queue with Reload. Its accepted prefix finishes, the root saves and returns, and the runtime cancels and drains remaining tasks. New input waits for the replacement. Resize and an initial zero-time frame must finish before its first presentation is accepted; until then the old program and handover remain available for rollback. Cold startup, including a retry, finishes before a relink may stop it. A live migration never silently discards incompatible state; only a cold start may retry without an incompatible saved value.

After a trap, app_state exposes the last complete checkpoint. Serializing it runs the old entry with Reload first, avoiding the failing update or view. The consumed failing event is dropped; unread input remains. A cold first-frame failure also retains its owner so a source fix can resume it. A closure kept in the state can retain old code; applications storing functions must reconstruct them through save/restore when changing their implementation. Relinked module globals, including Scheme’s top-level variable cells, start over.

The reload client (host/browser/reload.sld, bound by reload-client.mjs) compares manifests and relinks from the first changed module. It serializes relinks and keeps only the latest waiting build. A build becomes the page’s current build only after acceptance. Missed events reconcile against the next complete manifest; a refused build is not retried automatically until the developer starts over.

Native application process owner

host/native/application-process.mjs implements ownership of one ordinary OS child for one run target. The native development service, HTTP API and editor use it. The public snail run command executes a cold project build with ordinary shell IO.

The owner accepts a latest launch request {build, entry, args} or null to stop. Each non-null request means a fresh attempt. entry names the accepted generation directly, rather than its mutable build-target link: development entries are modules.json, cold entries are program.wasm. The owner invokes the existing runner through process.execPath without a shell. It copies arguments and captures the caller’s working directory when constructed; the environment is inherited. Its stdin is EOF. Interactive execution remains available through snail run.

Replacement first terminates the old child and awaits its close event, including captured streams, then re-reads intent. New requests overwrite pending ones; Stop can remove a queued replacement while the old child exits. A terminal attempt is retained and never retried automatically. SIGTERM has two seconds before SIGKILL; the owner still waits for actual cleanup rather than allowing overlapping children. Signal failures are reported. OS cleanup or inherited pipes can delay closure; this is not a wall-clock shutdown guarantee or process-tree supervisor.

Status separates the desired build from the observed attempt’s build, PID, phase, exit code/signal and error. It retains only the latest attempt and the last 64 KiB of each output stream, decoded for display; truncation can split a UTF-8 character. There is no history or ordering guarantee between stdout and stderr. Observers receive current snapshots, may request or close synchronously, and must not throw. Permanent close prevents late launches, returns one stable cleanup promise and removes observers after cleanup.

Start/Restart policy and accepted-build decisions belong to the project service. Applications are assumed not to spawn additional OS children, and accepted generation files must remain available for the child’s lifetime. The current project publisher retains those directories.

Development run targets

developProject exposes startRun, stopRun and restartRun for names from the last accepted project.datum. Targets begin stopped. Start sets run intent and is idempotent while that intent remains set, including after exit or failure. Restart explicitly requests a fresh attempt from the latest accepted build. Stop clears intent immediately; a compilation already in flight cannot restore it.

Acceptance compares the run’s selected build target, literal arguments and complete output digest against its last issued launch. Changed wanted targets request a replacement; unchanged targets keep their child and actual build ID. A compiler cache hit is not this evidence: a discarded build may already have warmed the cache. Failed or superseded builds do not change accepted launch definitions or running children. Start remains usable against the last successful build while the current compilation fails.

Status includes each run’s availability, intent, desired build and observed attempt. Removed names stop and retain their owner and last attempt until session shutdown; reintroducing a name leaves it stopped. Retaining the owner prevents an old stopping child from overlapping a new child with the same name. There is no attempt history or automatic crash retry. Closing the development session waits for all owners as well as the compiler and file observer.

Proposed durable reload

The process controls above are implemented. Durable application data and browser reload remain application work. Process replacement alone preserves no managed state; killing a process cannot promise cleanup or a final commit.

The typed state prototype proposes durable base tables and disposable, read-only cached views. Ordinary entry startup opens the same store, recreates sockets/windows and requests views. There is no application resume method, heap snapshot or state-transfer IPC. Managed objects, closures and suspended computations never cross code versions. This API remains under review.

Failed startup retains stored records and reports the failure, without automatic rollback to the old running loop. Reverting code does not undo data writes. The state chapter defines transaction outcomes, explicit schema migration and cache validity; process replacement supplies none of those guarantees by itself.

Keep storage identities stable, database files/journals outside source watching and output directories, and independent browser stores separately named. One concrete server database binding is sufficient; browser storage needs its own transaction and commit proof. The existing save/restore implementation remains until the replacement is reviewed and tested.

Delivering new browser code

The proposed HTTP bundle helper keeps the server origin stable across restarts and serves files, development manifests and reload events. Reconnect supplies the latest manifest. Pin assets to that manifest’s build: a request for B must not silently return C’s bytes. If B is unavailable, reconcile and retry using the latest manifest. Network failures and incompatible stored data are distinct; neither permits clearing data silently.

Advertise a bundle only after its server can serve it. Multiple frontends use separate prefixes and manifests. Production serving omits development endpoints. Compilation does not prove client/server API or durable-schema compatibility. The editor remains an independent client of development services; application browser windows are ordinary clients of their server.

Development toolchain

Snail-Core can link a program statically or link its libraries when it starts. Static builds compile the whole program, optimize it and merge the Rust runtime into one module. --release increases optimization; it does not select linking mode. Development builds keep each library as its own module, with the runtime as a separate instance.

CommandBuild
snail build, snail run, make site, internal source compile/runStatic; build accepts --release for full optimization
snail dev, ./scripts/snail-tool serve, ./scripts/snail-tool watch, make ideSeparate development modules

The public commands operate on projects. Compiler contributors can use ./scripts/snail-tool run SOURCE, wat SOURCE, schema SOURCE or bootstrap. ./scripts/snail-tool compile -o FILE SOURCE writes a raw Wasm file and linker intermediates; it does not provide project publication guarantees. Source-level run uses a private build directory by default and removes it when the process finishes.

The project chapter defines project.datum, named targets, output directories and artifact bundles.

snail run PROJECT RUN-TARGET [ARGUMENT ...] builds the referenced program in debug mode and runs that exact completed generation. Configured arguments come first; arguments after the target are passed through unchanged. The application inherits the caller’s working directory, environment and terminal IO.

After compiler and scratch cleanup, the native command replaces itself with the runtime using Node’s process replacement. Its PID, exit status and signals therefore belong to the application. This uses the existing Node 26 POSIX host. Compilation and generator execution retain the cold builder’s synchronous behavior.

snail check PROJECT checks every declared build entry using the same expansion, type/effect checking, specialization and ownership passes as a static debug build. It does not emit or execute those entries. Specialization still matters: selected typeclass methods can reveal unsafe handler captures or affine uses. Checking does not invent specializations for unused generic definitions.

Generated-library producers execute normally. An artifact reference requires a real temporary build, including browser packaging, even if that target’s code was checked earlier. The same traversal detects cycles through checked roots. All temporary artifact generations are discarded when checking ends, including on failure; check never publishes them. A root-only check creates no project output directory. Artifact dependencies can leave an empty build directory. The command stops at the first failure and is silent on success.

From an edit to a build

snail dev PROJECT starts one native development process and prints its editor URL. --port PORT selects a port (8000 by default; 0 chooses a free one). make ide runs it for Siegework. The browser owns open buffers and drafts; the native service owns disk files, observation and compilation.

editor Save or external writer -> disk -> shared observer
                                           |          |
                                      compiler     editor reads
                                      worker       saved files
                                           |
                                  candidate + diagnostics
                                           |
                              reconcile and accept or discard
                                           |
                                output links + UI status
                                           |
                              affected wanted run targets

Save only writes disk. The observer triggers compilation regardless of which application wrote the file. Manual Build also requests an attempt. Unsaved text never enters compilation. Each attempt starts with fresh diagnostics while retaining valid compiler caches; an error does not display stale diagnostics from the preceding attempt.

host/native/project.mjs supplies the same serial builder to cold commands and the resident service. A cold command owns its compiler directly. Development uses one persistent compiler worker so synchronous compilation and tool IO cannot block the HTTP service or disk observation. The worker retains a successful compiler Session per target; changing its entry or ordered library roots starts a new Session. Producers run again each attempt. The compiler chapter explains reuse: a change to a monomorphic function body recompiles its library; an interface change also recompiles importers. A cold start has no retained compiler cache.

Observation and acceptance

host/native/watch.mjs supplies one disk observer with independent subscriptions. Construction installs recursive watches before the first inventory read; callers subscribe and then await reconcile(). Named native notifications are hints. Periodic inventories compare file identity, size and nanosecond modification/change times, recording symlinks without following them. Directory identity omits child timestamps so writes inside explicitly excluded output/data directories do not invalidate their parents. Exclusions are exact paths, not every folder named build.

Missing names and lost watches report uncertainty; they do not by themselves mean source code changed. The caller decides which observed paths affect compilation. Missing roots are checked again, and replaced roots get new watches. A scan stops at the first unreadable root and retains the previous complete inventory. It reports one uncertainty for the failure episode, then one recovery after a complete scan succeeds; failures moving between roots do not publish partial inventories. reconcile() requests a fresh scan after any active one without waiting for global quiet and returns its regular filenames, detached from the internal inventory. Closing suppresses later notifications and waits for an active scan to finish. There is no content cache or filesystem snapshot. Metadata polling catches ordinary local writes but cannot guarantee detection on filesystems that hide changes behind unchanged or coarse metadata. The project development parent consumes this observer; the old commands still use their existing watches until command/application integration.

host/native/project-develop.mjs owns serialized project attempts and one current status, with independent subscriptions. host/native/project-worker.mjs owns the resident builder. Each attempt starts with an ID and empty diagnostics. The parent keeps distinct observed paths only for the active attempt, including paths outside the previous project’s public inputs. A reply carries the newly parsed description even after a later build failure. The parent reconciles disk, tests those paths against the new description, and publishes synchronously only if no observed input change superseded it. Otherwise it discards and coalesces a successor. Successful compiler cache entries remain useful in either case; accepted application files remain available after a failed build.

The builder also hashes each completed target’s sorted relative filenames and bytes, including runtime modules, artifacts and public files. File boundaries are length framed; generation directory names and publication bookkeeping are excluded. The development owner compares that digest and the run’s arguments with its last issued launch, independently of compiler cache counters. An unchanged child keeps its earlier generation and build ID; explicit Restart uses the latest accepted generation. Paths observed through command-line arguments therefore still belong to the actual run. Native runner/tool changes require restarting the development service. Application processes define the controls and their interaction with acceptance and shutdown.

Source inputs currently include the project description, .sld libraries, declared entry/public paths and browser runtime assets. Ordinary data writes do not request builds. Arbitrary producer file reads are not tracked yet: use manual Build for those inputs. Unnamed watcher hints do not themselves declare source changes. The observer and compiler do not claim an atomic filesystem revision.

Before sending work, the parent reserves a private generation-name prefix under the project’s build directory. A worker result relinquishes that attempt; exit relinquishes any unfinished one. This lets the parent remove only abandoned private generations, including ones a crashed worker never returned. Publication consumes ownership even when some target links moved before failure, so those generations must never be swept afterward. Shutdown suppresses acceptance, closes observation and requests graceful worker exit, then termination. Synchronous native operations can delay actual exit; cleanup waits for it. Arbitrary compiler traps retire this development session; there is no automatic crash loop.

Project file service

host/native/project-files.mjs owns one physical project directory and three native operations: filter the shared observer’s filenames, read text with a revision, and save against that revision. Listing awaits a fresh complete reconciliation; a failed scan rejects the request instead of returning stale names. There is no second directory traversal. No contents or revisions persist in the service. The editor holds its unsaved text and the revision of its saved baseline; the shared disk observer remains the only source of file-change notifications. Save has no compiler call.

The text subset is valid UTF-8 without NUL, up to 8 MiB, preserving BOM, newline bytes and a missing final newline. Listing selects regular files ending in .sld, .scm, .datum, .html, .css, .js, .mjs, .json, .svg, .txt or .md; reading establishes content validity. Other suffixes and extensionless files are outside this first editor API. Paths reject traversal and symlinks. .git and the root build directory are excluded; src/build remains an ordinary source directory. A pipe cannot block the service while it checks for a regular file.

Revisions describe file identity and exact bytes. A save compares the current revision, writes an exclusive temporary file under project/build, compares again, preserves ordinary permission bits and renames onto the destination. This keeps temporary writes out of source/public trees and compiler inputs. It returns evidence for the submitted bytes, even if another writer subsequently changes the file. Failed writes or rename leave the old destination intact; saves across a nested filesystem mount fail with EXDEV instead of copying over the destination.

Reads/saves are bounded synchronous operations, so this service’s saves cannot interleave. Rename is atomic; comparison plus rename is not. An external writer can still race after the final comparison, and pathname checks do not constitute a sandbox against a hostile local process. This API makes no crash-durability, ACL, extended-attribute or hardlink-preservation promise. It edits existing files; creation, rename, deletion, merging and automatic conflict retry are omitted.

Project HTTP service

host/native/project-http.mjs connects the project attempt owner and file service on 127.0.0.1. Its native API returns the URL, development session, completion promise and an idempotent close operation. host/native/dev.mjs supplies the dev command. The same HTTP service serves a fixed list of trusted editor assets and the formatter; it never serves project HTML or arbitrary filesystem paths.

RequestResult
GET /api/projectProject name, canonical directory and candidate relative filenames.
GET /api/file?path=...{text, revision} for one file.
PUT /api/file?path=...UTF-8 text/plain plus one strong quoted If-Match revision; returns the submitted snapshot or a conflict (409).
POST /api/buildExplicit Build, with an empty body. Save never calls this operation.
POST /api/run/start?target=...Start an accepted run target; empty body, 204 acknowledgement.
POST /api/run/stop?target=...Stop a retained run target, including a removed name; empty body, 204 acknowledgement.
POST /api/run/restart?target=...Fresh attempt for an accepted run target; empty body, 204 acknowledgement.
GET /api/eventsSSE: current compiler status (phase, attempt id, diagnostics), current runs array, then files invalidation; subsequent changes to each.

Reconnect obtains both current snapshots and an unconditional file invalidation; there is no event archive or replay cursor. Each run includes its target, availability, intent, desired build and observed attempt, with PID, build, phase, exit outcome and bounded stdout/stderr tails. Compiler and run frames are sent independently when their respective values change: output does not replay compiler diagnostics. Build directories and worker identity stay in the native session. Each stream holds at most one pending write; a later event closes a reader whose previous write still needs drain. An idle blocked stream stays open until another event or connection closure, within the 64-stream limit. Request bodies are limited to 8 MiB and decoded strictly as UTF-8. File operations remain responsive while the real compiler waits on a source pipe in its worker.

The service requires its exact Host authority and rejects a supplied foreign Origin or fetch context. Mutations require the exact Origin. It provides no CORS access. This boundary keeps other websites from reading or writing project files; it is not authentication against other native processes on the same machine.

The native development owner admits run controls using the last accepted definitions, even while compilation fails. HTTP maps its rejection of unknown names, and Start/Restart for removed names, to 404. The 204 response acknowledges applying intent; it contains no process snapshot that could overwrite a newer SSE observation. A lost response leaves the outcome unknown and must not cause automatic retries. Closing an HTTP or event connection does not stop an application. Closing the development service does.

Closing removes subscriptions, closes HTTP connections and pending request bodies, and awaits the development owner. A body that finishes after shutdown cannot save. A fatal compiler/service failure closes the session and rejects its completion promise; a failed bind also disposes the session it created.

Browser editor

The editor has a file browser and split panes. (snail-core format) runs in the page for Scheme highlighting, Tab/Enter indentation and format-on-save (.sld, .scm and .datum). Other supported files use ordinary text editing. Browser textareas use LF in edited text; the native file API preserves exact bytes until saved. The browser has no compiler, copied project, directory handles or persistent project storage. Closing/reloading a page with drafts asks before discarding them.

The Applications panel selects one run target and provides Start, Stop and Restart. It shows requested and observed builds, PID, exit outcome and separate stdout/stderr tails. Output is literal text and preserves scroll position; it does not refresh files, panes or compiler diagnostic evidence. Removed names remain visible and stoppable. Application pages remain ordinary clients of their own server.

Run snapshots arrive only through the current event connection. One command may be in flight; its response changes feedback, never the observed process state. A lost response or timeout reports an unknown outcome without retrying. That message names its command and target, survives output, selection and reconnect, and clears on the next deliberate run command. An empty run catalog does not hide pending command feedback. The controls are not a terminal, log archive or debugger.

Each open buffer owns its draft and saved {text, revision} baseline. A file read updates a clean buffer; a dirty buffer keeps its draft and reports a changed disk revision. Saving sends the baseline revision and one captured string. Success advances the baseline without replacing later typing; an older in-flight read cannot overwrite that evidence. Every completed save refreshes from disk, including failures whose writes might have reached the server. There is one save in flight per buffer, with no autosave or queued retries. A formatting failure still permits saving the original draft, including incomplete code. Explicit operation failures remain visible through background reads until the next Save, Format or Reload action; reading successfully does not prove saving succeeded.

Reload discards a confirmed draft immediately to its saved baseline, then reads disk. A failed read leaves that baseline and an error; later typing is a new draft and survives the read. A current file read returning 404 clears a clean/loading buffer; a dirty deleted file remains selectable with its text. Saves cannot recreate it. The UI handles an empty project and later discovers files through the same observer. A project listing alone never discards a buffer: lists and reads are different observations, with independent ordering.

Build status replaces the preceding diagnostic collection at each attempt. The page shows messages and compiler-provided locations in a separate panel. A location in an editable project file opens that file. A fresh file read after the failure binds a one-character squiggle to that observed revision. Later reads withdraw it until the same revision is confirmed; Save invalidates it. Drafts and conflicts suppress it. This is an editor observation, not proof of the compiler’s exact filesystem snapshot; the watcher rejects observed supersession without claiming an atomic filesystem revision. Columns count Unicode scalars in the original file, converted to textarea UTF-16 offsets and LF display text, including CRLF files. Generated scratch and external library locations remain messages rather than being mapped to another file. These are source points, not guessed expression ranges or full source spans. Manual Build requests compilation; Save writes disk and leaves scheduling to the shared observer. Reconnecting the event stream supplies current status and file invalidation without a replay log. An exact repeat of the retained status keeps its diagnostic observations, including invalidation by Save or a changed read. It still restores status presentation and refreshes files. Going back online replaces the stream too, covering file reads that failed while an existing event connection survived.

Existing application development loop

dev currently compiles and edits projects. Application execution and reload still use ./scripts/snail-tool serve APP-DIRECTORY and ./scripts/snail-tool watch SOURCE [ARGUMENT ...]. Their loop is src/snail-core/develop.sld, exposed by bootstrap/develop.sld; it retains a compiler session, debounces changes and publishes manifests. It cannot run generated-library producers or resolve project artifacts. Use project builds for those inputs. The application chapter defines current reload behavior.

host/native/develop.mjs owns watches, timers, HTTP and application children; develop-worker.mjs owns the compiled loop, assembler and immediate filesystem helpers. Watch, schedule, serve, broadcast and run commands cross to the parent. Watches are installed before the first compile. HTML, manifests and module bytes are staged beside their destinations and renamed, providing complete individual files rather than the project builder’s generation publication. The loop keeps its latest and previous module sets. Shutdown closes native resources and the worker and prevents late commands from starting replacement children.

Application and networking integration

The native service, HTTP API and editor implement application run controls and replacement after accepted builds. Durable state and browser reload integration remain proposed work. Production serve and package installation still need their own contracts; neither is a current public command.

The concrete socket effects provide TCP streams and UDP datagrams to native CLI programs and replacement handlers for tests. Their native owners define bounded buffering, cancellation and close behavior. UDP keeps message boundaries without guaranteeing delivery or order.

The HTTP library provides bounded parsing, response framing and one complete exchange over TCP. Routing and bundle/reload serving remain to be added. The project service currently uses Node HTTP; sharing a Snail-Core HTTP implementation with applications is future integration. Use the existing runtime suspension bridge and test real sockets as well as intercepted operations. Networking work tracks this boundary. No transport hierarchy or generic provider framework is needed. TLS, HTTP/2 and deployment infrastructure are separate work.

Process boundaries and future debugging

BoundaryInformation crossing it
Editor ↔ development processFile reads/saves, build requests, compiler diagnostics, run controls and current process/output snapshots.
Development process ↔ compiler workerAttempt IDs, project descriptions, results, diagnostics and candidate output identities. Compiler objects and WasmGC references stay in the worker.
Development process ↔ application processEntry, arguments, environment, working directory, standard streams, exit status and OS controls. Each process lifetime names its build and run target.
Browser ↔ application serverOrdinary HTTP; build-specific reload delivery is proposed.

A future debugger attaches to a run’s process and build. Its inspector endpoint expires with that run. run-snail.mjs executes Wasm in a Node worker: attachment must reach that agent, not just its wrapper. Debug commands use the inspector connection rather than compiler requests or parsed stdout. The development service stays responsive while an application is paused. Source mappings and breakpoints require a separate compiler/assembler proof; debugger support is not implemented. Editor and debugger work tracks these extensions.

Build-tooling and package decisions track cold-cache reuse, producer-input observation and package installation.

Typed tables and cached views

Proposed API, not implemented. The application model proposes preserving server and browser data through durable typed tables and disposable views. This chapter develops that state model for review; its forms are illustrative library syntax, not runnable examples. Storage semantics, checking rules and performance need proof before replacing current save/restore. The state issue tracks that review.

One owner for retained state

All base tables are durable. A view computes a value from a consistent database snapshot and explicit arguments; caching that value is optional. There are no independently mutable volatile tables, application resume methods or automatic heap migrations. A new program starts at its ordinary entry point, opens its store, recreates platform resources and asks for the views it needs.

ConceptRepresentationLifetime
EntityStable typed key, shared by its component tablesAs long as the application retains the identity
ComponentOrdinary struct/enum stored as a table rowCommitted database data
ViewTyped computation over a snapshot and explicit argumentsCached result may be evicted at any time
System/actionOrdinary computation inside a write transactionOne invocation
ResourceSocket, window, renderer or other ordinary runtime resourceIts owning runtime/task

Split components by how they are used, not by a rule requiring a table for every field. Key types distinguish domains; they do not prove that an entity or component exists. Missing-row results and referential constraints remain explicit. IDs are values, not array positions or pointers.

Schema from ordinary types

(struct EntityId
  ((value I64))
  (deriving Serialize Deserialize))

(struct Paragraph
  ((text String)
   (font-size F32))
  (deriving Serialize Deserialize))

(struct Document
  ((title String)
   (paragraphs (Vector EntityId)))
  (deriving Serialize Deserialize))

(define-store Notes
  (table documents EntityId Document)
  (table paragraphs EntityId Paragraph))

define-store names one database schema and generates concrete operations. The prototype starts with lookup, insert, replace, delete and scans. It does not compile arbitrary Scheme predicates into SQL. A server may use a concrete SQL database and a browser may use IndexedDB behind the same record operations; this is one storage contract with platform bindings, not a provider/plugin API. SQL indexes and richer typed queries are separate additions.

Illustrative generated operations use existing effect syntax:

(effect NotesRead
  ((paragraphs-ref (-> EntityId (Option Paragraph)))
   (documents-ref (-> EntityId (Option Document)))))

(effect NotesWrite
  ((paragraphs-insert! (-> EntityId Paragraph Bool))
   (paragraphs-replace! (-> EntityId Paragraph Bool))
   (paragraphs-delete! (-> EntityId Bool))))

Insert returns false if the key exists; replace/delete return false if it is missing. Other tables receive the same operations. Ordinary helper functions can combine them. Scans have a documented stable key order or require explicit sorting before order affects a result; storage iteration order is not an input to application behavior. A durable document referring to a paragraph must check existence and enforce deletion policy in the same write transaction.

Rows are a deliberately limited data subset: scalars, text, immutable vectors, structs and enums composed of supported data. No procedures, mutable references, opaque managed values or platform handles. Durable keys/rows additionally need codecs. Serialize, Send and Sync each serve their existing purpose; none alone is a certificate for this complete storage contract. This does not add a general-purpose Immutable marker.

Reads and writes have scopes, not exposed tokens

The examples use the existing (Result A E) from (snail-core base), with (Ok A) and (Err E). The following signatures are schematic generated API contracts:

(: notes-read
   (All (A)
     (-> (-> A ! NotesRead)
         (Result A StoreError) ! store)))

(: notes-write
   (All (A E)
     (-> (-> (Result A E) ! NotesRead NotesWrite)
         (Result A (WriteError E)) ! store)))

notes-read binds one consistent snapshot for the whole computation, including nested views. notes-write binds one transaction and reads its own staged writes. Both execute their action once; the first version has no automatic retry or nested transactions. A conflict is a result the caller may explicitly retry. Writes require serializable isolation, not merely snapshot isolation: checks across several rows must not admit write skew. The backend must either serialize writes or detect conflicts that would violate this contract. Pure helper calls are permitted. Networking, rendering, timers and task dispatch are absent from the action’s closed effect row. Storage operations themselves may suspend; an unrelated application wait cannot keep a transaction open.

Returned values must satisfy the structural data restriction too. No cursor, transaction token, borrowed row or effectful closure escapes. Static checking must also reject mutable hidden captures/globals, using the same kind of reachability checks as ordinary handler installations. The arrows above do not, by themselves, establish those restrictions. define-view and named action declarations are possible checking boundaries; this is a compiler gap to settle before implementing the API.

There are no top-level defaults for NotesRead or NotesWrite. Their handlers exist only in the appropriate scope. A test can intercept the underlying store effect; runtime implementations own the actual synchronized storage. A reusable, shareable handler is not itself a lock or an isolation guarantee.

The simple operation results above require a runtime-owned recoverable abort boundary: an engine failure aborts the enclosing action and becomes its StoreError, rather than being reported as a missing row or false result. Ordinary value-returning effect handlers do not establish this boundary. Implementing it is explicit runtime/compiler work; an alternative is to expose typed storage errors on every operation. User-defined control handlers are not required, but pure library expansion of the runner is not yet established.

An edit action might be:

(: replace-text
   (-> EntityId String (Result Unit EditError)
       ! NotesRead NotesWrite))
(define (replace-text id text)
  (match (paragraphs-ref id)
    ((None) (Err (MissingParagraph id)))
    ((Some paragraph)
     (begin
       (paragraphs-replace! id (Paragraph-text-set paragraph text))
       (Ok (unit))))))

;; Called from an ordinary event handler with the store effect available.
(notes-write (lambda () (replace-text paragraph-id edited-text)))

The read and replacement share a serializable transaction. This action overwrites the text; it cannot detect that a client prepared it from an older UI snapshot. To reject stale-editor writes, pass an expected row version/value and compare it inside the action. Transaction isolation alone does not supply that policy. Domain errors roll back; storage errors remain distinct. A trap also aborts unpublished work. Once commit succeeds, later cancellation cannot undo it. A lost response may leave the caller uncertain whether the operation committed; no exactly-once delivery guarantee is implied. Send a response or present UI only after the write result, outside the transaction.

Views are ordinary typed computations

Assume Layout is ordinary immutable data and typeset is a pure function:

(: paragraph-layout
   (-> EntityId F32 (Option Layout) ! NotesRead))
(define-view (paragraph-layout id width)
  (match (paragraphs-ref id)
    ((None) (None))
    ((Some paragraph)
     (Some (typeset paragraph width)))))

(: document-layout
   (-> EntityId F32 (Option (Vector (Option Layout))) ! NotesRead))
(define-view (document-layout id width)
  (match (documents-ref id)
    ((None) (None))
    ((Some document)
     (Some (vector-map (lambda (paragraph-id)
                         (paragraph-layout paragraph-id width))
                       (Document-paragraphs document))))))

These are cacheable read functions, not mutable derived tables. Calling a view with caching disabled must produce the same result. Arguments such as viewport width, locale or a font asset’s identity are explicit. There is no hidden clock, random generator, mutable capture or renderer dependency. Changing database rows requires a write action. Changing parameters selects a different view result. Changing code discards the cache.

One frame/response obtains all related values through one read scope:

(match (notes-read (lambda () (document-layout document-id viewport-width)))
  ((Ok layout) (present layout))
  ((Err error) (show-storage-error error)))

Presentation happens after the read scope ends. No database transaction spans the wait for the next frame or request. Nested views share the snapshot; they do not begin independent reads. The result is internally consistent, not a promise that no newer commit exists by the time presentation finishes.

Cache correctness before fine-grained invalidation

The first cache key includes the database identity/incarnation, its revision, the code generation, the view identity and argument values. Initially use an exact structural encoding with bit-preserving floating-point arguments, including signed zero; numeric equality alone can merge observably different inputs. Hashes require collision checks against that encoding. Read the revision inside the same snapshot that supplies the rows. A view that started under revision 8 may finish after revision 9 commits; its result remains an answer for revision 8 and cannot populate revision 9’s cache.

Every managed write advances the revision in the same database transaction. Multiple server processes therefore agree on it. Replacing/restoring a database also changes its incarnation or invalidates every associated cache. If arbitrary external writers bypass this protocol, disable reuse across read scopes until the backend supplies reliable change detection. A local counter is insufficient.

Initially any committed write makes all earlier cached results ineligible for new snapshots. Keep caching bounded and evict freely; old read scopes retain their own consistent result. Inside a write transaction, bypass shared view caches because a committed revision does not identify its staged writes.

This coarse policy can be expensive. Later table-version tracking can avoid invalidating unrelated views, including dependencies inherited from nested views and missing-row lookups. Row-level dependencies, incremental query maintenance and subscriptions are not prerequisites for the first contract.

Startup, reload and schema changes

The runtime opens the configured store and binds store around the ordinary entry point. A new application may perform a transaction that inserts missing initial records. There is no special init or resume method, and no second authoritative in-memory world to reconstruct. Sockets and windows are created by the entry point in the normal way. Views are computed when requested.

On code replacement, finish or abort the old instance’s transactions according to their commit boundary, close its resources, discard its view cache and start the new code against the same durable tables. No Wasm object or continuation crosses. If startup fails, retain the database and report the failure; automatic old-code rollback is not promised, and reverting code cannot undo committed writes. Database files and journals stay outside compiler watch inputs and build directories. Independent browser applications have distinct store names.

Changing a view implementation or result type needs only cache invalidation. Changing a durable row schema requires an explicit migration. Existing wire TypeIds detect mismatches; they do not migrate data. Keep stable table identity and schema versions separate from compiler type hashes. Refuse incompatible data without clearing it. Automatic migration inference is outside this design. Initially migrations are offline/exclusive: stop every store user and exclude concurrent access before changing the schema. Old instances must not continue using an incompatible schema. Online migration and fencing live instances are separate work; checking only when an instance first opens is insufficient.

A successful write means the backend completed its configured transaction commit, preserving records across code/process replacement under that platform’s storage contract. It is not an identical power-loss or retention promise on every host; browser storage eviction remains a platform policy. Do not report success merely because a write request was accepted or a RAM cache changed.

Performance and implementation boundaries

Reads may use memory and indexes; durability does not imply a physical disk read for every lookup. Durable writes still cost work. A game’s live positions can be derived only if durable inputs, a journal or checkpoints reconstruct them. Otherwise they are authoritative data that must be stored. View caches cannot preserve facts absent from those inputs.

Batch work and return ordinary typed values for inner loops. Do not promise ECS array-loop performance from one host call or SQL query per entity. An actual SQL database plus generated typed record access is enough for the first server proof; this project does not need its own database engine. Browser transaction lifetime, cancellation and commit failures must be proved against its actual storage API, not inferred from the server implementation.

Existing foundationWork still required
Structs/enums, generic functions, concrete effect operationsSchema/library forms and generated typed operations
Serialize/Deserialize and schema-sensitive TypeIdsStable database schema identity and explicit migration boundary
Closed effect rows and handler capture checksA precise structural data/capture check for cached views and actions
JSPI task suspension and cleanupRecoverable storage-abort boundary, serializable writes, consistent snapshots and commit/cancel ownership
Compiler generation identityBounded view caching tied to database identity and snapshot revision

No associated types, borrow checker, user-defined control handlers or new application typeclass is required by the surface model. Whether its proposed forms can expand entirely to existing checks is still to be proved. Transactions and cache coherence remain runtime work; a type annotation cannot supply them.

Review and verification

Before adopting this API, prove: a typed key/row mismatch is rejected; a view cannot write or depend on mutable hidden state; a write cannot perform network I/O; an engine failure reaches the runner as an error; an aborted write exposes no changes; writes reject write skew; migration excludes old instances; two views share one snapshot; a late old view cannot contaminate a newer revision; another process’s write invalidates reuse; database replacement cannot collide with an old cache; and code reload preserves committed data while permitting view result types to change.

Measure whole-frame/request cost, transactions at realistic write rates, cache misses after writes and first-view latency after reload. These determine whether coarse invalidation is sufficient. These measurements and checks are acceptance criteria for adopting the API, not claims about the current runtime.

Related prior art: SpacetimeDB tables and transactional reducers combine typed records and transactions; Flecs queries illustrate component-oriented access. The prototype borrows those organizing ideas without adopting their entire runtimes or claiming their performance.

Notable omissions

Snail-Core is deliberately smaller than its first plan. These parts were dropped because they would cost more than they give the programs Snail-Core runs today; each can return if a program needs it.

Cut from the plan

  • The dependent elaborator. Generic code is served by Hindley–Milner checking with typeclasses, effect rows and monomorphization. Normalization by evaluation and implicit arguments would add type-level computation no program uses. The implemented ownership subset assigns grade 1 to Once and concrete records containing it; it has no general dependent elaborator or grade-polymorphic containers.
  • Checked interfaces. Separate compilation hashes each library’s interface to decide what to rebuild, but stores none: the compiled compiler checks Siegework, the largest program, in a fraction of a second, so an interface file would add a format and its invalidation without a measurable gain. Wire schemas come from the checked program.
  • The WGSL backend. The renderer’s shaders are WGSL text that shares no structs with Snail-Core code beyond fixed instance and material layouts. The runtime and graphics plan proposes shaders written as S-expressions.
  • Actor destinations. Tasks schedule one-shot closures. Mailboxes, request/reply protocols and network transports are application concerns.

Simplifications made on the way

  • Local bodies are sequential: a local function may call itself but not a later local definition.
  • Only unannotated top-level functions are generalized; other definitions are monomorphic unless they have a signature.
  • Effect labels take no type parameters, and operations are monomorphic.
  • Classes have no superclasses and no higher-kinded parameters.
  • Wire TypeIds are 32 bits, and every struct and enum value carries its case’s TypeId.

Some smaller features are planned but not yet implemented: complex numbers and mutable pairs in the dynamic Scheme libraries, mutually recursive local functions, superclasses, literals at a generic numeric type, and overflow-checked arithmetic. The language issue tracks these gaps.

References

  • R7RS, the Scheme standard whose syntax and libraries Snail-Core follows.
  • WebAssembly garbage collection, the target: structs, arrays and references managed by the engine.
  • Binaryen, which assembles, optimizes and links the compiler’s output.
  • Luc Maranget, Compiling Pattern Matching to Good Decision Trees and Warnings for Pattern Matching: the checks behind match.
  • Per Vognsen’s Bitwise and Resin’s discussion of it: representations that explain operations, and control flow that teaches the problem.
  • Chibi Scheme, the first stage of the compiler’s bootstrap.

Tutorials

Small complete programs, each run by the repository’s test suite with the output shown. Run one with

./scripts/snail-tool run examples/fibonacci.sld
  • Fibonacci: a typed recursive function and output.
  • Colors: structs, enums, typeclasses and pattern matching.
  • Effects: effect rows and handlers.
  • Scheme on Snail-Core: the same kind of program through (scheme base).

Siegework is the complete application.

Fibonacci

examples/fibonacci.sld:

;; The smallest complete program: a typed recursive function and output.
(import
 (snail-core base)
 (snail-core io))

(: fibonacci (-> I64 I64))
(define (fibonacci n)
  (if (< n 2) n (+ (fibonacci (- n 1)) (fibonacci (- n 2)))))

(display (fibonacci 30))
(newline)

It prints:

832040

Colors

examples/colors.sld:

;; Structs, enums, typeclasses and pattern matching.
(import
 (snail-core base)
 (snail-core io))

(struct (Vec3 T)
  ((x T)
   (y T)
   (z T)))

(enum PrimaryColor
  (Red)
  (Green)
  (Blue))

(enum Color
  (Primary ((value PrimaryColor)))
  (Rgb ((value (Vec3 I64)))))

(typeclass (ToHex T)
  (: to-hex (-> T String)))

(: channel-hex (-> I64 String))
(define (channel-hex value)
  (let ((digits "0123456789abcdef"))
    (string (string-ref digits (quotient value 16)) (string-ref digits (remainder value 16)))))

(instance (ToHex PrimaryColor)
  (define (to-hex color)
    (match color
      ((Red) "#ff0000")
      ((Green) "#00ff00")
      ((Blue) "#0000ff"))))

(instance (ToHex (Vec3 I64))
  (define (to-hex v)
    (string-append "#" (channel-hex (Vec3-x v)) (channel-hex (Vec3-y v)) (channel-hex (Vec3-z v)))))

(instance (ToHex Color)
  (define (to-hex color)
    (match color
      ((Primary value) (to-hex value))
      ((Rgb value) (to-hex value)))))

(for-each (lambda (color) (write-line (to-hex color)))
          (list (Primary (Red)) (Rgb (Vec3 255 160 0)) (Primary (Blue))))

It prints:

#ff0000
#ffa000
#0000ff

Effects

examples/effects.sld:

;; Effect rows and handlers. `log` performs Log; a handler decides what
;; logging means, and the type system tracks which code may perform it.
(import
 (snail-core base)
 (snail-core io))

(effect Log
  ((log (-> String Unit))))

(: total-cost (-> (List I64) I64 ! Log))
(define (total-cost prices)
  (fold-left (lambda (total price)
               (log (string-append "adding " (show price)))
               (+ total price))
             0 prices))

;; The same function, quiet or verbose, depending only on its handler.
(: quietly (All (A e) (-> (-> A ! Log e) A ! e)))
(define (quietly action)
  (handle Log
      ((log (lambda (message) (unit))))
    (action)))

(display (quietly (lambda () (total-cost (list 3 4 5)))))
(newline)
(display (handle Log
             ((log (lambda (message) (write-line message))))
           (total-cost (list 1 2))))
(newline)

It prints:

12
adding 1
adding 2
3

A callback can use fewer effects than its caller has permission to perform. This function adds output around an action while preserving the action’s row:

(: announce (All (A e) (-> (-> A ! e) A ! console e)))
(define (announce action)
  (write-line "calling")
  (action))

The call to action uses e, which fits the surrounding console e for any substitution of e. Extra permissions do not authorize effects absent from the context, and unrelated quantified tails are not interchangeable. Function-type equality still compares complete rows; this inclusion rule applies when calling.

Scheme on Snail-Core

examples/scheme-fibonacci.sld:

;; The same program through (scheme base): Scheme names, Snail-Core types.
(import
 (scheme base))

(define (fibonacci n)
  (if (< n 2) n (+ (fibonacci (- n 1)) (fibonacci (- n 2)))))

(display (fibonacci 25))
(newline)
(display (map (lambda (x) (* x x)) (cons 1 (list 2 3))))
(newline)
(display (car (cdr (list 10 20 30))))
(newline)

It prints:

75025
(1 4 9)
20

Runtimes

Snail-Core’s shipping target is WebAssembly GC with JavaScript Promise Integration (JSPI). A runtime supplies platform effects and owns the computations that use them. The language’s intended concurrency contract permits multiple threads; the shipping runtime uses one JavaScript execution agent.

The host is the surrounding platform, such as Node or a browser. A runtime owns language tasks; an OS process owns its platform execution environment. A compiler session is cached compiler work, not an application runtime. The toolchain terms keep these boundaries distinct in the project and development design.

The task contract specifies the replacement for the actor API. It separates scheduling, handler installations and owned values. The actor request/reply API has been removed. Task operations run in the CLI and GUI. Applications describes checkpoint handover and reload on that runtime. One-shot work and ownership describes the implemented affine subset. The socket effects bind typed, interceptable TCP and UDP operations to native Node resources, with cancellation and shutdown owned by the runtime.

Execution and suspension

A synchronous piece of Wasm runs until it returns or reaches a suspending import. A JSPI import can return a Promise: Wasm execution suspends while the JavaScript event loop services other work. Resolving or rejecting that Promise resumes or fails the computation. A pending Promise alone does not keep a Node process alive; a runtime must account for outstanding work and own any required process-lifetime token.

An event loop, an I/O readiness service and a scheduler serve different purposes. Linux epoll reports which descriptors can make progress; it does not schedule language closures or preserve effect bindings. Our host uses JavaScript platform APIs for I/O notifications, and JSPI for suspended Wasm stacks. It does not need its own epoll wrapper. See the Linux interface and V8’s JSPI explanation.

Scaling the deployed program

For CPU parallelism today, run independent instances in separate execution agents or processes and distribute requests at the service boundary. A reverse proxy can load-balance several processes. Each instance has its own managed heap; communication uses an explicit external protocol.

workerd is a possible host to investigate, not a currently supported Snail-Core deployment target. Its isolates are not synonymous with OS processes. Its WasmGC, JSPI and import support must be checked before promising compatibility. Snail-Core does not need a second compiler backend to use multiple independently hosted instances.

Future work

Mixed memory and continuations proposes type-directed GC and owned linear storage in the existing Wasm backend, explicit affine and reusable continuations, and a scheduler written in Snail-Core. Transferable linear-memory tasks could then run across workers while ordinary GC references remain local. These changes are not implemented.

Continuations and parallel execution retains the earlier backend research. Neither native alternative is part of the draft plan.

Task operations

(snail-core task) exposes the runtime-owned tasks control effect:

OperationMeaning
(dispatch-local work)Transfer a Once thunk into an independent local task.
(yield)Suspend until an event-loop turn permits more work.
(sleep milliseconds)Suspend for a timer, using an I64 delay from 0 through 2147483647.
(import
 (snail-core base)
 (snail-core io)
 (snail-core task))

(dispatch-local
 (once (lambda ()
         (yield)
         (write-line "the child finishes after main returns"))))

The CLI waits for all tasks, including suspended children, before closing its external resources. A child failure lets siblings finish and gives the invocation a failing exit status. Whole-program builds and separately linked library builds use the same scheduler. Library initializers run in dependency order; their children need not finish before the next initializer runs.

dispatch-local returns Unit, not an actor or an application object. Its argument is consumed. There are no mailboxes, request/reply protocols or supervisors. The runtime’s host API can cancel individual tasks or close the runtime; this first Snail-Core API does not expose task handles.

Effects and locality

The dispatcher must admit both tasks and the thunk’s latent effects. Dispatching a thunk cannot hide an otherwise unhandled operation. A child inherits the ordinary handler bindings present at dispatch, including those whose installing parent returns before the child runs.

The runtime binds tasks; an ordinary handle tasks is rejected. JSPI supplies these restricted control waits. User-defined control handlers and first-class resumptions await the continuation work described in future work.

Local dispatch may capture a mutable Ref, because the work stays on this execution agent. This does not certify the closure as Send or Sync. Ordinary handlers still require both properties and grade omega. An eventual API that can choose any worker must establish transfer safety for the complete environment.

Only explicit waits release the execution permit. Existing scalar console and filesystem services remain synchronous; performing an arbitrary effect does not by itself suspend. The runtime does not preempt a CPU loop.

The host boundary

Wasm passes the host an opaque owner cell and invocation and discard function references. The invocation entry takes and clears that cell before executing the thunk. The discard entry clears queued work without running it. JavaScript neither inspects the closure nor constructs Snail-Core objects. These entries also enforce one-shot use at the trusted host boundary.

Optimization must preserve those external calls. The build no longer enables Binaryen’s --closed-world: that mode assumes outgoing function references will not be called by the host. This is a correctness requirement of the task ABI. See Binaryen’s world-mode contract.

The CLI’s existing Node worker provides V8 heap settings for one execution agent. It is not a worker pool; all tasks in this runtime still execute cooperatively on that one agent.

JSPI uses finite, engine-managed stacks. Deep non-tail recursion can overflow; use tail calls for loops requiring constant stack space. The previous CLI-only million-depth non-tail recursion guarantee and its large native-stack setting have been removed. Ordinary recursion and tail calls remain supported.

One-shot work and ownership

Once gives a zero-argument procedure grade 1: it may be consumed at most once. Ordinary procedures and ordinary data have grade omega and can be reused. These grades belong to types, not to individual bindings.

(import
 (snail-core base)
 (snail-core io))

(: greeting (-> String (Once (-> Unit ! console))))
(define (greeting name)
  (once (lambda () (write-line name))))

(let ((work (greeting "hello")))
  (call-once work))

call-once consumes the work item before entering its body. Calling it again is a compile error. Moving it to another binding, argument, result or one-shot closure consumes the old binding. Both alternatives of an if may consume the same value, because only one runs; consuming it again after that if is rejected.

A concrete struct or enum containing an affine field is itself affine. Destructure it with match; its generated accessors and replacements would permit duplicate ownership and cannot be used. An enum is affine even when its current variant has no fields. Unused bindings and fields are dropped on the path that does not use them.

Only a lambda supplied directly to once may capture affine values. Its construction moves those captures into the work item. An ordinary procedure remains reusable, so capturing an affine value in one is an error. Taking an affine argument is fine: each invocation receives its own owner.

Last use and representation

The ownership pass runs after specialization, before Wasm emission. It places drops at the earliest point on a path where an owned binding has no remaining use. An unused argument is dropped on entry, and an unused binding just after its initializer. This does not wait for the end of the lexical scope.

The initial backend represents an affine value as a cell containing a nullable managed reference. A move clears the old cell and creates a new cell for the same payload. It does not copy the payload. Invocation and discard clear the final cell. Pattern tests only peek; a successful pattern then moves its fields and clears the consumed containers. Failed tests retain no raw payload temporaries across the selected body.

These rules release ownership and prevent old aliases from retaining its owned descendants. WasmGC still decides when to reclaim managed memory. This subset has no user destructors or first-class I/O continuations; clearing a closure is not a substitute for cancelling an I/O registration. The runtime must explicitly own and cancel those registrations.

Deliberate limits

Once currently wraps only (-> Unit ! effects). Existing generic type variables range over unrestricted types: a generic duplicate cannot be instantiated with a work item. Grade-polymorphic containers are future work. Concrete record fields can contain Once today.

Affine values cannot be globals, elements of Ref, Array or Vector, or payloads erased into Any. There are no borrowed references or user defined drop operations. The static check establishes ownership within Snail-Core; unsafe foreign code remains a trusted boundary.

Ownership does not establish Send or Sync. A locally owned work item can capture a local mutable cell. Ordinary effect handlers still require reusable captures satisfying both thread-safety properties. Future dispatch to arbitrary workers will also need evidence for the work item’s complete environment.

Scheduling and suspension

host/browser/tasks.mjs implements the scheduler shared by runtime adapters. The Snail-Core task API and application event and storage waits use it in the CLI and browser. It uses one JavaScript execution agent and shipping JSPI; it does not create workers.

A live task is either queued, executing or suspended. The runtime keeps a set of these tasks. Dispatch inserts a child before returning to its parent. Returning or trapping removes only the completed task; independently dispatched work remains live. drain() resolves when the set is empty. Work dispatched after exhaustion begins another interval of activity.

The ready queue contains starts and resumptions. An execution permit allows only one of them to use a program’s dynamic handler globals. Short tasks normally advance through microtasks; a bounded number of steps yields an event-loop turn so input is not starved. A CPU loop that does not suspend still monopolizes this execution agent.

Handler bindings

Ordinary handlers are reusable and satisfy Send + Sync. Dispatch copies the current binding vector and gives it to the child. The implementations may be shared, but the vector belongs to that task. This is why a child can keep using a handler after its parent returns. Root entries start with runtime defaults.

Wasm handler globals act as the executing task’s registers. At a JSPI wait, the runtime saves them, restores the defaults and releases the permit. I/O completion only makes a resumption ready. The scheduler later installs that task’s bindings and resolves or rejects its wait Promise. It keeps the permit until the resumed Wasm suspends again or settles; resolving the Promise alone does not establish that Wasm has run yet.

One scheduler owns each program and all its handler globals. Runtime authors must route suspending imports through its suspension bridge. Dynamically loaded modules must appear in the handler-global enumeration too. Their compiler-generated handler globals initially contain null.

Cancellation and adapters

cancel() requests cancellation; done marks completion. Queued work that has never entered Wasm can be discarded immediately. A suspended invocation must resume with rejection and unwind before it leaves the live set. Cancellation is sticky, including when a successful I/O result has already queued a resumption.

An adapter receives an AbortSignal and returns one Promise covering both its operation and cleanup. It must handle an already aborted signal, remove listeners on normal completion, and settle only after owned cleanup finishes. Timers and event waits can disarm promptly. An operation that cannot abort may finish normally; the runtime then rejects its resumption because cancellation was requested. Its external effects are not rolled back.

suspend(operation, discard) can take a synchronous native disposer for a successful result. The suspension owns that result until the scheduler accepts delivery. If cancellation wins while the result is queued, or a non-abortable operation succeeds after cancellation, the runtime disposes the result exactly once and rejects the parked invocation. It does not dispose delivered results or operation failures. A disposer failure also rejects the parked invocation, so Wasm still unwinds before task completion.

The disposer must not suspend or reenter Snail-Core. If it starts OS resource closure, the native owner awaits that remaining cleanup during shutdown. Work must return its complete invocation Promise, and suspending imports must return or await their gate; abandoning either loses the relationship between task completion and its parked stack.

This makes cleanup part of the suspended task’s lifetime, without a second cleanup counter. A host adapter that never settles can prevent exhaustion. Runtime authors must uphold this boundary; a pending Promise is not cancellation machinery by itself.

Closing a runtime rejects new work, cancels its live tasks and waits for them to settle. A task failure does not cancel siblings. A Node host must also hold a process-lifetime token until drain: unresolved Promises alone do not keep Node alive.

Native networking

(snail-core socket) provides concrete, interceptable tcp and udp effects. The Node program runner binds them to host/native/tcp.mjs and host/native/udp.mjs through JSPI and host/native/socket-imports.mjs. Static and separately linked programs use the same binding. The HTTP library implements one complete request/response exchange over TCP. Browser sockets are not implemented. The networking issue tracks the remaining work.

Snail-Core API

Import (snail-core socket) and include tcp or udp in the effect row of code using it. Every operation returns Result with a SocketError whose code is a string such as "EBADF", "EINVAL" or a native network error code. Cancellation unwinds the task; it is not a recoverable SocketError. TCP operations are:

OperationSuccess value
listen (Endpoint address port)Listener; Listener-port is the actual bound port.
connect (Endpoint address port)Connection.
accept listenerConnection.
read connection maximumSome Bytes containing an available nonempty prefix, or None at EOF.
write connection bytesI32 count accepted locally, possibly smaller than the input.
finish-connection connection graceUnit after ending output, discarding input and awaiting native closure; grace is an I32 millisecond deadline.
close-listener listenerUnit.
close-connection connectionUnit.

TCP read limits are 1–65,536 bytes; one write accepts at most 65,536. Reads may split or combine peer writes. Callers must loop to transfer an exact length. Addresses are numeric IPv4 and ports are 0–65,535; listen port 0 selects a free port. Hostnames, TLS and stream framing are outside this API.

UDP operations are:

OperationSuccess value
bind (Endpoint address port)UdpSocket; UdpSocket-port is the actual bound port.
send socket peer bytesI32 count of the entire datagram accepted locally, including zero; never a partial prefix.
receive socket maximumDatagram with an Endpoint peer and immutable bytes.
close-udp socketUnit.

UDP payloads and receive bounds are 0–65,507 bytes. Empty packets are messages, not EOF. An oversized receive consumes and discards that packet with EMSGSIZE; the next receive starts at the next packet. Datagram-peer supplies the sender’s address and port, and Datagram-bytes supplies the whole payload. Bind port 0 selects a free port; send destination ports are 1–65,535. UDP does not guarantee delivery, order or uniqueness.

Listener, Connection and UdpSocket carry an opaque Any token belonging to their handler. They are reusable local values, neither Send nor Sync. Closing revokes all aliases; closing the same native token again succeeds. Native token types stay private to the library. A different handler’s token reaching the native binding reports EBADF instead of being cast to an integer handle. TCP and UDP owners may issue the same numeric handle, but their private token types differ. Tokens must remain with the runtime that issued them.

A replacement handle tcp supplies eight operations; handle udp supplies four. They may create endpoint values with their own tokens. For example, a test can put a Ref Bytes in a connection token or a Ref Datagram in a UDP token, accessed through operation arguments. The reusable handler’s captures still satisfy Send + Sync and grade omega; this does not relax the language’s handler rules.

Wasm boundary

The binding borrows the program’s synchronous constructor/projector exports from (snail-core socket). Native I/O runs while the task is suspended. Only after its scheduler gate returns the execution permit does the binding enter Wasm to construct a typed Result. Ordinary JavaScript objects cannot substitute for these managed values. Host code remains trusted to use the correct Snail-Core types.

Outgoing bytes are copied from staging before any other Wasm call or await. Incoming bytes are written into a temporary managed array and copied to an immutable vector; there is currently one helper call per byte. Endpoint text uses bounded character projection. This simple bridge avoids a global reply slot or callback protocol; bulk copying can be optimized separately.

A pending acquisition carries its native handle and bound-port metadata, where applicable. The scheduler disposes a successful acquisition if cancellation wins before delivery. A trap constructing its managed wrapper also closes the handle. Successful delivery transfers responsibility to user code: close explicitly, or leave cleanup to runtime shutdown. There is no implicit last-use socket drop or task-scoped socket owner. Cancelling an unrelated task does not close a delivered endpoint. The runner cancels/drains tasks, closes all socket resources, then closes its scalar resources when execution ends or initialization fails.

TCP operations

Each createTcp() owns one set of native resources and its own positive I32 handle namespace. Handles never repeat within that owner. They are not global identities, transferable capabilities or references to Wasm-managed objects. Every operation must use the owner that issued its handle.

OperationResult
listen(signal, address, port)Listener handle after binding; port 0 chooses a free port.
port(listener)The listener’s bound port.
connect(signal, address, port)Connected socket handle.
accept(signal, listener)One connection, independent of its listener after delivery from this operation.
read(signal, connection, maximum)A nonempty available byte prefix, or null at EOF. The limit is 1–65,536.
write(signal, connection, bytes)A count of bytes accepted locally, at most 65,536; empty input returns 0.
finish(signal, connection, grace)End output, discard input and await physical closure; grace bounds graceful progress in milliseconds.
close(handle)Immediate revocation of aliases and initiation of native cleanup; repeated close does nothing.
shutdown()Reject new work, close all resources, and await native cleanup.

Addresses are numeric IPv4 and ports are 0–65,535. There is one pending accept per listener and at most one pending read and one write per connection; a read and write can overlap. Duplicate operations fail with EBUSY, revoked handles with EBADF, and invalid arguments with EINVAL. Native network errors retain their codes. Receiving EOF leaves the connection writable; available bytes are read before EOF. No operation promises peer receipt or message framing.

Write borrows its bytes unchanged until settlement. The Wasm binding copies staging memory before awaiting; native callers must not mutate or detach the supplied storage. A cancelled write may already have transmitted bytes, so retrying the whole buffer is not automatically safe.

Finishing a connection

Snail-Core’s finish-connection binds the native finish operation. It takes an idle connection and a whole-number grace period from 0 through 2,147,483,647 milliseconds. Pending read or write rejects with EBUSY without changing ownership. Invalid grace rejects with EINVAL.

Once accepted, finish ends output and consumes remaining input without keeping it. Node’s writableEnded state prevents aliases from starting further I/O; close can still abort the connection. EOF plus completed output lets Node close the socket naturally. This staged teardown avoids discarding a final HTTP response when unread input remains; see RFC 9112 §9.6. Success reports local completion, not proof that the peer application processed the response.

The deadline is elapsed time, not an idle timeout renewed by incoming data. Expiry forces closure and reports ETIMEDOUT; zero means immediate expiry. Cancellation preserves its exact reason. Transport failure, explicit close, timeout or cancellation records the first cause; clean native closure records success. A later cancellation cannot rewrite that native outcome, although the task scheduler can still reject delivery to a cancelled task. Every accepted finish waits for physical closure before settling. The grace bounds graceful progress; forced cleanup still needs event-loop scheduling.

Ownership and cancellation

Node accepts eagerly. A listener retains at most 64 unclaimed connections in a paused state, using native stream buffers. Overflow connections close immediately. Revoked queued connections are skipped by accept. Cancelling accept leaves its listener and unclaimed connections usable; closing a listener releases its queue but leaves already handed-out connections independent.

Native ownership lasts from creation through the close callback. A public handle exists only after successful listen/connect/accept. Immediate close removes it; graceful finish disables ordinary I/O but retains the handle for an explicit abort until physical closure. Failed creation and rejected incoming sockets therefore participate in shutdown without receiving a handle. Exhausting the handle namespace reports EMFILE; an accept that cannot publish leaves its connection listener-owned.

Cancelling listen/connect closes the pending resource and waits for cleanup. Cancelling an installed read or write closes that connection and wakes its other pending operation. Completed operations detach cancellation. A success arriving just before cancellation is checked again before it can publish a handle or write count. Abort failures preserve the task’s exact cancellation reason.

Ordinary close does not wait: Node can defer a listener’s close callback until its handed-out connections finish. Shutdown closes those connections too and waits for all ownership, including late arrivals during listener closure. The task scheduler separately owns successful native results until delivery; the binding supplies its disposer when a cancelled task cannot receive a newly created handle. No native cleanup record retains a managed wrapper.

Native UDP owner

host/native/udp.mjs owns IPv4 datagram sockets behind the udp effect. It is tested against real loopback sockets, including forced asynchronous native sends. It uses Node’s synchronous numeric bind, available from 26.4. Binding is a nonblocking local operation, so it needs no opening state, DNS lookup or bind cancellation registration.

OperationResult
bind(signal, address, port)Positive I32 handle; port 0 selects an available port.
port(handle)The actual bound port.
receive(signal, handle, maximum)One whole datagram: {bytes, address, port}, including the sender’s endpoint.
send(signal, handle, address, port, bytes)Entire payload length after local completion; no peer delivery guarantee.
close(handle)Immediate alias revocation and initiation of cleanup; repeated close does nothing.
shutdown()Stop new binds, close remaining sockets and await native cleanup.

Handles never repeat within their owner, including after failed bind attempts. Addresses must be numeric IPv4; send destination ports are 1–65,535. A payload may contain 0–65,507 bytes. Zero-length datagrams are messages, not EOF. Receive accepts a maximum in the same range. If the next datagram is too large, it is consumed and discarded with EMSGSIZE; there is no partial result or remainder to read. Oversized sends fail before native submission. UDP makes no delivery, order or uniqueness guarantee.

Each socket queues at most 32 complete datagrams; excess arrivals are dropped, preserving the queued messages. One receive and one send may be pending at a time and may overlap; duplicates fail with EBUSY. Receive cancellation removes its registration and preserves the socket and remaining queue. A datagram already consumed before cancellation wins is not requeued.

Send borrows immutable bytes until its callback. The Wasm binding snapshots staging before calling it. Cancelling send waits for native completion and then rejects with the exact cancellation reason. It cannot retract the datagram. A send callback failure affects that operation; a socket-level error closes the socket. Closing wakes pending receives and discards queued datagrams.

Node still resolves numeric send addresses asynchronously. Closing during that lookup can suppress the send callback, and a native close event can otherwise precede pending send completion. The owner therefore revokes aliases immediately but defers native close until the single send settles. The closed record remains owned until its close event. Shutdown cannot finish while that send or cleanup is outstanding. Failed bind closes and awaits cleanup before rejecting.

As for TCP, the scheduler owns successful acquisitions until delivery; the shared binding supplies the existing native disposer. The owner adds no second delivery protocol and retains no managed wrapper.

Tasks, ownership and runtimes

This is the language contract for tasks and effects. The runtime overview distinguishes its implemented subset from future requirements. There is one shipping target: WebAssembly GC with JSPI, on one JavaScript execution agent. A second backend is future research.

Computations

A task is a single execution of a zero-argument closure. A runtime owns tasks and the platform operations they await. Dispatch transfers a closure into a task; it does not create an actor instance, mailbox or request/reply protocol. A task finishes by returning or failing. There is no terminate effect and no supervisor hierarchy.

Tasks may overlap in lifetime. The language permits a runtime to execute eligible tasks on different threads. The current implementation interleaves them cooperatively: a task runs until it returns, fails, explicitly yields, or awaits a runtime operation. A CPU loop that never yields monopolizes the execution agent. Suspension is not CPU parallelism.

A runtime is exhausted only when all its tasks have settled. A suspended task, an outstanding event registration and cancellation cleanup remain live work. An empty ready queue is not exhaustion. Spawning reserves work before returning to the caller, so a parent can finish immediately after dispatch without letting the runtime exit early.

Grades and transfer

Grades belong to types. Grade 1 means at most once, not exactly once; grade omega permits repeated use. Moving an affine value transfers its ownership. Alternatives in a branch may each consume it; two uses along one execution path may not. Capturing it moves it into the closure, and requires a one-shot closure. Records containing an affine field are affine. This is an ownership check, not a borrow checker.

An owned value’s lifetime ends at its last use, not the enclosing lexical scope. Moving into a task keeps it alive in that task. Deterministic resource cleanup is separate from tracing the managed storage: abandoning a pending one-shot continuation must disarm its operation and initiate cancellation, not merely release the allocation holding its captures. Unrestricted aliases cannot each independently destroy a shared resource at last use. Dropping one reference to a future reusable continuation does not cancel other retained invocations.

Send means ownership may cross a thread boundary. Sync means shared access is safe across threads. Neither means serializable. The compiler establishes these properties from representations and captures; users cannot assert them with an empty marker instance. Mutable aliased Ref and Array, opaque Any, and unclassified foreign references do not gain these properties just because this implementation uses one thread. Today’s compiler checks types and captures structurally. Worker dispatch will also need proof that their emitted representations can be transferred; the mixed memory draft specifies that additional obligation. Current WasmGC values remain local.

Local dispatch may retain values that are not Send. A task eligible for any worker must be Send. A reusable closure’s signature alone does not prove anything about its environment. Foreign code and host capabilities are explicit trusted boundaries, with thread confinement recorded there.

Effects and handlers

An effect row states which operations code can perform. The runtime binds platform effects; applications may bind ordinary handlers for abstractions and tests. Possessing an effect label alone does not confer authority to a platform resource.

Every ordinary handler implementation, including its captured environment, must be Send + Sync and grade omega. Its individual invocations may own affine arguments and allocate local mutable storage. An operation that needs a local resource takes that resource as an argument rather than hiding it in a shared handler. Host implementations may use synchronized capability proxies or execution-agent confinement.

A handler implementation is distinct from an installation of that handler. Installations and their outer bindings belong to a computation. Suspending and resuming a computation preserves those bindings. Reusing handler code does not duplicate a control delimiter or permit two resumes of one affine continuation. Newly dispatched tasks inherit the current ordinary bindings. Each task owns its binding vector; the reusable implementations may be shared. This preserves the dispatched closure’s declared effects. It does not establish rules for cloning future control delimiters.

Runtime event waits are control operations: the runtime retains a one-shot resumption and later makes it ready with a result or cancellation. JSPI implements this restricted control boundary today. User-defined ctl handlers and first-class continuations are deferred. Ordinary handlers remain tail-resumptive: their result returns once to the operation caller.

Events and application state

The runtime turns platform callbacks into completed waits. It may buffer input while application code is suspended, and coalesce redundant motion, resize and frame notifications. A frame wait can return the input received since the previous frame. Each input is consumed once by its owning loop. The runtime does not infer a shared current application state.

An application owns its state as loop arguments. For example, in schematic syntax:

animate(window, world):
    frame = next_frame(window)
    next = update(world, frame.inputs, frame.dt)
    render(window, next)
    animate(window, next)

The existing application’s checkpoint and save/restore handover remain useful during browser hot reload. They are an application protocol on top of tasks, not the definition of a task. Reload waits for the old computation to settle before relinking its modules.

The typed state proposal would replace application handover with durable records and disposable views; that API remains under review.

Draft mixed memory and continuations

Status: proposed. Snail-Core will keep one backend that emits WebAssembly directly. Types will select GC references or owned linear-memory storage; the compiler will lower control effects into explicit continuations. This allows Scheme graphs to retain automatic cycle collection while eligible Snail-Core tasks execute across workers using shared linear memory.

The implemented baseline is affine one-shot work, compiler-owned structural Send and Sync checks, and local tasks scheduled through JSPI. The pointer types, ownership libraries, explicit continuations and worker pool below are new work. Their names describe the proposed contracts; their surface syntax is not settled.

Scope

Ownership, continuation management and scheduling move into Snail-Core libraries; Rust and JavaScript supply allocation, foreign services, startup and callbacks. The existing frontend, Chibi bootstrap and separate compilation remain the foundation. This uses one Wasm emitter, without Emscripten. The earlier backend research retains the LLVM and C alternatives.

Reference types and storage

Gc<T> and Ptr<T> are the two primitive reference representations. Gc<T> lowers to an opaque WasmGC reference; Ptr<T> lowers to an address in linear memory. They require different allocation and access instructions. An allocator cannot interchange them behind a common raw pointer interface, and a cast cannot turn one into the other.

Ownership libraries build safe owners over Ptr<T>:

TypeOwnership and reclamationWorker capability
Gc<T>Reusable traced reference; the engine reclaims unreachable graphs.Neither Send nor Sync, even when T is immutable.
Ptr<T>Raw address; copying it transfers no ownership. Access and deallocation require an unsafe contract.Neither marker is derived automatically.
Box<T>Affine owner; destruction drops the payload and frees its allocation.Send when T: Send and the allocation supports transfer.
Arc<T>Reusable shared owner; the last strong release destroys the payload.Send + Sync when T: Send + Sync.
Weak<T>Reusable weak reference; upgrade may fail after payload destruction.Send + Sync under the same payload bounds as Arc<T>.

These rules assume the linear allocation lives in the workers’ shared memory and its allocator permits allocation and deallocation on different workers. Arc counters protect ownership, while payload access needs its own synchronization. Weak references keep the control allocation alive without keeping its payload alive, following the familiar Arc and Weak contract.

Storage, grade and transferability are independent properties. An affine continuation may retain local GC values. A reusable owner may use linear memory. A raw pointer’s numeric representation proves neither a valid lifetime nor safe access from another worker. The compiler carries each property separately through specialization and lowering.

The compiler must also establish whether a payload is representable in the chosen storage. T: Send alone is not a layout proof. In particular, an ordinary GC reference cannot occupy bytes in a linear record. The first implementation preserves existing data layouts unless an explicit owner or reference type selects a new representation. Existing implicit GC strings, vectors and closures must not retain transfer evidence merely because their contents are immutable. Today’s checks inspect types and captures; they do not prove that the emitted WasmGC representation can cross workers. The proposed checks must establish that additional representation evidence.

Ownership and destruction

Extend the existing affine ownership pass. A move transfers an owner; a last-use drop destroys it. For shared owners, repeated use may require retaining a reference, while a final use may move it. Neither operation is an untracked byte copy. Grade-polymorphic containers are needed so queues and aggregates can own affine values.

The compiler generates destruction of fields and deallocation of their owners. A proposed Drop typeclass supplies custom resource cleanup; its interaction with generated field destruction and moves must be specified before implementation. Initial custom destructors complete without suspending or throwing. Drop does not imply duplicability.

Affine resource cleanup also applies when the wrapper itself is GC allocated: the compiler performs the resource action at the ownership boundary and the engine later reclaims the wrapper. A reusable GC alias cannot independently perform that same destruction.

Crossing between heaps

WasmGC and shared linear memory coexist, but they do not form one traced ownership graph. The GC representation keeps references separate from numeric addresses. Apply these initial boundary rules:

  • Linear records cannot directly store GC references. A local rooted handle table may provide an explicit bridge, with a defined owner and release operation. A handle stays local to its worker; copying its integer ID elsewhere does not transfer its referent.
  • Safe reusable GC payloads cannot silently own a Box, Arc count or other resource requiring deterministic release. Collecting a numeric pointer field does not invoke Drop. Such ownership needs an explicit lifetime protocol before it is admitted.
  • Arbitrary Scheme cycles stay entirely in GC storage. Strong Arc cycles still require weak edges. Cycles involving both rooted GC handles and linear owners are outside the initial safe ownership model.

Copying or converting a graph across representations is an explicit operation with specified identity, sharing and resource behavior. There is no implicit copy at a Send boundary. Unsafe foreign code remains responsible for keeping borrowed addresses valid.

Workers and transferable tasks

Each worker instantiates compatible code against the shared linear memory and retains its own ordinary GC references, globals and local resources. A task can allocate and use GC values after starting on a worker. It stays on that worker whenever a live continuation contains non-transferable state. A later suspension may become transferable if its complete captured state permits it.

Send checks include closure captures, continuation frames, handler installations, dynamic bindings, wind callbacks and foreign registrations. The compiled code must also have no hidden access to an unshared global that assumes another worker’s identity. A procedure’s argument and result types alone do not establish this property.

A transferable closure uses a stable code identifier and an environment in shared linear memory. Each worker resolves the identifier through its own compatible code table. Wasm function references and instance-local table entries are not shared by putting their numeric indices in a queue. Module initialization establishes the mapping, separate worker stacks and thread-local state before the worker accepts tasks. Reload drains all old tasks before replacing a code generation; a queued identifier cannot be interpreted using a different generation’s code. Draining tasks alone does not account for saved reusable continuations. Initially reject them in a reload handover; supporting that handover later requires retaining or explicitly migrating their code and captured state.

The Snail-Core scheduler owns queues of affine thunks. A task start and a bound continuation invocation use the same work representation. Invoking a reusable continuation creates one affine work item while retaining the continuation for another invocation. Suspended work is owned by its pending operation and is absent from ready queues. Local queues retain pinned work; the shared queues accept only transferable work.

Start with synchronized queues and explicit ownership. Browser execution runs bounded batches and returns to the event loop; the main thread does not wait for a worker or an I/O completion.

The pool uses shared linear memory; sharing GC references through Shared-Everything Threads is not a prerequisite.

Explicit continuations

Use selective control lowering inspired by Koka’s generalized evidence passing and yield bubbling. Code proven not to capture control uses ordinary calls. On a yielding path, relevant callers package their remaining computation and live values until the matching handler receives an explicit continuation. Resumption executes those frames with the correct dynamic context. The transformation targets the existing Wasm emitter and retains proper tail calls.

Both continuation grades belong in the first control representation:

  • An affine continuation owns its captures. Resumption consumes it; abandonment releases owned resources. Resumed code may suspend again, producing a new continuation.
  • A reusable continuation preserves captured control state across invocations. Immutable frames can be shared; each invocation gets any mutable activation bookkeeping it needs. Ordinary captured mutable locations remain shared, rather than being restored from a snapshot.

Multi-shot control cannot silently duplicate a live affine resource. Checking must cover caller frames across potentially capturing calls, including calls through effect-polymorphic functions. Require reusable captures for such control, or an explicit resource protocol that makes the required sharing valid. GC allocation does not waive this ownership rule.

The frame representation follows its captures. GC-backed continuations serve unrestricted local Scheme control. Transferable continuations use linear frames and compatible code identifiers. Affine local frames may use GC storage while still having explicit resource cleanup. Multiplicity does not select the heap by itself.

Scheme control and dynamic wind

The Scheme layer needs abortive call/cc with reusable continuations of unlimited extent, multiple values, dynamic parameters and exception handlers. Invoking an escape continuation replaces the current continuation. A root delimiter must model the Scheme execution context; delimiting every ordinary function or temporary browser callback would truncate it. Other Scheme conformance work remains in the language issue. See R7RS sections 6.10 and 6.11.

Represent dynamic-wind scopes as persistent nodes with identity, parent, entry and exit thunks, and their surrounding dynamic context. A continuation retains the applicable chain. For a control transfer, find the common ancestor, exit current scopes from inside outward, then enter destination scopes from outside inward:

Current: root -> A -> B -> C
Target:  root -> A -> D
Actions: C.after -> B.after -> D.before -> resume target

Remove a scope before calling its exit thunk; install it after its entry thunk returns. Preserve the body’s returned values across normal exit. The transition machinery must participate in control lowering when a hook performs an effect. The standard library implementation in The Scheme Programming Language illustrates the chain and ordering.

Compiler-generated returns used to package a suspension do not themselves leave a language scope. Ordinary I/O waiting preserves the task’s dynamic context. Actual continuation transfers run the corresponding wind transitions. Capturing a continuation and collecting an already inactive continuation do not independently trigger wind hooks. Final resource destruction and dynamic scope exit remain separate operations.

Cancellation and foreign callbacks

A pending operation owns its one-shot continuation. Completion and cancellation race to settle that operation once; only the winner may enqueue its work or initiate cancellation cleanup. Abandoning a task disarms its registration or retains the registration’s storage until late callbacks can no longer access it. An empty ready queue is not runtime exhaustion while waits or cleanup remain live.

Cancellation follows an explicit task cleanup path. It does not rely on GC reachability or a native Rust/JavaScript exception to walk language frames. Reusable Scheme continuations require a distinct lifetime policy: discarding one reference does not cancel every retained invocation or destroy resources still reachable through another reference.

Foreign callbacks return normally. They copy transient inputs or transfer an explicitly owned buffer, settle a registration and enqueue Snail-Core work. They do not capture a foreign stack as part of a continuation. Browser operations that need an immediate response, such as event suppression, keep that decision in the synchronous adapter.

Compiler and runtime responsibilities

Preserve representation, grade, capture multiplicity, effects and capability evidence until the passes that consume them have completed. mono.sld currently erases effect rows; continuation lowering requires retaining the relevant evidence through specialization. Generic functions must specialize or adapt consistently to the control ABI they receive.

Check source ownership and effect restrictions first, lower continuations, then establish ownership and insert retain/drop operations for generated frames as well as source values. Persist ABI-relevant evidence in library interfaces and cache keys so separately compiled callers agree about representations and suspension behavior. Chibi must continue to run the same compiler sources, with compiler-used primitives supplied by its host.

ComponentProposed responsibility
Snail-Core compilerReference representations, layouts, unsafe memory and atomic primitives, control lowering, ownership glue and foreign ABI checks.
Snail-Core librariesBox/Arc/Weak, collections, continuations, handler contexts, dynamic wind, queues, synchronization and task lifecycle policy.
Rust servicesAllocation and operations obtained from foreign libraries or the host; preserve existing behavior while portable algorithms migrate to Snail-Core.
JavaScript hostModule and worker startup, browser/Node API calls, DOM and WebGPU integration, and completion delivery.

Use an explicit foreign mechanism, including extern "C" for Rust and other Wasm libraries with a compatible ABI. Declare pointer width, alignment, ownership and callback lifetime at the boundary. Rust’s private Vec and Arc layouts are not the Snail-Core ABI. Atomics can lower directly to Wasm instructions.

Move portable policy into Snail-Core incrementally, preserving bootstrap-visible behavior. The new representation need not wait for every scalar service to move.

Validation boundary

The implementation issue tracks representation, ownership, control lowering, scheduler and worker proofs. Each backend step must execute emitted Wasm on both linked and whole-program paths; keep Chibi/bootstrap behavior and real browser reload tests. The existing JSPI implementation remains until its replacement demonstrates those contracts.

A local feasibility probe on 2026-10-24 combined ordinary GC structs with shared memory64 in the same module. Four workers each performed 100,000 atomic increments while maintaining local GC state; Node 24.19 and Chromium 152 both produced 400,000 without experimental Wasm flags. This establishes coexistence on those engines, not the allocator, continuation or scheduler invariants above. Convert that probe into a reproducible backend fixture when shared execution is implemented.

Decisions to specify before implementation

  • Surface syntax for reference allocation, unsafe operations and borrowed access; whether existing container names gain explicit storage variants.
  • The Drop contract, field destruction order and the initial safe set of GC payloads and lifetime bridges.
  • How control multiplicity appears in effect signatures and how Scheme callbacks cross typed code that has live affine owners.
  • The representation of task-local dynamic contexts and which local handler forms the Scheme layer needs alongside existing transferable ordinary Snail-Core handlers. Representation-aware checks will reject some GC captures accepted today; specify how those handler environments migrate.
  • Address width and the foreign ABI for the first delivery. Mixed heaps do not require memory64. The current Rust runtime targets wasm32-wasip1; moving it to Rust’s Tier 3 wasm64-unknown-unknown also requires building the standard library and adapting platform I/O.

Continuations and parallel execution

Research recorded on 2026-10-24. This page describes future options, not shipping features. We ship one WasmGC backend. The language keeps its multithreaded contract and Send + Sync handler restrictions while the implementation runs one execution agent.

The follow-on mixed memory and continuations draft selects one Wasm backend with both GC and owned linear storage, and compiler-lowered affine and reusable continuations. The native alternatives below remain research; their implementation is not required by that plan.

What the Wasm target needs

Existing Wasm threads share linear memory. That does not make ordinary WasmGC objects, closure displays or references transferable between workers. The Shared-Everything Threads proposal addresses that larger sharing model. A prototype engine flag is not a deployment baseline; we require default support in the runtimes we ship on.

Typed stack switching provides typed continuation creation, suspension and resumption. JSPI gives us host-driven asynchronous suspension today, but not a language-level first-class continuation API or arbitrary migration of suspended stacks. User-defined ctl remains deferred while runtime-provided waits use JSPI.

These extensions solve different problems. Stack switching does not make the heap shareable. Sharing the heap does not establish ownership, handler lifetimes or cancellation. An implementation of both still needs to preserve the language’s invariants.

Option A: restore a Wasm-to-LLVM backend

The archive archive/llvm-backend-20261012, at 5c277ab6, used BDWGC and translated Binaryen’s folded text into LLVM. The later backend removed by PR #341 was substantially different. Its last revision, fc8bf59a, read Wasm binaries, resolved their types, emitted LLVM, and used a custom Immix collector. Research should start from that later revision, rather than assuming the named archive contains the final implementation.

The later backend’s exact writable roots are a useful foundation. Reference locals lived in root slots; temporary references were published before collecting calls and reloaded after objects could move. The collector was synchronous and single-threaded. RUNTIME and native_roots were process globals explicitly assumed never to be shared between threads. Native worker isolation was not a shared-heap thread pool. See its roots contract and runtime.

A stackful implementation would allocate stacks, switch between them at delimited suspension points, and represent a continuation as ownership of a suspended stack chain. The Wasm type checker supplies the continuation’s type; LLVM need not gain a first-class continuation type. Affine resumptions avoid copying stacks to support repeated resumes. A historical WasmFX prototype, section 5, implemented this using Wasmtime’s fibers and native register/stack switching. That establishes feasibility, not readiness of our old backend.

LLVM’s coroutine intrinsics instead support splitting a function into entry, resume and destruction functions with saved state in a frame. They do not automatically capture arbitrary nested ordinary calls. We would either transform every potentially suspending path or supply a stack-switching runtime. The coro.id.retcon.once convention describes a coroutine yielding once; it does not by itself implement our rule that each captured resumption is consumed at most once.

The substantial work is making the runtime correct:

  • Give each suspended computation a root chain. Publish live references before suspension and reload them after resumption, since other tasks may have caused a moving collection in between.
  • Enumerate roots from every running and suspended computation. A first multithreaded collector can stop all mutators; it need not collect concurrently. It still needs safepoints and safe allocation coordination.
  • Implement stack limits, foreign-call boundaries, cancellation unwinding and exactly-once cleanup. Cross-thread resume must account for thread-local state, not just copy a stack pointer.
  • Implement the shared Wasm memory/object operations and their atomic semantics. Native pointer accessibility alone does not provide them.

A useful first experiment would suspend with live managed references, force a moving collection in another computation, resume, and check the references. Cancellation should exercise the same roots and cleanup paths. Only then should two native mutators run concurrently.

Option B: a C backend informed by Koka

Koka’s usual C backend lowers effects through a selective monadic transformation and generalized evidence passing. At an operation that yields, generated code returns through ordinary C frames. Each relevant caller adds a closure for its remaining computation. At the matching handler, the collected closures form the resumption. Resuming invokes those closures with the appropriate handler bindings. It does not preserve the physical C frames. The compiler transformation and handler runtime show both halves.

The generalized evidence passing paper, sections 2.5–2.8 explains the optimizations: effect types help select handler evidence; tail-resumptive operations can execute in place; generated code builds continuation closures on the yielding path. A yielding flag distinguishes ordinary return values from suspension. Capture does work along the continuation’s path, whereas native stack switching can retain the stack without rebuilding that path as closures. These are different cost models; neither establishes a performance result for Snail-Core without measurement.

Koka supports multiple resumptions. Restricting Snail-Core to affine resumptions would simplify ownership, but the follow-on draft plans both grades. A C backend would still need closure conversion, effect lowering, a managed-memory strategy, a foreign ABI and a portable runtime. Adopting this technique does not mean embedding Koka or obtaining those pieces automatically. Koka’s reference-counting implementation is also not a requirement of the continuation technique.

Koka explicitly finalizes abandoned resumptions so finally clauses run. Its raw ctl exposes a resume context whose owner must eventually resume or finalize it. This is evidence against treating cancellation as merely freeing a closure display. The tour describes that contract. The related libmprompt project takes a separate native stack-switching approach; it is not the mechanism described above.

The compiler transformation could also target today’s WasmGC. Therefore waiting for typed stack switching is a choice to avoid another substantial lowering strategy, not proof that ctl is impossible on today’s Wasm.

Handler ownership and scheduling

An unrestricted handler implementation may be invoked repeatedly and concurrently. In an affine-resumption design, each invocation receives its own affine resumption. That does not make the installation, delimiter or captured resources unrestricted. A continuation must retain the bindings and resources it needs; cancellation must release them without retaining managed wrappers through external cleanup records. A precise account of grades, effect instances and escaping resumptions remains theoretical work before public ctl handlers are added.

When a shared-heap backend exists, a conventional pool is sufficient: worker-local deques, recently spawned work preferred locally, older work available for stealing, and an injection queue for external submissions. Suspended tasks are absent from ready queues. Thread-affine resumptions remain pinned. This is a future scheduling policy, not part of the language’s observable ordering guarantees.

For now, deploy independent Wasm instances and balance requests externally. Neither native backend is on the implementation path of the task rewrite.

Games

Games are complete Snail-Core applications: the rules, the scene and the HUD are all Snail-Core, and the browser only forwards input, draws frames and stores the save.

  • Siegework: explore a seeded map, wire an energy network across it, and destroy three nests before their attacks destroy your hub. Play it.

Siegework

Play Siegework in a browser with WebGPU. The game saves itself in the browser and continues where you left it.

Explore a seeded map, wire an energy network across it, and destroy three nests before their attacks destroy your hub.

Playing

  • The land. Each map’s seed picks one of four biomes. Meadows have lakes and mountains; deserts have no water, only mesas and winding canyon walls; tundra has frozen lakes, mountains and scattered boulders; an archipelago is sea but for its islands. Open ground is walked and built on. Rock stops walkers, sight lines and wires until burrowers dig it out. Water stops only walkers, so wires reach islands enemies cannot. Ice is walked but not built on. Every nest has a way in to the hub, though it may wind through a pass or over a causeway.
  • Energy. The colony runs on one store of energy, starting at 160. Extractors draw it from violet crystals until they run dry: the starter, four cells from the hub in a direction the seed picks, yields 2 a second, richer crystals more, and each extractor level multiplies its yield. A destroyed nest exposes a rich crystal of its own.
  • The network. Every finished building is wired to every other within reach whose line to it crosses no rock (water is fine). The hub reaches 9 cells, pylons 7, 10 and 12 by level, turrets 6 and extractors 4. A building the hub cannot reach through wires is unpowered and does nothing: no energy, no shots, no sight. Rebuild the link and it wakes up.
  • Building takes time. A new building needs visible open ground and a powered building that can wire to it. Its cost is paid at once and it is built while it holds one of the hub’s build slots: pylons take 2 s, extractors 3 s and turrets 4 s, and the colony’s first extractor is ready at once. Nothing happens until it is ordered: the crystals in sight pulse white under a title banner that points to them and links to this guide; the camera opens on the starter crystal. Once a crystal is selected, the banner points to the build button, which pulses. The hub starts with one slot; upgrading it adds a second and a third. Further orders wait in the queue, and an unpowered site waits without blocking the others.
  • Rush. Rushing finishes work at once for four times the share of its cost that remains: five times the price when rushed as it is ordered, less later. Cancelling refunds the cost.
  • Upgrades, repair and scavenging. Each building has three levels. Upgrades raise pylon reach, extractor yield, and turret damage (one and a half, then two times, with a gun more each level), and restore full health. Repair restores the hub to full health at one energy for each two health it lacks. Scavenging returns a fifth of what a building cost.
  • The siege. The first attack leaves the nests at 0:45, then one every 30 seconds; raids aimed at exposed outposts replace every other attack from 1:45, and a boss leads an attack every nine minutes. Enemies grow stronger every 90 seconds, less for each nest destroyed, and some burrow through rock. Turrets shoot the nearest enemy in sight and range, harder up close, and their guns stay where they last fired. A wounded enemy turns on the first turret that hit it and hunts that one until it falls, whatever else shoots it; a raider then goes back to its outpost.
  • Winning. Destroy all three nests to win. Lose the hub and you lose.

Controls

The same controls work with a mouse, a pen and fingers. Drag to pan, pinch or use the wheel to zoom, and tap a cell to select it; the selection alone never orders anything. The dock on the side of your hand shows hub health, energy and the nests’ strength with the time to the next attack, then the build menu for the selected cell: a pylon, a turret, an extractor, an upgrade, a rush and closing. A button that may work later, waiting on energy, work, the network or the first extractor, turns flat blue with faint lines; one that cannot apply to the selection fades. Rush finishes the selected work, or else makes new buildings rushed. A selected building or hub adds cancelling its work and scavenging it, or at the hub repair. Beside it come the menu and turning buttons and the build queue, with a ring while a work builds and ! while it is unpowered; tap one to go to it. Under the nests in the opposite corner, a red alert throbs while the colony is under attack and goes to the latest attack, and a notice says why an order was refused.

KeyAction
1, 2, 3Build a pylon, a turret or an extractor on the selected cell
4Rush the selected work, or make new buildings rushed
5Close the selection
UUpgrade the selected building, or add a build slot at the hub
RRush, as 4 does
C, XCancel the selected work, scavenge the selected building
FRepair the hub to full health
WASD, arrowsPan
Q, ETurn a quarter
HReturn to the hub
POpen or close the menu
EscapeBack out of a dialog, then clear the selection

Keys held with ⌘, Ctrl or Alt go to the browser, so its shortcuts, such as reloading, keep working. With Shift, WASD and the arrows pan as they do alone, and other keys do nothing.

The menu also restarts on a new map, resigns, and moves the dock to the left or right edge.

Saves

The colony depends only on its seed and the orders given at each tick, so the save is a journal: the seed, the tick reached and each accepted order with its tick, plus whether the game was paused and for which hand. Loading replays the journal. Under rules that have changed since it was written, an order that is now rejected is dropped and the colony grows differently; there are no save versions.

Reading the code

Siegework’s source is a tour of a complete application: seeded terrain, an energy network derived from the buildings, a siege on a fixed schedule, a world that changes only through orders and ticks, and a scene and HUD drawn from the state alone.

Graphics

Applications draw today through (snail-core app): view returns a frame of mesh batches, sprites and pictures, and a fixed WebGPU renderer in host/browser/renderer.mjs draws it with one metallic-roughness shader.

  • The WebGPU design proposes an effect and graphics libraries of composable pipelines and packers. The application-owned JSPI loop already runs; the renderer replacement remains proposed.
  • The path tracing primer explains the physics of the lighting that plan leaves for after rasterization.

WebGPU and graphics libraries

Proposed. The application loop and task runtime already execute on JSPI. Applications still send Frame values to host/browser/renderer.mjs; this design replaces that renderer with a concrete GPU effect and libraries of pipelines and packers. Delivery is tracked in the graphics issue.

Ordinary reusable handlers and runtime-provided waits are sufficient for device requests, decoding, pipeline creation and readbacks. Public ctl is not required. JSPI does not expose typed delimited continuation values; the continuation research and explicit-lowering draft explain future options without making GPU work depend on an engine extension.

WebGPU through an effect

The gpu effect mirrors WebGPU’s own operations and descriptors, so code using it reads like WebGPU:

(effect gpu
  ((create-buffer (-> BufferDescriptor GpuBuffer))
   (write-buffer (-> GpuBuffer I64 Bytes Unit))
   (create-texture (-> TextureDescriptor GpuTexture))
   (write-texture (-> TextureCopy Bytes Unit))
   (create-shader-module (-> ShaderSource GpuShaderModule))
   (create-render-pipeline (-> RenderPipelineDescriptor GpuRenderPipeline))
   (create-bind-group (-> BindGroupDescriptor GpuBindGroup))
   (create-command-encoder (-> GpuCommandEncoder))
   (begin-render-pass (-> GpuCommandEncoder RenderPassDescriptor GpuRenderPass))
   (set-pipeline (-> GpuRenderPass GpuRenderPipeline Unit))
   (set-bind-group (-> GpuRenderPass I32 GpuBindGroup Unit))
   (draw-indexed-indirect (-> GpuRenderPass GpuBuffer I64 Unit))
   (end-pass (-> GpuRenderPass Unit))
   (finish (-> GpuCommandEncoder GpuCommandBuffer))
   (submit (-> (List GpuCommandBuffer) Unit))
   (current-texture (-> GpuTexture))
   (destroy (-> GpuObject Unit))))

The calls are procedural; effects keep them reasonable. A ! gpu row says exactly which functions touch the device, and handlers customize what the calls do without changing their callers: tracing, recording for tests, validating handles, capturing a frame, or decoding compressed textures for a device that lacks them.

  • Paths. Long-lived objects are created by resource path, as in (create-buffer (BufferDescriptor (path "siegework" "materials") size usage)). The same path and descriptor return the existing object, which is how a reloaded module reattaches to what the last one created.
  • Lifetimes follow scope. Objects created under reset live for the run; objects created during a frame, such as encoders and transient buffers, are destroyed when it ends; destroy stays available.
  • Eviction. Each frame marks the objects it uses. Unmarked objects move to an LRU pool and are evicted only when an allocation would exceed the memory budget. If the marked objects alone exceed it, the runtime panics with a report of the largest. An evicted object is created afresh when next requested.
  • Handles carry generations, so using an evicted handle is a clear error rather than a WebGPU validation message.
  • The boundary marshals. Descriptors cross as staged bytes, and pass commands as small scalar calls that the default handler may batch until submit.

The graphics library

graphics is middleware: composable WebGPU pipelines and packers whose interfaces are buffers and textures. The application decides what to pack, when to copy, and how to stitch passes together.

LibraryProvides
(graphics pod)(deriving Pod) on a struct gives its WGSL layout and alignment, a packer from a list to Bytes, and its WGSL declaration, one source of truth for each shader interface.
(graphics wgsl)Shaders as S-expressions: one-to-one with WGSL, read from source files by build-time recipes and printed to WGSL text, with struct declarations from Pod.
(graphics mesh)The flat glTF-shaped model as records and packers: geometry, material, instance, transform and indirect-draw tables, and lights.
(graphics atlas)Texture-array pages with padded slots, slot tables, mips, and texture encoders, uncompressed first.
(graphics raster)A deferred G-buffer pipeline with configurable targets: depth, normal, albedo, metal-roughness, emissive, ids and motion.
(graphics passes)Full-screen passes: a fixed sun, tone mapping, FXAA, copies.
(graphics quad)2D quads in world units under a 2D camera, pixel-snapped in the vertex shader, sampling any texture view, including earlier passes’ targets.
later(graphics bvh), compute ray tracing and (graphics svgf), for path-traced lighting over the rasterized G-buffer.
  • Sampling an atlas. Slots carry gutters of repeated edge texels; UVs wrap in slot space, fract for repeat and clamp for clamp; samples stay half a texel inside the slot; filtered textures use textureSampleGrad with derivatives of the unwrapped UVs, so the wrap cannot spike the mip level; pixel art samples nearest, without mips.
  • No bindless. Core WebGPU has no binding arrays, so material textures live in atlas arrays, and the quad pass draws runs of quads that share a texture.
  • No multi-draw. The G-buffer pass issues one drawIndexedIndirect per geometry; the indirect-first-instance feature lets each start at its own instance.
  • A low-poly game packs its geometry, material and instance tables, renders albedo, normal and depth, lights them with a sun pass of its own or the library’s, and composites the result to the canvas with quads. A richer game builds its own passes from the same pieces.

Device baseline

Required features: texture-compression-bc, shader-f16, indirect-first-instance, float32-filterable and rg11b10ufloat-renderable; timestamp-query when present, for profiling. Raised limits: buffer and storage-binding sizes near 1 GiB, 16384-texel textures, and 64 bytes of color attachments per sample, since a full G-buffer fills the default 32. Texture heaps start uncompressed; a later reset may choose BC.

Design choices

  • Images are decoded by the browser behind decode-image, a suspending import, rather than in Snail-Core.
  • update stays total: prefetching happens from view.
  • Open: shader sources as S-expression files compiled by recipes, the default here, or as S-expressions built in Snail-Core code.

Path Tracing Theory Primer

This primer gives a high-level overview of the physical and mathematical principles behind path tracing, derived from the Rendering Equation. It was written for the compute-shader path tracer of resin, and describes the lighting the graphics plan leaves for after rasterization.

1. The Rendering Equation

At its core, physically based rendering solves Kajiya’s Rendering Equation. For a point $p$ on a surface, the outgoing radiance $L_o$ in a direction $\omega_o$ is the sum of emitted light and reflected light:

$$ L_o(p, \omega_o) = L_e(p, \omega_o) + \int_{\Omega} f_r(p, \omega_i, \omega_o) L_i(p, \omega_i) (\omega_i \cdot n) d\omega_i $$

Where:

  • $L_e$: Emitted light (if the object glows).
  • $\int_{\Omega}$: Integral over the hemisphere of all incoming light directions.
  • $f_r$: The BRDF (Bidirectional Reflectance Distribution Function).
  • $L_i$: Incoming radiance from direction $\omega_i$.
  • $(\omega_i \cdot n)$: The cosine law (Lambert’s law), attenuating light based on the angle of incidence.

2. The BRDF ($f_r$)

The BRDF defines how a material reflects light. In modern PBR (Physically Based Rendering), the Cook-Torrance microfacet model is the standard. It splits into diffuse and specular terms:

$$ f_r = k_d f_{lambert} + k_s f_{microfacet} $$

Diffuse Term ($f_{lambert}$)

Models light that enters the surface, scatters internally, and exits in a random direction. $$ f_{lambert} = \frac{c}{\pi} $$ Where $c$ is the albedo color. The $\frac{1}{\pi}$ ensures energy conservation when integrating over the hemisphere.

Specular Term ($f_{microfacet}$)

Models the surface as millions of microscopic mirrors. $$ f_{specular} = \frac{D(h) F(v, h) G(l, v, h)}{4 (\omega_i \cdot n) (\omega_o \cdot n)} $$

This is composed of three statistical functions:

  1. D (Normal Distribution Function): Fraction of microfacets aligned to reflect light from $\omega_i$ to $\omega_o$. (e.g., GGX/Trowbridge-Reitz).
  2. F (Fresnel Equation): Fraction of light that reflects vs. refracts/absorbs based on view angle. (e.g., Schlick’s Approximation).
  3. G (Geometry Function): Fraction of microfacets not blocked (shadowed/masked) by others. (e.g., Smith-GGX).

3. The Monte Carlo Estimator

The integration $\int_{\Omega}$ is impossible to solve analytically for complex scenes. Path tracing approximates it using Monte Carlo integration:

$$ L_o \approx \frac{1}{N} \sum_{k=1}^{N} \frac{f_r(\omega_i, \omega_o) L_i(\omega_i) (\omega_i \cdot n)}{p(\omega_i)} $$

  • $N$: Number of samples (rays).
  • $p(\omega_i)$: The Probability Density Function (PDF)—the likelihood of choosing a specific random direction.

To reduce variance (noise), we use Importance Sampling, choosing directions where light is likely to come from (e.g., towards the sun) or where the BRDF is strong (e.g., the specular reflection angle).

The term commonly tracked in a path tracer’s “throughput” is the weight: $$ \text{Throughput} = \frac{f_r(\omega_i, \omega_o) (\omega_i \cdot n)}{p(\omega_i)} $$

When the PDF $p(\omega_i)$ is perfectly matched to the BRDF geometry (e.g., Cosine-weighted sampling for diffuse), terms cancel out effectively: $$ \text{Diffuse Weight} = \frac{(\frac{c}{\pi}) (\omega_i \cdot n)}{\frac{\omega_i \cdot n}{\pi}} = c $$


4. Q&A: From First Principles

Q: Why is the integral a simple multiplication? Why not a more complex function?

The integral form $\int f_r \cdot L_i \cdot (n \cdot \omega_i)$ relies on two key physical assumptions:

  1. Linearity (Superposition): Light does not interact with other light. The result of two light sources is simply the sum of them individually. This allows us to use an integral (a continuous sum).
  2. Locality: The strict definition of a BRDF assumes light enters and exits at the exact same point ($x_i = x_o$).
    • For metals, this is true (reflection is immediate).
    • For dielectrics (plastic/paint), light penetrates but exits so close to the entry point that it appears local.
    • Note: Materials where this fails (skin, wax) require a BSSRDF (Subsurface Scattering), which integrates over an area, not just a hemisphere.

Q: Why split materials into Lambertian + Specular?

This split models the two distinct paths a photon takes when hitting a dielectric interface:

  1. Path A (Specular): The photon hits the surface boundary and reflects immediately due to Fresnel forces. It interacts only with the surface, not the pigments inside.
  2. Path B (Diffuse): The photon enters the material (refracts), hits pigment particles, scatters chaotically, and exits in a random direction. The “Russian Roulette” logic in path tracing (choosing to bounce diffusely OR specularly) explicitly models this bifurcation.

Q: Why do we multiply by $(\omega_i \cdot n)$ but then divide by it in the Specular BRDF?

  1. The Multiplier $(\omega_i \cdot n)$: This is the Cosine Law. It effectively spreads the light energy over a larger area when hitting a surface at a glancing angle. A tilted surface physically intercepts fewer photons.
  2. The Denominator: The Microfacet BRDF contains a division by $4 (\omega_i \cdot n) (\omega_o \cdot n)$. This is a Jacobian correction factor. It converts the distribution of microfacets (which don’t care about the macro-surface normal) into the macroscopic reflection ratio.
  3. The Result: For specular reflections, the two terms cancel out. This physically means a mirror doesn’t get darker at grazing angles; it simply reflects a larger slice of the environment. For diffuse surfaces, the denominator doesn’t exist, so the cosine law applies, and the surface darkens as it turns away from the light.

Q: Is the Geometry (G) term actually important?

Yes, it is critical for energy conservation. Without $G$, the specular term would divide by zero near grazing angles ($(\omega_i \cdot n) \approx 0$), causing edges to glow infinitely bright. $G$ represents micro-shadowing and masking—at grazing angles, surface bumps block most light, mathematically cancelling the singularity and keeping the result finite.

Q: Why does Fresnel’s Equation make sense physically?

Why do surfaces become mirrors at grazing angles?

  • Physics: When a light wave hits a surface head-on, it “sees” the empty space between atoms and enters (refracts).
  • Grazing Angle: The surface appears compressed to the incoming wave. Atoms appear stacked impenetrably tight, presenting a “wall” of electrons. The wave cannot penetrate and is forced to bounce off (reflect). This is why even matte objects (asphalt, Toast) exhibit specular reflections at extreme grazing angles.