carlos / todomvc-bakeoff Public
fix(ocaml): review findings: bounded request heads, edits survive re-renders, Unicode trim, SQLite built from source
- Run cohttp-eio's connection loop through a per-connection byte budget (64 KiB head, 431 beyond it; at most 256 connections). - app.js keeps an open editor, its text, focus and selection when a response re-renders the list. - Titles are trimmed of Unicode whitespace (and U+FEFF) on add and edit. - SQLite 3.54.1 is compiled from its amalgamation into .cache for each target; make clean-deps deletes it and keeps the tarball.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+214
−32
apps/ocaml/DEPS+2 −0apps/ocaml/Makefile+8 −4apps/ocaml/README.md+16 −6apps/ocaml/bin/dune+1 −1apps/ocaml/bin/main.ml+58 −1apps/ocaml/src/app.ml+2 −2apps/ocaml/src/form.ml+25 −0apps/ocaml/static/app.js+32 −6apps/ocaml/test/test_todo.ml+11 −1apps/ocaml/tools/arm64.sh+6 −2apps/ocaml/tools/sqlite.sh+45 −0apps/ocaml/tools/x86.sh+8 −9
diff --git a/apps/ocaml/DEPS b/apps/ocaml/DEPS| index 9832193..8c7fdd4 100644 |
| --- a/apps/ocaml/DEPS |
| +++ b/apps/ocaml/DEPS |
| @@ -3,5 +3,7 @@ eio_posix |
| cohttp-eio |
| http |
| uri |
| +cstruct |
| sqlite3 |
| +sqlite (3.54.1 amalgamation, built by tools/sqlite.sh) |
| alcotest |
diff --git a/apps/ocaml/Makefile b/apps/ocaml/Makefile| index 117ef10..d1b90dc 100644 |
| --- a/apps/ocaml/Makefile |
| +++ b/apps/ocaml/Makefile |
| @@ -12,26 +12,30 @@ static/%.css: ../../shared/%.css |
| cp $< $@ |
| build: $(CSS) |
| + mkdir -p _build |
| APP_VERSION=$(VERSION) tools/x86.sh $(DUNE) --build-dir $(CURDIR)/_build/x86 ./bin/main.exe |
| mkdir -p dist && install -m 755 _build/x86/default/bin/main.exe dist/app |
| build-arm64: $(CSS) |
| + mkdir -p _build |
| APP_VERSION=$(VERSION) LINK_FLAGS='-ccopt -static' \ |
| tools/arm64.sh $(DUNE) --build-dir $(CURDIR)/_build/arm64 ./bin/main.exe |
| mkdir -p dist && install -m 755 _build/arm64/default/bin/main.exe dist/app-linux-arm64 |
| echo $(VERSION) > dist/app-linux-arm64.version |
| test: $(CSS) |
| + mkdir -p _build |
| tools/x86.sh dune test --root . --build-dir $(CURDIR)/_build/x86 --force |
| clean: |
| rm -rf dist _build $(CSS) |
| -# Deletes both opam switches (every compiled library) but keeps the opam |
| -# repositories and the download cache, so the next build compiles every |
| -# library from its already-downloaded source. |
| +# Deletes both opam switches and both SQLite builds (every compiled |
| +# library) but keeps the opam repositories, opam's download cache and the |
| +# SQLite tarball in .cache/src, so the next build compiles every library |
| +# from its already-downloaded source. |
| clean-deps: clean |
| - rm -rf .cache/opam-arm64/default .cache/opam-x86/default |
| + rm -rf .cache/opam-arm64/default .cache/opam-x86/default .cache/sqlite-arm64 .cache/sqlite-x86 |
| bump: |
| sed -i 's/^let build_marker = "todomvc"$$/let build_marker = "todomvc-bumped"/' src/app.ml |
diff --git a/apps/ocaml/README.md b/apps/ocaml/README.md| index b59f2fb..645e1dd 100644 |
| --- a/apps/ocaml/README.md |
| +++ b/apps/ocaml/README.md |
| @@ -14,7 +14,7 @@ Live at |
| |---|---|---| |
| | Concurrency | [Eio](https://github.com/ocaml-multicore/eio) on the `eio_posix` backend | Eio is OCaml 5's direct-style IO library: plain functions, no monads. The posix backend uses `poll`, which works everywhere; `eio_linux` needs io_uring, which sandboxes and qemu often refuse | |
| | HTTP | [cohttp-eio](https://github.com/mirage/ocaml-cohttp) | The long-standing OCaml HTTP library, on Eio. It serves a listening socket you hand it, so a unix socket (`--socket`) and TCP (`--listen`) are the same code | |
| -| SQLite | [sqlite3-ocaml](https://github.com/mmottl/sqlite3-ocaml) | Thin bindings to the C library, which links statically. Prepared statements are cached per connection | |
| +| SQLite | [sqlite3-ocaml](https://github.com/mmottl/sqlite3-ocaml), over SQLite 3.54.1 built from its amalgamation by `tools/sqlite.sh` | Thin bindings to the C library. SQLite is compiled into `.cache/` for each target and linked statically, so no system SQLite is involved. Prepared statements are cached per connection | |
| | HTML | Hand-written `Buffer` rendering with one `escape` function | It's one page. A template library would be more code than the page, and the escaping rule stays in one place you can read | |
| | Assets | Compiled into the binary by a dune rule (`gen/embed.ml`) | One file to ship. `shared/base.css` and `shared/index.css` are copied in at build time and served byte for byte | |
| | Randomness | `getrandom(2)` through a ten-line C stub | Cookie values need a CSPRNG, and this needs no `/dev/urandom` inside the sandbox and no crypto library | |
| @@ -30,7 +30,12 @@ The code is in four small modules under `src/`: |
| - `form.ml`: form bodies and cookies. |
| `bin/main.ml` parses the flags, opens the database, adapts `App.handle` |
| -to cohttp-eio, and stops cleanly on SIGTERM. |
| +to cohttp-eio, and stops cleanly on SIGTERM. It runs cohttp-eio's |
| +per-connection loop itself rather than `Cohttp_eio.Server.run`, because |
| +`run` reads headers into an unbounded buffer. Here every connection reads |
| +through a byte budget: 64 KiB for a request's line and headers (more gets |
| +431), 1 MiB for a body (more gets 413), and at most 256 connections at |
| +once. |
| ### How the page works |
| @@ -75,15 +80,20 @@ make clean-deps # also the compiled libraries; downloads stay |
| The first `make build-arm64` takes a long time: every library compiles |
| under qemu. After that, a rebuild only recompiles what changed. |
| +SQLite is built from the amalgamation by `tools/sqlite.sh`: the tarball is |
| +downloaded once into `.cache/src` and checked against sqlite.org's |
| +SHA3-256, then compiled into `.cache/sqlite-x86` or `.cache/sqlite-arm64`, |
| +where the `sqlite3` opam package finds it through `pkg-config`. |
| +`make clean-deps` deletes those builds and keeps the tarball. |
| + |
| Two things about docs-t1 shape the scripts in `tools/`: |
| - The x86 compiler is the bakeoff's opam switch. opam won't treat a |
| compiler from another opam root as `ocaml-system`, so `tools/x86.sh` |
| links it into `.cache/x86-bin`. |
| -- docs-t1 has no SQLite headers or `pkg-config`, and no `sudo`. So |
| - `tools/x86.sh` unpacks the Debian packages (`apt-get download`, no |
| - root) into `.cache/x86-sysroot`. The local build links the system |
| - `libsqlite3.so.0`; the shipped arm64 build links SQLite statically. |
| +- docs-t1 has no `pkg-config` and no `sudo`, so `tools/x86.sh` unpacks |
| + Debian's `pkgconf` (`apt-get download`, no root) into |
| + `.cache/x86-sysroot`. |
| ## Run |
diff --git a/apps/ocaml/bin/dune b/apps/ocaml/bin/dune| index 9cbd141..646ba82 100644 |
| --- a/apps/ocaml/bin/dune |
| +++ b/apps/ocaml/bin/dune |
| @@ -2,7 +2,7 @@ |
| (name main) |
| (public_name todo-ocaml) |
| (package todo_ocaml) |
| - (libraries todo eio eio_posix cohttp-eio http uri) |
| + (libraries todo eio eio_posix cohttp-eio http uri cstruct) |
| (link_flags (:standard (:include link_flags.sexp)))) |
| ; LINK_FLAGS="-ccopt -static" links statically (the shipped arm64 build). |
diff --git a/apps/ocaml/bin/main.ml b/apps/ocaml/bin/main.ml| index 48a4cec..4d3523e 100644 |
| --- a/apps/ocaml/bin/main.ml |
| +++ b/apps/ocaml/bin/main.ml |
| @@ -6,6 +6,46 @@ let usage = "app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>" |
| (* Bodies are form posts; 1 MiB is far more than any title needs. *) |
| let max_body = 1 lsl 20 |
| +(* The request line and headers of one request, together. *) |
| +let max_head = 64 * 1024 |
| + |
| +(* Each connection costs at most about max_head + max_body of buffers. *) |
| +let max_connections = 256 |
| + |
| +(* A socket that refuses to read more than [remaining] bytes. cohttp-eio |
| + parses headers from an unbounded buffer, so every read goes through one |
| + of these: a connection starts with max_head bytes, and each request it |
| + carries gets max_body + max_head more (its body, then the next request's |
| + head). Running out raises [Head_too_large]. *) |
| +module Budget = struct |
| + exception Head_too_large |
| + |
| + type t = { flow : Eio.Flow.source_ty Eio.Resource.t; mutable remaining : int } |
| + |
| + let read_methods = [] |
| + |
| + let single_read t buf = |
| + if t.remaining <= 0 then raise Head_too_large; |
| + let buf = if Cstruct.length buf > t.remaining then Cstruct.sub buf 0 t.remaining else buf in |
| + let n = Eio.Flow.single_read t.flow buf in |
| + t.remaining <- t.remaining - n; |
| + n |
| + |
| + let handler = Eio.Flow.Pi.source (module struct |
| + type nonrec t = t |
| + let read_methods = read_methods |
| + let single_read = single_read |
| + end) |
| + |
| + let source t : Eio.Flow.source_ty Eio.Resource.t = Eio.Resource.T (t, handler) |
| + |
| + (* The budget of the connection whose request is being handled. *) |
| + let key : t Eio.Fiber.key = Eio.Fiber.create_key () |
| + |
| + let next_request () = |
| + Option.iter (fun t -> t.remaining <- max_body + max_head) (Eio.Fiber.get key) |
| +end |
| + |
| let fail fmt = Printf.ksprintf (fun s -> prerr_endline ("app: " ^ s); exit 2) fmt |
| let parse_listen s = |
| @@ -31,6 +71,7 @@ let to_app_request req body = |
| } |
| let callback app _conn req body = |
| + Budget.next_request (); |
| let body = |
| match Http.Request.meth req with |
| | `POST -> Eio.Buf_read.(parse take_all) ~max_size:max_body body |
| @@ -49,6 +90,22 @@ let callback app _conn req body = |
| Cohttp_eio.Server.respond ~headers ~status ~body:(Eio.Flow.string_source "") () |
| | _ -> Cohttp_eio.Server.respond_string ~headers ~status ~body:res.body () |
| +(* One connection: cohttp-eio's own loop over keep-alive requests, reading |
| + through a Budget. A head too large for it gets 431 and the connection |
| + closes. *) |
| +let connection server socket peer = |
| + Eio.Switch.run @@ fun sw -> |
| + let budget = { Budget.flow = (socket :> Eio.Flow.source_ty Eio.Resource.t); remaining = max_head } in |
| + let input = Eio.Buf_read.of_flow ~max_size:max_head (Budget.source budget) in |
| + Eio.Fiber.with_binding Budget.key budget @@ fun () -> |
| + try |
| + Eio.Buf_write.with_flow socket @@ fun output -> |
| + try Cohttp_eio.Server.callback server (sw, peer) input output |
| + with Budget.Head_too_large | Eio.Buf_read.Buffer_limit_exceeded -> |
| + Eio.Buf_write.string output |
| + "HTTP/1.1 431 Request Header Fields Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n" |
| + with Eio.Io (Eio.Net.E (Connection_reset _), _) -> () |
| + |
| let () = |
| let socket = ref "" and listen = ref "" and db = ref "" in |
| Arg.parse |
| @@ -83,7 +140,7 @@ let () = |
| Eio.Fiber.first |
| (fun () -> Eio.Condition.await_no_mutex stop) |
| (fun () -> |
| - Cohttp_eio.Server.run sock server ~on_error:(fun e -> |
| + Eio.Net.run_server sock (connection server) ~max_connections ~on_error:(fun e -> |
| Printf.eprintf "app: %s\n%!" (Printexc.to_string e))))); |
| (match addr with `Unix path -> (try Sys.remove path with Sys_error _ -> ()) | _ -> ()); |
| Todo.Store.close store; |
diff --git a/apps/ocaml/src/app.ml b/apps/ocaml/src/app.ml| index b8a238b..12abd69 100644 |
| --- a/apps/ocaml/src/app.ml |
| +++ b/apps/ocaml/src/app.ml |
| @@ -117,7 +117,7 @@ let action_of_path path = |
| let perform store ~list fields = function |
| | Add -> |
| - let title = String.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) in |
| + let title = Form.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) in |
| if title <> "" then Store.add store ~list title; |
| true |
| | Toggle_all -> Store.toggle_all store ~list; true |
| @@ -125,7 +125,7 @@ let perform store ~list fields = function |
| | Toggle id -> Store.toggle store ~list id |
| | Destroy id -> Store.delete store ~list id |
| | Edit id -> ( |
| - match String.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) with |
| + match Form.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) with |
| | "" -> Store.delete store ~list id |
| | title -> Store.rename store ~list id title) |
diff --git a/apps/ocaml/src/form.ml b/apps/ocaml/src/form.ml| index 701f247..d828438 100644 |
| --- a/apps/ocaml/src/form.ml |
| +++ b/apps/ocaml/src/form.ml |
| @@ -44,3 +44,28 @@ let cookie header name = |
| | Some i when String.trim (String.sub part 0 i) = name -> |
| Some (String.trim (String.sub part (i + 1) (String.length part - i - 1))) |
| | _ -> None) |
| + |
| +(* Unicode White_Space, plus U+FEFF (byte order mark), the set JavaScript's |
| + String.prototype.trim removes. *) |
| +let is_space u = |
| + match Uchar.to_int u with |
| + | 0x09 | 0x0A | 0x0B | 0x0C | 0x0D | 0x20 | 0x85 | 0xA0 | 0x1680 | 0x2028 | 0x2029 | 0x202F |
| + | 0x205F | 0x3000 | 0xFEFF -> |
| + true |
| + | n -> n >= 0x2000 && n <= 0x200A |
| + |
| +(* Removes leading and trailing whitespace by that definition, so a title |
| + of only non-breaking spaces trims to nothing. [s] is valid UTF-8. *) |
| +let trim s = |
| + let n = String.length s in |
| + let rec go i first last = |
| + if i >= n then (first, last) |
| + else |
| + let d = String.get_utf_8_uchar s i in |
| + let next = i + Uchar.utf_decode_length d in |
| + if is_space (Uchar.utf_decode_uchar d) then go next first last |
| + else go next (if first < 0 then i else first) next |
| + in |
| + match go 0 (-1) 0 with |
| + | -1, _ -> "" |
| + | first, last -> String.sub s first (last - first) |
diff --git a/apps/ocaml/static/app.js b/apps/ocaml/static/app.js| index 308dfd9..78bcbda 100644 |
| --- a/apps/ocaml/static/app.js |
| +++ b/apps/ocaml/static/app.js |
| @@ -27,14 +27,40 @@ function post(form) { |
| }); |
| } |
| +// True while render() swaps the list, so that losing focus to the swap |
| +// doesn't count as finishing an edit. |
| +let swapping = false; |
| + |
| // Replaces section.main and footer.footer with the server's; either may be |
| -// absent when the list is empty. The header, and what's typed in it, stays. |
| +// absent when the list is empty. The header, and what's typed in it, stays, |
| +// and so does an edit in progress: the item being edited stays open in the |
| +// new list, with its unsaved text, focus and cursor. |
| function render(html) { |
| const next = new DOMParser().parseFromString(html, 'text/html').querySelector('.todoapp'); |
| - for (const part of ['.main', '.footer']) app.querySelector(`:scope > ${part}`)?.remove(); |
| - for (const part of ['.main', '.footer']) { |
| - const el = next.querySelector(`:scope > ${part}`); |
| - if (el) app.append(el); |
| + const editing = app.querySelector('.todo-list li.editing'); |
| + const input = editing?.querySelector('.edit'); |
| + const focused = input && document.activeElement === input; |
| + const [start, end] = input ? [input.selectionStart, input.selectionEnd] : []; |
| + |
| + swapping = true; |
| + try { |
| + for (const part of ['.main', '.footer']) app.querySelector(`:scope > ${part}`)?.remove(); |
| + for (const part of ['.main', '.footer']) { |
| + const el = next.querySelector(`:scope > ${part}`); |
| + if (el) app.append(el); |
| + } |
| + } finally { |
| + swapping = false; |
| + } |
| + |
| + const li = editing && app.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`); |
| + if (!li) return; |
| + li.classList.add('editing'); |
| + const fresh = li.querySelector('.edit'); |
| + fresh.value = input.value; |
| + if (focused) { |
| + fresh.focus(); |
| + fresh.setSelectionRange(start, end); |
| } |
| } |
| @@ -85,5 +111,5 @@ app.addEventListener('keydown', (e) => { |
| }); |
| app.addEventListener('focusout', (e) => { |
| - if (e.target.matches('.edit')) save(e.target.closest('li')); |
| + if (!swapping && e.target.matches('.edit')) save(e.target.closest('li')); |
| }); |
diff --git a/apps/ocaml/test/test_todo.ml b/apps/ocaml/test/test_todo.ml| index c574b88..b02d6b5 100644 |
| --- a/apps/ocaml/test/test_todo.ml |
| +++ b/apps/ocaml/test/test_todo.ml |
| @@ -24,6 +24,14 @@ let form_decoding () = |
| Alcotest.(check string) "malformed escapes are kept" "100%" (Form.decode "100%"); |
| Alcotest.(check string) "bad hex is kept" "%zz" (Form.decode "%zz") |
| +let trimming () = |
| + Alcotest.(check string) "ascii" "a b" (Form.trim " \t a b \n"); |
| + Alcotest.(check string) "nbsp, ideographic, em space, bom" "naïve 🚀" |
| + (Form.trim "\u{00A0}\u{3000}\u{2003}\u{FEFF}naïve 🚀\u{00A0}\u{202F}"); |
| + Alcotest.(check string) "only nbsp" "" (Form.trim "\u{00A0}\u{00A0}"); |
| + Alcotest.(check string) "inner kept" "a\u{00A0}b" (Form.trim "a\u{00A0}b"); |
| + Alcotest.(check string) "empty" "" (Form.trim "") |
| + |
| let cookies () = |
| Alcotest.(check (option string)) "finds by name" (Some "abc") |
| (Form.cookie "a=1; todomvc_list=abc; b=2" "todomvc_list"); |
| @@ -100,6 +108,7 @@ let add_and_redirect () = |
| let r = post app c "/todos" "title=y" in |
| Alcotest.(check (option string)) "missing filter" (Some "/") (header r "location"); |
| ignore (post app c "/todos" "title=+++&filter=all"); |
| + ignore (post app c "/todos" "title=%C2%A0%C2%A0&filter=all"); |
| let body = page app c "/" in |
| Alcotest.(check int) "three items, blank skipped" 3 (count body "<li data-id="); |
| Alcotest.(check bool) "trimmed" true (contains body "<label>buy milk</label>"); |
| @@ -158,7 +167,7 @@ let edit_and_destroy () = |
| Alcotest.(check bool) "edit form" true (contains editing {|action="/todos/2/edit"|}); |
| ignore (post app c "/todos/2/edit" "title=++renamed++&filter=all"); |
| Alcotest.(check bool) "renamed" true (contains (page app c "/") "<label>renamed</label>"); |
| - ignore (post app c "/todos/2/edit" "title=+&filter=all"); |
| + ignore (post app c "/todos/2/edit" "title=%C2%A0%E3%80%80&filter=all"); |
| Alcotest.(check int) "empty edit deletes" 1 (count (page app c "/") "<li data-id="); |
| ignore (post app c "/todos/1/destroy" "filter=all"); |
| Alcotest.(check int) "destroyed" 0 (count (page app c "/") "<li data-id=") |
| @@ -209,6 +218,7 @@ let platform_routes () = |
| let () = |
| Alcotest.run "todo" |
| [ ("form", [ Alcotest.test_case "decoding" `Quick form_decoding; |
| + Alcotest.test_case "trimming" `Quick trimming; |
| Alcotest.test_case "cookies" `Quick cookies; |
| Alcotest.test_case "escaping" `Quick escaping ]); |
| ("app", [ Alcotest.test_case "first cookie" `Quick first_cookie; |
diff --git a/apps/ocaml/tools/arm64.sh b/apps/ocaml/tools/arm64.sh| index 6a110fb..20bb937 100755 |
| --- a/apps/ocaml/tools/arm64.sh |
| +++ b/apps/ocaml/tools/arm64.sh |
| @@ -2,17 +2,21 @@ |
| # Runs a command in this app's arm64 opam switch (.cache/opam-arm64) inside |
| # the emulated Debian arm64 root, creating the switch on first use. The |
| # compiler is the root's Debian OCaml 5.3.0, used as ocaml-system; every |
| -# library is compiled from source into .cache, from the versions in |
| -# todo_ocaml.opam.locked. |
| +# library, SQLite included, is compiled from source into .cache, from the |
| +# versions in todo_ocaml.opam.locked and tools/sqlite.sh. |
| # tools/arm64.sh <command> [args...] |
| set -euo pipefail |
| app=$(cd "$(dirname "$0")/.." && pwd) |
| if [ -z "${IN_ARM64_ROOT:-}" ]; then |
| + "$app/tools/sqlite.sh" fetch |
| exec "$app/../../scripts/arm64.sh" /usr/bin/env IN_ARM64_ROOT=1 \ |
| APP_VERSION="${APP_VERSION:-dev}" LINK_FLAGS="${LINK_FLAGS:-}" "$app/tools/arm64.sh" "$@" |
| fi |
| # The root is shared: keep dune's cache off so nothing lands in its /root. |
| export OPAMROOT=$app/.cache/opam-arm64 OPAMYES=1 OPAMCOLOR=never DUNE_CACHE=disabled |
| +# SQLite is compiled here too, not taken from the root's libsqlite3-dev. |
| +"$app/tools/sqlite.sh" build "$app/.cache/sqlite-arm64" |
| +export PKG_CONFIG_PATH=$app/.cache/sqlite-arm64/lib/pkgconfig |
| if [ ! -d "$OPAMROOT/default" ]; then |
| [ -f "$OPAMROOT/config" ] || opam init -q --bare -n --disable-sandboxing default https://opam.ocaml.org |
| opam switch remove -q default >/dev/null 2>&1 || true # forget one make clean-deps deleted |
diff --git a/apps/ocaml/tools/sqlite.sh b/apps/ocaml/tools/sqlite.sh| new file mode 100755 |
| index 0000000..dfdbd73 |
| --- /dev/null |
| +++ b/apps/ocaml/tools/sqlite.sh |
| @@ -0,0 +1,45 @@ |
| +#!/usr/bin/env bash |
| +# Builds SQLite from its amalgamation into a prefix: <prefix>/lib/libsqlite3.a, |
| +# <prefix>/include/sqlite3.h and a pkg-config file, which the sqlite3 opam |
| +# package then finds. The source tarball is downloaded once into .cache/src, |
| +# checked against sqlite.org's SHA3-256, and kept by make clean-deps. |
| +# tools/sqlite.sh fetch (on the host) |
| +# tools/sqlite.sh build <prefix> (with the target's C compiler) |
| +set -euo pipefail |
| +app=$(cd "$(dirname "$0")/.." && pwd) |
| +version=3540100 |
| +sha3=b6ced585c750916b1052667239a12c25a7ff34e7f06e1e6e01ecb0427657d9c4 |
| +tarball=$app/.cache/src/sqlite-autoconf-$version.tar.gz |
| + |
| +case $1 in |
| +fetch) |
| + [ -f "$tarball" ] && exit 0 |
| + mkdir -p "$(dirname "$tarball")" |
| + curl -sSfo "$tarball.part" "https://sqlite.org/2026/sqlite-autoconf-$version.tar.gz" |
| + got=$(python3 -c 'import hashlib,sys; print(hashlib.sha3_256(open(sys.argv[1],"rb").read()).hexdigest())' "$tarball.part") |
| + [ "$got" = "$sha3" ] || { echo "sqlite: SHA3-256 mismatch: $got" >&2; rm -f "$tarball.part"; exit 1; } |
| + mv "$tarball.part" "$tarball" |
| + ;; |
| +build) |
| + prefix=$2 |
| + [ -f "$prefix/lib/libsqlite3.a" ] && exit 0 |
| + rm -rf "$prefix" && mkdir -p "$prefix/build" "$prefix/lib/pkgconfig" "$prefix/include" |
| + tar xzf "$tarball" -C "$prefix/build" --strip-components=1 \ |
| + "sqlite-autoconf-$version/sqlite3.c" "sqlite-autoconf-$version/sqlite3.h" "sqlite-autoconf-$version/sqlite3ext.h" |
| + # -fPIC: the x86 build also links it into the OCaml stubs' shared library. |
| + cc -c -O2 -fPIC -DSQLITE_THREADSAFE=1 -DSQLITE_DQS=0 -DSQLITE_DEFAULT_MEMSTATUS=0 \ |
| + -DSQLITE_OMIT_DEPRECATED -DSQLITE_OMIT_SHARED_CACHE \ |
| + -o "$prefix/build/sqlite3.o" "$prefix/build/sqlite3.c" |
| + ar rcs "$prefix/lib/libsqlite3.a" "$prefix/build/sqlite3.o" |
| + cp "$prefix/build/sqlite3.h" "$prefix/build/sqlite3ext.h" "$prefix/include/" |
| + rm -rf "$prefix/build" |
| + cat > "$prefix/lib/pkgconfig/sqlite3.pc" <<EOS |
| +Name: SQLite |
| +Description: SQL database engine, built by tools/sqlite.sh |
| +Version: ${version:0:1}.${version:1:2}.${version:4:1} |
| +Cflags: -I$prefix/include |
| +Libs: -L$prefix/lib -lsqlite3 |
| +Libs.private: -lm -lpthread |
| +EOS |
| + ;; |
| +esac |
diff --git a/apps/ocaml/tools/x86.sh b/apps/ocaml/tools/x86.sh| index cdfe4e9..2ec0b47 100755 |
| --- a/apps/ocaml/tools/x86.sh |
| +++ b/apps/ocaml/tools/x86.sh |
| @@ -1,9 +1,7 @@ |
| #!/usr/bin/env bash |
| # Runs a command in this app's x86 opam switch (.cache/opam-x86), creating |
| # it on first use. The compiler is the bakeoff's OCaml 5.3.0, used as |
| -# ocaml-system; libraries are compiled into .cache. docs-t1 has no SQLite |
| -# headers or pkg-config, so their Debian packages are unpacked into |
| -# .cache/x86-sysroot (no root needed). |
| +# ocaml-system; libraries, SQLite included, are compiled into .cache. |
| # tools/x86.sh <command> [args...] |
| set -euo pipefail |
| app=$(cd "$(dirname "$0")/.." && pwd) |
| @@ -13,25 +11,26 @@ ocaml_bin=$(dirname "$(readlink -f "$(command -v ocamlopt)")") |
| unset OPAMSWITCH OPAM_SWITCH_PREFIX OPAM_LAST_ENV CAML_LD_LIBRARY_PATH OCAML_TOPLEVEL_PATH OCAMLTOP_INCLUDE_PATH MANPATH |
| export OPAMROOT=$cache/opam-x86 OPAMYES=1 OPAMCOLOR=never DUNE_CACHE=disabled |
| -# opam ignores compilers inside another opam root, so expose this one by symlink. |
| +# opam ignores compilers inside another opam root, so expose this one by |
| +# symlink. docs-t1 has no pkg-config; Debian's is unpacked into .cache. |
| bin=$cache/x86-bin |
| if [ ! -x "$bin/pkg-config" ]; then |
| mkdir -p "$bin" "$cache/debs" |
| for f in "$ocaml_bin"/ocaml*; do ln -sf "$f" "$bin/"; done |
| - (cd "$cache/debs" && apt-get download -q libsqlite3-dev pkgconf-bin libpkgconf3 >/dev/null) |
| + (cd "$cache/debs" && apt-get download -q pkgconf-bin libpkgconf3 >/dev/null) |
| for d in "$cache"/debs/*.deb; do dpkg-deb -x "$d" "$cache/x86-sysroot"; done |
| - # The local build links the system libsqlite3.so.0 (the stubs need a shared one). |
| - ln -sf /usr/lib/x86_64-linux-gnu/libsqlite3.so.0 "$cache/x86-sysroot/usr/lib/x86_64-linux-gnu/libsqlite3.so" |
| cat > "$bin/pkg-config" <<EOS |
| #!/bin/sh |
| root=$cache/x86-sysroot |
| -LD_LIBRARY_PATH=\$root/usr/lib/x86_64-linux-gnu PKG_CONFIG_LIBDIR=\$root/usr/lib/x86_64-linux-gnu/pkgconfig \\ |
| - PKG_CONFIG_SYSROOT_DIR=\$root exec \$root/usr/bin/pkgconf "\$@" |
| +LD_LIBRARY_PATH=\$root/usr/lib/x86_64-linux-gnu PKG_CONFIG_LIBDIR=$cache/sqlite-x86/lib/pkgconfig \\ |
| + exec \$root/usr/bin/pkgconf "\$@" |
| EOS |
| chmod +x "$bin/pkg-config" |
| fi |
| export PATH=$bin:/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin |
| +"$app/tools/sqlite.sh" fetch |
| +"$app/tools/sqlite.sh" build "$cache/sqlite-x86" |
| if [ ! -d "$OPAMROOT/default" ]; then |
| [ -f "$OPAMROOT/config" ] || opam init -q --bare -n --disable-sandboxing default https://opam.ocaml.org |
| opam switch remove -q default >/dev/null 2>&1 || true # forget one make clean-deps deleted |