carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

todo-spinel

TodoMVC for the CARLOS bakeoff, written in Ruby and compiled ahead of time by Spinel, matz's Ruby-to-C compiler. The result is one static linux-arm64 binary with SQLite inside it and no Ruby at runtime. It's live at https://todo-spinel.bfj.oncarlos.com.

The stack

tep is the web layer. You write a Sinatra-style app.rb; tep translates it into plain Ruby that Spinel can compile, inlines its own HTTP server, and drives Spinel to a binary. It's the only web framework that targets Spinel, and it already powers a CARLOS app (lpay), so it was the obvious pick. Spinel compiles a subset of Ruby, which is why app.rb reads a little old-fashioned: while loops, string concatenation, small top-level functions, no metaprogramming.

SQLite comes from the official amalgamation, compiled by the Makefile and linked statically. tep's own SQLite shim talks to it. Lists live in one todos table keyed by the visitor's cookie, and a visitors table records every token the server has issued, so a cookie it didn't issue gets a fresh one. Migrations are numbered by PRAGMA user_version and only ever add.

HTML is built in Ruby functions in app.rb, escaped by hand. tep has build-time ERB, but its templates can only be called from a route block and every value has to go through a per-request string bag, which made the item and fragment renderers clumsier than plain functions.

JavaScript is one ES module, assets/static/app.js, with no framework. The page works fully without it: every action is a real form POST that answers 303. With JS on, the script sends the same forms with fetch and an X-Fragment: 1 header, and swaps in the section.main and footer.footer the server sends back. The no-JS controls sit invisibly over their TodoMVC counterparts and disappear when the script adds the js class.

tep is copied into .cache/ and patched rather than used as-is. patches/1-tep-carlos.patch is lpay's: the --socket contract and a Content-Length that counts bytes. patches/2-tep-bakeoff.patch adds --listen 127.0.0.1:<port>, which refuses anything but a loopback address, and removes tep's fallback to listening on every interface: with neither --socket nor --listen the binary exits. It gives each request a 10-second deadline for the whole exchange, so a slow or idle client can't hold the single worker. It reads bodies whole up to 256 KiB (tep stopped at 64 KiB and carried on with a truncated title), answers 413 above that, and answers 400 without running any handler when a body is short of its Content-Length or chunked. Smaller changes: the 303 See Other reason phrase, and a return-type fix in Tep::Assets._add that otherwise breaks the C compile. Last, it replaces tep's form decoding. spinelkit's Url.parse_query keeps a decoded key in an unrooted temporary while it decodes the value, so a garbage collection in between loses the key. On arm64 that dropped any todo title over a thousand or so characters; on x86-64 it only shows under SPINEL_GC_STRESS=1. The patch decodes in C and keeps every intermediate in a local, and make test runs a second pass under GC stress to catch anything like it. Edit .cache/x86/tep and run scripts/regen-patch to change the second patch.

Build, run, test

source ../../scripts/env.sh
make build                 # dist/app, for this machine
dist/app --listen 127.0.0.1:8080 --db $TMPDIR/todo.db
make test                  # test/run.sh: curl checks of the contract
../../scripts/test-app.sh http://127.0.0.1:8080   # the shared browser suite

make build takes about a minute and a half, nearly all of it Spinel.

make build-arm64 makes dist/app-linux-arm64. The host Spinel turns Ruby into C natively, then scripts/arm64-cc compiles that C inside the emulated arm64 root (scripts/arm64.sh) and links it statically against the root's glibc, OpenSSL (tep's HTTP layer links it) and our SQLite. Running all of Spinel under emulation also works but takes three times as long. Expect five minutes warm, and another four the first time while SQLite compiles.

make clean-deps removes the compiled SQLite and tep copies; the downloaded SQLite zip stays in .cache/src.

Deploy

make deploy MESSAGE="what changed"

This ships dist/app-linux-arm64 to the todo-spinel app on account bfj. The version is the commit's short SHA, so commit before you build. Before a first deploy, run ../../scripts/boot-check.sh dist/app-linux-arm64.