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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 5de4c9d5b58faefa87800d30834d1ea80d14762a parent 8fb37d6
6 files changed, +181 −18
  • apps/mojo/DEPS +1 −0
  • apps/mojo/Makefile +3 −2
  • apps/mojo/README.md +109 −0
  • apps/mojo/src/libc.mojo +31 −12
  • apps/mojo/src/main.mojo +4 −4
  • apps/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 &lt;b&gt;</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"