carlos / todomvc-bakeoff Public
fix(mojo): clean exit on SIGTERM despite the Mojo runtime's own handler
The runtime starts worker threads before main and catches SIGTERM, so a signal mask or signalfd can't work; a C-ABI handler writes to a pipe the poll loop watches. Adds a process-level smoke test to make test, README and DEPS, and moves the local port off one another builder uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+181
−18
apps/mojo/DEPS+1 −0apps/mojo/Makefile+3 −2apps/mojo/README.md+109 −0apps/mojo/src/libc.mojo+31 −12apps/mojo/src/main.mojo+4 −4apps/mojo/tests/smoke.sh+33 −0
diff --git a/apps/mojo/DEPS b/apps/mojo/DEPS| new file mode 100644 |
| index 0000000..a82556c |
| --- /dev/null |
| +++ b/apps/mojo/DEPS |
| @@ -0,0 +1 @@ |
| +sqlite (https://www.sqlite.org/2026/sqlite-amalgamation-3540100.zip, 3.54.1, compiled from source and linked statically) |
diff --git a/apps/mojo/Makefile b/apps/mojo/Makefile| index 310b19d..6286538 100644 |
| --- a/apps/mojo/Makefile |
| +++ b/apps/mojo/Makefile |
| @@ -87,14 +87,15 @@ $(ARM64_SQLITE): $(SQLITE_SRC) |
| $(ARM64) cc $(SQLITE_CFLAGS) -c $< -o $(dir $@)sqlite3.o |
| $(ARM64) ar rcs $@ $(dir $@)sqlite3.o |
| -test: $(SOURCES) $(ASSETS) tests/test_main.mojo $(HOST_SQLITE) |
| +test: dist/app $(SOURCES) $(ASSETS) tests/test_main.mojo $(HOST_SQLITE) |
| @mkdir -p $(CACHE)/test $(HOST_MOJO_CACHE) |
| MODULAR_CACHE_DIR=$(HOST_MOJO_CACHE) mojo build tests/test_main.mojo -I src \ |
| -Xlinker $(HOST_SQLITE) -o $(CACHE)/test/test_main |
| $(CACHE)/test/test_main |
| + tests/smoke.sh dist/app |
| # Runs the host build on 127.0.0.1 for the shared suite. |
| -PORT ?= 18431 |
| +PORT ?= 39517 |
| DB ?= $(TMPDIR)/mojo.db |
| run: dist/app |
| dist/app --listen 127.0.0.1:$(PORT) --db $(DB) |
diff --git a/apps/mojo/README.md b/apps/mojo/README.md| new file mode 100644 |
| index 0000000..55c5069 |
| --- /dev/null |
| +++ b/apps/mojo/README.md |
| @@ -0,0 +1,109 @@ |
| +# 🤖 todo-mojo |
| + |
| +TodoMVC for the CARLOS bakeoff, written in [Mojo](https://mojolang.org) |
| +1.2.0. The server renders every page, every change is a plain form POST, |
| +and the whole app works with JavaScript turned off. A small script makes |
| +it behave like the reference TodoMVC when JavaScript is on. |
| + |
| +It builds and passes the shared suite, but it isn't deployed: see |
| +`STATUS.md`. In short, every Mojo binary loads |
| +`libKGENCompilerRTShared.so` at start-up, Mojo ships no static version of |
| +it, and Carloku's binary kind takes exactly one file. |
| + |
| +## The stack, and why |
| + |
| +Mojo's standard library has no networking, no SQLite binding and no |
| +templating, and Mojo 1.2.0 has no package that covers them and builds for |
| +linux-arm64 without other shared libraries. So the app is small, plain |
| +Mojo on top of two C interfaces it already has: |
| + |
| +- **libc, through `external_call`** (`src/libc.mojo`). The server is one |
| + thread running a `poll(2)` loop over non-blocking sockets, so a slow or |
| + idle browser connection never holds up anyone else. It speaks |
| + HTTP/1.1 with keep-alive, which is enough for browsers and the |
| + Carloku edge. The Mojo runtime starts its own worker threads and |
| + catches SIGTERM, so the app installs a small C-ABI handler that writes |
| + to a pipe the loop watches, and shutdown is an ordinary return from the |
| + loop: close the sockets, close SQLite (which |
| + checkpoints the WAL), remove the socket file, exit 0. |
| +- **SQLite's C API** (`src/db.mojo`), from the 3.54.1 amalgamation, |
| + compiled by the Makefile for each target and linked in statically. |
| + It runs in WAL mode with `synchronous=NORMAL`, and migrations are |
| + numbered in `PRAGMA user_version` and only ever add. |
| +- **HTML is built with string appends** (`src/render.mojo`), with one |
| + `escape` function for every piece of user text. The page is TodoMVC's |
| + own template from `shared/reference.html`. |
| +- **The static files are compiled in.** `scripts/embed.py` turns |
| + `base.css`, `index.css`, `app.css` and `app.js` into Mojo string |
| + constants, so the binary is the whole app and the CSS is served byte |
| + for byte. |
| + |
| +### Without JavaScript, and with it |
| + |
| +Each control the contract asks for is a real form or link, laid over the |
| +spot you'd click with JavaScript on, so the page looks the same either |
| +way: an invisible submit button over each checkbox, another over the |
| +"mark all" chevron, and an edit link over each title. The forms use |
| +`display: contents` so they add no boxes to TodoMVC's layout. |
| + |
| +`static/app.js` (one ES module, no build step) marks the page with |
| +`html.js`, which hides those controls, and then sends the same form POSTs |
| +with `fetch`. It adds the header `X-Requested-With: fetch`, and for that |
| +header the server answers 200 with the updated page instead of a 303; the |
| +script swaps in the new `section.main` and `footer.footer`. Requests go |
| +one at a time, in order. Double-click, Enter, blur and Escape work as in |
| +TodoMVC. |
| + |
| +### Lists and cookies |
| + |
| +Each visitor's list is keyed by a cookie, `todo_list`, holding 256 random |
| +bits from `getrandom(2)` as hex. A missing or malformed value gets a new |
| +one, and so a new, empty list. Every query includes the list key, so an |
| +id from another list answers 404 and changes nothing. A POST whose |
| +`Origin` isn't `https://$CARLOS_HOST` (or `http://<listen address>` |
| +locally) answers 403. |
| + |
| +## Honest trade-offs |
| + |
| +- **Trust class C** (2026-10-23): todo text is stored as plain text and |
| + the server can read it. The cookie value is also stored as is, so a |
| + copy of the database is enough to open anyone's list. |
| +- **One thread.** Requests are handled one after another. Each takes well |
| + under a millisecond, so this is fine for a todo list, and it means one |
| + SQLite connection with no locking to think about. |
| +- **A deliberately small HTTP server.** No chunked request bodies (a 400), |
| + no compression, request heads up to 64 KiB and bodies up to 1 MiB. |
| +- **Not one file.** The binary depends on Mojo's runtime libraries, which |
| + is why it isn't on Carloku. |
| + |
| +## Build, run, test, deploy |
| + |
| +Toolchains come from `source ../../scripts/env.sh` (the Makefile also |
| +puts pixi's `mojo` on `PATH`). |
| + |
| +| Command | What it does | |
| +|---|---| |
| +| `make build` | Host build into `dist/app` | |
| +| `make build-arm64` | linux-arm64 build into `dist/app-linux-arm64`, run inside the emulated arm64 root (`scripts/arm64.sh`); slow, since everything is emulated | |
| +| `make test` | Builds and runs `tests/test_main.mojo`: parsing, escaping, the store, and every route through `App.handle` | |
| +| `make run` | Runs the host build on `127.0.0.1:39517` (`PORT=`, `DB=` to change) | |
| +| `make boot-check` | `scripts/boot-check.sh` on the arm64 binary, under qemu | |
| +| `make clean` | Removes `dist/` and generated files | |
| +| `make clean-deps` | Also removes the compiled SQLite and Mojo's compilation caches (kept in `.cache/`); the downloaded SQLite source stays | |
| +| `make bump` / `make unbump` | Changes / restores the page comment in `src/app.mojo` | |
| +| `make deploy` | `carlos deploy` of `dist/app-linux-arm64` to `todo-mojo` on account `bfj` | |
| + |
| +The first build downloads the SQLite amalgamation into `.cache/src/` and |
| +checks its SHA3-256. |
| + |
| +The shared behaviour suite: |
| + |
| +```sh |
| +make build |
| +dist/app --listen 127.0.0.1:39517 --db "$TMPDIR/mojo.db" & |
| +../../scripts/test-app.sh http://127.0.0.1:39517 "$TMPDIR/mojo-suite.json" |
| +``` |
| + |
| +The binary takes `--socket <path> --db <path>` on Carloku, or |
| +`--listen 127.0.0.1:<port> --db <path>` locally, and refuses to listen on |
| +any other address. |
diff --git a/apps/mojo/src/libc.mojo b/apps/mojo/src/libc.mojo| index ca90b96..dfadd9d 100644 |
| --- a/apps/mojo/src/libc.mojo |
| +++ b/apps/mojo/src/libc.mojo |
| @@ -26,7 +26,6 @@ comptime EAGAIN = 11 |
| comptime SIGINT = 2 |
| comptime SIGTERM = 15 |
| -comptime SIG_BLOCK = 0 |
| def errno() -> Int: |
| @@ -150,15 +149,35 @@ def poll(mut fds: List[Int32], timeout_ms: Int) -> Int: |
| return Int(external_call["poll", c_int](fds.unsafe_ptr(), Int(len(fds) // 2), c_int(timeout_ms))) |
| +# The write end of the shutdown pipe sits at this fixed descriptor, because |
| +# a signal handler can't capture state and Mojo has no global variables. |
| +comptime TERM_PIPE_FD = 100 |
| +comptime F_GETFD = 1 |
| + |
| + |
| +def _on_termination(sig: c_int): |
| + # Runs in a signal handler, on whichever thread the kernel picks: only |
| + # async-signal-safe calls here. |
| + var b = UInt8(sig) |
| + _ = external_call["write", Int](c_int(TERM_PIPE_FD), Pointer(to=b), Int(1)) |
| + |
| + |
| def termination_fd() raises -> Int: |
| - """Blocks SIGTERM and SIGINT and returns a signalfd that reads them.""" |
| - var mask = List[UInt8](length=128, fill=0) |
| - _ = external_call["sigemptyset", c_int](mask.unsafe_ptr()) |
| - _ = external_call["sigaddset", c_int](mask.unsafe_ptr(), c_int(SIGTERM)) |
| - _ = external_call["sigaddset", c_int](mask.unsafe_ptr(), c_int(SIGINT)) |
| - if external_call["sigprocmask", c_int](c_int(SIG_BLOCK), mask.unsafe_ptr(), Int(0)) != 0: |
| - raise Error("sigprocmask: errno " + String(errno())) |
| - var fd = Int(external_call["signalfd", c_int](c_int(-1), mask.unsafe_ptr(), c_int(SOCK_NONBLOCK | SOCK_CLOEXEC))) |
| - if fd < 0: |
| - raise Error("signalfd: errno " + String(errno())) |
| - return fd |
| + """Returns a descriptor that becomes readable on SIGTERM or SIGINT. |
| + |
| + The Mojo runtime starts worker threads before `main` and catches SIGTERM |
| + itself, so blocking the signal in this thread (or using a signalfd) |
| + can't work. Instead a handler writes a byte to a pipe the poll loop |
| + watches. |
| + """ |
| + var fds = List[Int32](length=2, fill=0) |
| + if external_call["pipe2", c_int](fds.unsafe_ptr(), c_int(SOCK_NONBLOCK | SOCK_CLOEXEC)) != 0: |
| + raise Error("pipe2: errno " + String(errno())) |
| + if external_call["fcntl", c_int](c_int(TERM_PIPE_FD), c_int(F_GETFD)) != -1: |
| + raise Error("descriptor " + String(TERM_PIPE_FD) + " is already in use") |
| + if external_call["dup3", c_int](fds[1], c_int(TERM_PIPE_FD), c_int(SOCK_CLOEXEC)) < 0: |
| + raise Error("dup3: errno " + String(errno())) |
| + close_fd(Int(fds[1])) |
| + _ = external_call["signal", Int](c_int(SIGTERM), _on_termination) |
| + _ = external_call["signal", Int](c_int(SIGINT), _on_termination) |
| + return Int(fds[0]) |
diff --git a/apps/mojo/src/main.mojo b/apps/mojo/src/main.mojo| index 36c1c20..a308630 100644 |
| --- a/apps/mojo/src/main.mojo |
| +++ b/apps/mojo/src/main.mojo |
| @@ -58,6 +58,7 @@ def main() raises: |
| i += 2 |
| if not db_path or (not socket_path) == (not listen): |
| usage() |
| + var term_fd = libc.termination_fd() |
| var origins = List[String]() |
| var host = getenv("CARLOS_HOST", "") |
| @@ -77,9 +78,8 @@ def main() raises: |
| origins.append("http://" + listen) |
| var app = App(Store(db_path), origins^, secure_cookie=Bool(socket_path)) |
| - var sig_fd = libc.termination_fd() |
| print("todo-mojo listening on", socket_path if socket_path else listen) |
| - serve(app, listen_fd, sig_fd) |
| + serve(app, listen_fd, term_fd) |
| libc.close_fd(listen_fd) |
| if socket_path: |
| @@ -87,7 +87,7 @@ def main() raises: |
| print("todo-mojo stopped") |
| -def serve(app: App, listen_fd: Int, sig_fd: Int): |
| +def serve(app: App, listen_fd: Int, term_fd: Int): |
| """One thread, non-blocking sockets, poll(2). Returns on SIGTERM or SIGINT.""" |
| var conns = List[Conn]() |
| var fds = List[Int32]() |
| @@ -95,7 +95,7 @@ def serve(app: App, listen_fd: Int, sig_fd: Int): |
| fds.clear() |
| fds.append(Int32(listen_fd)) |
| fds.append(Int32(POLLIN)) |
| - fds.append(Int32(sig_fd)) |
| + fds.append(Int32(term_fd)) |
| fds.append(Int32(POLLIN)) |
| for idx in range(len(conns)): |
| fds.append(Int32(conns[idx].fd)) |
diff --git a/apps/mojo/tests/smoke.sh b/apps/mojo/tests/smoke.sh| new file mode 100755 |
| index 0000000..6a7b33f |
| --- /dev/null |
| +++ b/apps/mojo/tests/smoke.sh |
| @@ -0,0 +1,33 @@ |
| +#!/usr/bin/env bash |
| +# Runs the real binary on a unix socket and checks what the unit tests |
| +# can't: the poll loop, keep-alive, the cookie round trip, and a clean |
| +# exit on SIGTERM. |
| +# tests/smoke.sh <binary> |
| +set -euo pipefail |
| +bin=$1 |
| +work=$(mktemp -d -p "${TMPDIR:-$HOME/.cache}") |
| +pid= |
| +trap '[ -n "$pid" ] && kill "$pid" 2>/dev/null; rm -rf "$work"' EXIT |
| +CARLOS_HOST=smoke.invalid "$bin" --socket "$work/app.sock" --db "$work/app.db" > "$work/log" 2>&1 & |
| +pid=$! |
| +for _ in $(seq 1 50); do [ -S "$work/app.sock" ] && break; sleep 0.1; done |
| +# The cookie is Secure in socket mode, so curl wouldn't send it back over http. |
| +c() { curl -s --unix-socket "$work/app.sock" -H "Cookie: todo_list=$(printf "5%.0s" {1..64})" "$@"; } |
| +fail() { echo "smoke: $*" >&2; cat "$work/log" >&2; exit 1; } |
| + |
| +[ "$(c http://smoke.invalid/healthz)" = ok ] || fail "healthz" |
| +# Two requests on one connection. |
| +n=$(c -o /dev/null -w '%{num_connects}\n' http://smoke.invalid/healthz -o /dev/null http://smoke.invalid/api/version | awk '{s+=$1} END {print s}') |
| +[ "$n" = 1 ] || fail "keep-alive: $n connections" |
| +code=$(c -o /dev/null -w '%{http_code}' -H 'Origin: https://smoke.invalid' --data-urlencode 'title=naïve <b>' http://smoke.invalid/todos) |
| +[ "$code" = 303 ] || fail "add answered $code" |
| +c http://smoke.invalid/ | grep -q '<label>naïve <b></label>' || fail "item not shown" |
| +code=$(c -o /dev/null -w '%{http_code}' -H 'Origin: https://evil.example' -d title=x http://smoke.invalid/todos) |
| +[ "$code" = 403 ] || fail "cross-origin POST answered $code" |
| + |
| +kill -TERM "$pid" |
| +set +e; wait "$pid"; status=$?; set -e |
| +pid= |
| +[ "$status" = 0 ] || fail "exit status $status after SIGTERM" |
| +[ ! -e "$work/app.sock" ] || fail "socket left behind" |
| +echo "smoke: ok" |