Implementation notes. Read this before changing anything here; several of the non-obvious decisions below were arrived at empirically and are easy to "simplify" back into bugs.

IMPL.md is the original research document. It is still accurate about the Alloy internals, but its §3.2 architecture was not what got built — see "Architecture" below.

Thing Path
Alloy source (read-only) ~/kot/alloy/alloy
Practical Alloy book (HTML + figures) ~/kot/alloy/practicalalloy.github.io
Book models — the golden corpus ~/kot/alloy/practicalalloy-models
Instance XML format spec alloy/…/edu/mit/csail/sdg/translator/instance.txt

browser ──HTTP+SSE──> alloy-viz (Go) ──NUL-JSON stdin/stdout──> alloy-viz-solver (Java)
                        supervisor                               one warm JVM per model file

Java is a dumb worker; Go owns everything else. IMPL.md §3.2 proposed the opposite (Java serving varlink and forking a child JVM per solve, with a warm child pool as mitigation). That would have meant two hand-rolled protocols and a hand-rolled varlink server in Java, and it pays the JVM start per solve. The built design keeps one long-lived worker per model file, which is strictly warmer and needs no pool.

*server (serve.go) owns all of it: the worker, the one job in flight (s.current), and the connected browsers. jobs.go is now only job state and its subscriber fan-out; there is no manager type.

This is an invariant, not an accident. An earlier version keyed workers and jobs by path so several models could be open at once. Nothing ever used more than one — the UI watches exactly the file named on the command line — and the job table it required retained a full instance XML per job, so every save leaked one. It was removed; to watch a second file, run a second alloy-viz on another port.

Do not re-introduce the map because it looks more general. It needs a real multi-model requirement first, and with one it is not just a map: jobs need eviction, and workers need idle shutdown, neither of which the old code had. The previous shape is in the history at 7a868f02^.

de.profpatsch.Alloy was implemented (~460 lines, varlink.go + alloy.varlink + a systemd unit) and then deleted, because nothing consumed it. The web UI never went through it — serve.go had zero references to varlink — so it was a 16%-of-the-project adapter over the server API with no caller.

Do not re-add it speculatively. If a real consumer turns up (driving Alloy from a script, or exposing it to an agent via varlink-mcp-bridge, which needs only an interface and a .mcp.json entry), it is a thin wrapper over the methods on *server in serve.go and is easy to write then. It is in the history at d2739f45 if the IDL is worth reviving.

Measured, on protocol-design/instance_19 command 2:

solve
cold worker 566 ms
warm worker 152 ms

A running SAT4J solve cannot be interrupted. AbstractKodkodSolver.solveAll claims @throws AbortedException … Thread.interrupt, and that is simply false: kodkod/solvers/SAT4J.java:115 implements solve() as a bare isSatisfiable() with no interrupt polling. Verified empirically (probes/Probe3.java): after interrupt() and a 20 s join, the worker thread is still alive.

So Cancel is Process.Kill(). The Alloy GUI does the same thing (WorkerEngine runs solves in a subprocess and calls destroy()).

Consequence: cancelling discards the parsed CompModule, so the next run re-parses (~1 s). That is fine — cancel follows a solve that was already taking minutes.

workerRequest.CommandIndex must serialize even when it is 0, because 0 is a real command index — the default one. With omitempty the field vanishes from the JSON, req.get("command_index") returns null on the Java side, and running the first command in any file dies with a bare NullPointerException from gson. This shipped undetected for a while: the startup path is exercised with -command 3 in testing, and only the browser's run button hits index 0.

The Java side now answers a missing required field with a protocol error frame instead of dereferencing it, so the next instance of this is legible. When testing command selection, test index 0 explicitly.

--type json / A4Solution.toDTO() is silently wrong: it queries the raw kodkod instance, whose atom naming does not match Alloy's. On a three-line model it reports the wrong atom (A$2 for A$0) and an empty relation for a field constrained to be non-empty.

Always serialize with A4SolutionWriter.writeInstance — the path the GUI and --type xml use. This is why RunResult.instance_xml is XML and not a bespoke JSON schema.

Both exist and differ by one boolean passed to frame.solve. The fromBook variant is what the GUI and the CLI use.

A4Options defaults to 0, but the GUI defaults to 1 (A4Preferences.java:604). At depth 0 existential quantifiers produce no skolem relations, and the book's models — which pin instances with some disj d0, r : Dir … — would render without their witnesses.

VizState.resetTheme() sets the default shape/colour to ELLIPSE/WHITE at lines 105 and 121, then overwrites both with BOX/YELLOW at lines 151-152, under a comment about meta-model defaults. The second write wins. The book's figures confirm it: an unthemed File sig renders as a yellow box.

<node color="Red"><type name="Object"/></node> colours Dir, File and Root, because they extend Object. VizState.MMap.resolve walks parents until it finds a setting. This is why Atom.TypeChain exists and why the theme lookups take a chain rather than a type name.

Root and Dir, but Entry0..Entry3. Alloy drops the index when the sig holds exactly one atom (StaticInstanceReader.java:319).

Editors save by writing a temp file and renaming it over the target, which replaces the inode. A watch on the file follows the old inode and goes silent after the first save. Debounced 150 ms because one save emits several events.

-Dorg.slf4j.simpleLogger.logFile=/dev/null. Neither defaultLogLevel=off nor per-logger levels work — kodkod still emits six lines. Set in the nix wrapper.

Worker.java grabs FileDescriptor.out and then points System.out at System.err. A stray println from Alloy in the middle of a frame would corrupt the stream irrecoverably; this makes it harmless log noise instead.

worker.gen exists to fix a real race: when a killed process's readLoop exits, a replacement may already be running. Without the generation check it would mark the new process dead and close its pending requests, so the first run after any cancel failed.

kill() is synchronous now, so it can no longer cause that overlap itself, but gen is still load-bearing: a JVM that dies on its own has nobody waiting for its readLoop, and a concurrent send() can respawn while it is still unwinding. Do not delete gen on the grounds that the kill path is ordered.

send() keeps w.mu across both ensureStarted() (which bumps gen) and the w.pending registration. That single acquisition is what makes cancel and supersede safe: a request can never be registered against a generation that is about to change, so a departing readLoop either sees a mismatch and touches nothing, or finishes entirely before the new request exists. Splitting it into two acquisitions looks harmless and reintroduces the failure above. w.nextID survives respawns for the same reason — monotonic IDs mean a late frame from a dead process cannot alias a live request.

kill() blocks until readLoop has reaped the process. Without the wait, a caller that kills and immediately sends can have ensureStarted spawn a replacement before SIGKILL lands, so two JVMs are briefly alive, each holding an open SAT solver; under a burst of saves that fans out. Waiting makes "at most one solver process" structural rather than a matter of timing.

The done channel is closed by readLoop last, after its teardown, so observing it closed means the loop is finished with the worker's state — not merely that the process exited. It is created only after a successful spawn, so a failed Start cannot leave a later kill() blocked forever.

A job stays non-terminal for a moment after its solver is killed: its execute goroutine has to wake on the closed frame channel first. Two consequences, both of which were bugs:

The worker handles one op at a time, so a parse issued during a solve queues behind it — a save during a two-minute solve would otherwise block the reload for those two minutes. Since a save means the file changed, a solve still running is working on text that no longer exists, so cancelling it costs nothing. The re-parse this forces was already the price of any cancel.

default.nix passes ./foo.go paths straight to buildGo.program, which puts each file in its own store path. buildGo derives the embed directory from the first entry of srcs, so an embedded file would not be next to the source embedding it, and the build fails with "build system did not supply embed configuration". The fix is to assemble a real source directory with runCommandLocal first, as ../asciinema-server does. (This was needed for the varlink IDL; it went away with it.)

java is deliberately not in the dev shell (the JDK is large). For iteration, use the pinned store path:

JAVA=$(nix-build ~/kot/Profpatsch -A third_party.nixpkgs.openjdk21 --no-out-link)
JAR=$(nix-build ~/kot/Profpatsch -A third_party.nixpkgs.alloy6 --no-out-link)/share/alloy/alloy6.jar

$JAVA/bin/javac -cp $JAR -d ./tmp/classes java/src/de/profpatsch/alloy/Worker.java

The Go tests need no Java: they run against testdata/, which holds real solver output for filesystem.als command 3 plus that model's theme.

go test ./users/Profpatsch/alloy-viz/
nix build ~/kot/Profpatsch#alloy-viz

The corpus is a golden-output suite: many models pin an exact instance, and each corresponds to a screenshot in practicalalloy.github.io/_images/. Render and compare by eye.

Careful: IMPL.md claims filesystem.als corresponds to instance8.png. It does not — that image is the filesharing model. The right one is instance83.png, found by searching the chapter HTML for _images/.

Useful models:

Purpose Path
simple, fast, has a theme structural-modeling/instance_07_08/filesystem.als
temporal trace behavioral-modeling/instance_10/filesharing.als
slow — cancel test protocol-design/instance_19/leaderelection.als, command 6 (eventually_elected)
multi-command jar's models/examples/toys/ceilingsAndFloors.als (5 commands)

Note that several commands in instance_19/leaderelection.als fail with "You must specify a scope for sig this/Message". That is the model's own doing — stock alloy6 exec reports the identical error at the identical line. Use command 2 for a quick satisfiable run.

Instance enumeration (fork(-3/-1/-2/0/state+1)), the evaluator, trace stepping for temporal models, projection, unsat cores, magic layout.

Enumeration and evaluation need the live A4Solution, which cannot cross a process boundary — so they must be new ops on the worker that holds it, and the worker then becomes a session with a lifetime, needing explicit release and idle eviction (a live A4Solution pins an open SAT solver).