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

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.