From 35b6d0684dda409f30e59c17cda58dad370b7be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=A7=84=28Lee=20Yunjin=29?= <168180007+gg582@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:30:30 +0900 Subject: [PATCH 1/4] feat(wasm): replace Emscripten browser bundle with WASI component pipeline (#446) * feat(wasm): replace Emscripten browser bundle with WASI component pipeline (closes #203) * ci(wasm): install wasmtime in component pipeline job for abi-layout check (cherry picked from commit f6c7cfe36a8492973fffb12a4ba15cbcb20b804c) --- .github/workflows/release-assets.yml | 24 ++- .github/workflows/wasm.yml | 152 +++----------- .gitignore | 10 +- Makefile | 151 ++++++------- README.md | 4 +- docs/api/wasm-component.md | 9 +- docs/api/wasm.md | 9 +- example/wasm-service-worker/app.c | 25 ++- example/wasm-service-worker/build.sh | 43 +--- example/wasm-service-worker/smoke.js | 42 ++-- example/wasm-service-worker/sw.js | 171 +++++---------- include/cwist/wasm/typedarray.h | 9 +- include/cwist/wasm/wasm_component.h | 45 ++++ include/cwist/wasm/wasm_entry.h | 7 +- tests/browser/sw_example.mjs | 76 +++++++ tests/wasm_component_guest.c | 38 +--- tests/wasm_smoke.c | 303 --------------------------- tests/wasm_wrapper_test.c | 67 ------ tests/wasm_wrapper_test.js | 72 ------- wasm/npm/README.md | 103 ++++----- wasm/npm/component.d.ts | 3 +- wasm/npm/component.js | 13 +- wasm/npm/http.js | 75 +++++++ wasm/npm/index.d.ts | 51 +---- wasm/npm/index.js | 175 +--------------- wasm/npm/package.json | 3 +- 26 files changed, 539 insertions(+), 1141 deletions(-) create mode 100644 tests/browser/sw_example.mjs delete mode 100644 tests/wasm_smoke.c delete mode 100644 tests/wasm_wrapper_test.c delete mode 100644 tests/wasm_wrapper_test.js create mode 100644 wasm/npm/http.js diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 4791eec04..e3c977e25 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -189,10 +189,28 @@ jobs: ref: ${{ env.TAG }} submodules: recursive - - name: Install Emscripten - uses: mymindstorm/setup-emsdk@v14 + - name: Install node 24 + uses: actions/setup-node@v4 with: - version: 5.0.0 + node-version: 24 + + - name: Install wasi-sdk 25.0 + run: | + mkdir -p "$HOME/toolchains" + curl -sSL -o /tmp/wasi-sdk.tar.gz \ + https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-x86_64-linux.tar.gz + tar -xzf /tmp/wasi-sdk.tar.gz -C "$HOME/toolchains" + + - name: Install wasm-tools 1.259.0 and wit-bindgen 0.62.0 + run: | + curl -sSL -o /tmp/wasm-tools.tar.gz \ + https://github.com/bytecodealliance/wasm-tools/releases/download/v1.259.0/wasm-tools-1.259.0-x86_64-linux.tar.gz + tar -xzf /tmp/wasm-tools.tar.gz -C /tmp + curl -sSL -o /tmp/wit-bindgen.tar.gz \ + https://github.com/bytecodealliance/wit-bindgen/releases/download/v0.62.0/wit-bindgen-0.62.0-x86_64-linux.tar.gz + tar -xzf /tmp/wit-bindgen.tar.gz -C /tmp + echo "/tmp/wasm-tools-1.259.0-x86_64-linux" >> "$GITHUB_PATH" + echo "/tmp/wit-bindgen-0.62.0-x86_64-linux" >> "$GITHUB_PATH" - name: Pack cwist-wasm and verify clean-directory install run: | diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 6a50f9f0b..53a42cd16 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -11,131 +11,6 @@ permissions: contents: read jobs: - wasm: - name: Emscripten build + smoke test - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - # lib/cjson (a WASM_SRCS member) is a submodule; the wasm archive does - # not build without it. - - name: Install Emscripten - uses: mymindstorm/setup-emsdk@v14 - with: - version: 5.0.0 - actions-cache-folder: emsdk-cache - - # actions/cache restores the cached emsdk without the executable bit on - # its bundled node, which makes `node wasm_smoke.js` fail with - # "Permission denied" on cache hits. Fix the exact node on PATH and fall - # back to the runner's system node if the bit cannot be restored. - - name: Restore exec bit on cached emsdk node - run: | - NODE_BIN="$(command -v node || true)" - echo "node on PATH: $NODE_BIN" - [ -n "$NODE_BIN" ] && chmod +x "$NODE_BIN" || true - ls -l "$NODE_BIN" || true - - # Phase 2 (issue #93): the JS wrapper is only proven if a consumer-style - # module built through wasm_entry.h serves a request via the npm wrapper - # with no Emscripten glue in the calling code. NODE is resolved the same - # way as the smoke step above. - - name: Build wrapper-test module and run it through the JS wrapper - shell: bash - run: | - NODE_BIN="$(command -v node || true)" - if [ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ]; then - make wasm-wrapper-test NODE="$NODE_BIN" - else - make wasm-wrapper-test NODE="$(command -v nodejs || echo /usr/bin/node)" - fi - - # Phase 2a done-when (issue #183): the packed tarball must install and - # load in a clean directory, i.e. `npm install` replaces the from-source - # flow. Pack, install into a temp dir outside the repo, load it there. - # NODE is resolved the same way as the other steps: emsdk's node can be - # restored from cache without its exec bit, so never call bare `node`. - - name: Pack cwist-wasm and verify clean-directory install - run: | - NODE_BIN="$(command -v node || true)" - if [ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ]; then - make wasm-dist NODE="$NODE_BIN" - else - make wasm-dist NODE="$(command -v nodejs || echo /usr/bin/node)" - fi - TGZ="$(ls dist/cwist-wasm-*.tgz | head -1)" - INSTALL_DIR="$(mktemp -d)" - cd "$INSTALL_DIR" - npm init -y >/dev/null - npm install "$OLDPWD/$TGZ" - node -e "const w = require('cwist-wasm'); \ - const r = w.parseResponseBytes(new TextEncoder().encode( \ - 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nok')); \ - if (r.status !== 200 || r.headers['Content-Type'] !== 'text/plain') process.exit(1); \ - console.log('clean-install verification passed');" - - # Phase 4 (issue #93): the end-to-end example app - routing + zod + - # template + cwist_db + sessions behind a Service Worker host - must - # build, and its WASM logic is covered by a node smoke that dispatches - # through the cwist-wasm wrapper (the SW itself needs a browser). - - name: Build WASM service-worker example and run node smoke - shell: bash - run: | - make -j$(nproc) wasm - example/wasm-service-worker/build.sh - NODE_BIN="$(command -v node || true)" - if [ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ]; then - "$NODE_BIN" example/wasm-service-worker/smoke.js - else - "$(command -v nodejs || echo /usr/bin/node)" example/wasm-service-worker/smoke.js - fi - - # wasm-smoke compiles and runs the smoke binary under node, so this is - # build + run. Catches WASM-only regressions that native CI cannot see - # (issue #93 gap 2) - e.g. clang-format corrupting EM_JS bodies. - - name: Build libcwist_wasm.a and run smoke test - shell: bash - run: | - emcc --version - make -j$(nproc) wasm - NODE_BIN="$(command -v node || true)" - if [ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ]; then - make wasm-smoke NODE="$NODE_BIN" - else - # emsdk node unusable (e.g. cache restored without exec bit): use - # the runner's system node for the smoke run. - make wasm-smoke NODE="$(command -v nodejs || echo /usr/bin/node)" - fi - - # The struct layout contract (tests/abi_layout.h) on wasm32, where - # pointers are 4 bytes: the native jobs cover the 64-bit targets. - - name: Check the public struct layout contract on wasm32 - shell: bash - run: | - NODE_BIN="$(command -v node || true)" - if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then - NODE_BIN="$(command -v nodejs || echo /usr/bin/node)" - fi - make wasm-abi-layout NODE="$NODE_BIN" - - # The EM_JS `=>` corruption this PR fixes recurs silently the next time - # anyone runs `make format`. Guard exactly the files carrying EM_JS / - # CWIST_WASM_INSTALL_VIEWS bodies with clang-format --dry-run --Werror: - # scoped to those two files on purpose, because the tree-wide - # `make format-check` compares against one pinned clang-format version - # while runners ship different ones, and an unrelated grpc/protobuf.c - # formatting drift would fail this job without touching WASM at all. - - name: Verify clang-format guards (format-check) - run: | - clang-format --dry-run --Werror \ - tests/wasm_smoke.c include/cwist/wasm/typedarray.h \ - include/cwist/wasm/wasm_entry.h src/net/http/session.c src/sys/wasi/compat.c \ - example/wasm-service-worker/app.c - # v3.7 Phase 1 (issue #201): the wasip2 socket server is only "supported" # if CI builds it and proves wasi:sockets serving on every push. Mirrors # the manual `make wasip2-smoke` gate; toolchain versions are pinned to @@ -218,6 +93,13 @@ jobs: echo "/tmp/wasm-tools-1.259.0-x86_64-linux" >> "$GITHUB_PATH" echo "/tmp/wit-bindgen-0.62.0-x86_64-linux" >> "$GITHUB_PATH" + - name: Install wasmtime 25 + run: | + curl -sSL -o /tmp/wasmtime.tar.xz \ + https://github.com/bytecodealliance/wasmtime/releases/download/v25.0.0/wasmtime-v25.0.0-x86_64-linux.tar.xz + tar -xJf /tmp/wasmtime.tar.xz -C /tmp + echo "/tmp/wasmtime-v25.0.0-x86_64-linux" >> "$GITHUB_PATH" + - name: Validate WIT worlds (wit-check) run: make wit-check @@ -239,6 +121,26 @@ jobs: - name: Browser runtime gate (WASI 0.2, positive and negative controls) run: make component-browser-test NODE="$(command -v node)" + - name: WASM service-worker example (build + browser runtime test) + run: make wasm-sw-browser-test NODE="$(command -v node)" + + - name: Check the public struct layout contract on wasm32 + run: make wasm-abi-layout + + - name: Pack cwist-wasm and verify clean-directory install + run: | + make wasm-dist NODE="$(command -v node)" + TGZ="$(ls dist/cwist-wasm-*.tgz | head -1)" + INSTALL_DIR="$(mktemp -d)" + cd "$INSTALL_DIR" + npm init -y >/dev/null + npm install "$OLDPWD/$TGZ" + node -e "const w = require('cwist-wasm'); \ + const r = w.parseResponseBytes(new TextEncoder().encode( \ + 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nok')); \ + if (r.status !== 200 || r.headers['Content-Type'] !== 'text/plain') process.exit(1); \ + console.log('clean-install verification passed');" + - name: Streaming dispatch smoke (async host send-chunk) run: | make component-stream-smoke-p3 \ diff --git a/.gitignore b/.gitignore index 91f787f96..b9c23c266 100644 --- a/.gitignore +++ b/.gitignore @@ -140,10 +140,7 @@ test_https_pool_queue benchmarks/web-frameworks/cwist_bench dist/ cwist.pc -.wasm-build/ -libcwist_wasm.a -wasm_smoke.js -wasm_smoke.wasm +wasm_abi_layout.wasm .wit-check/ .wit-bindings/ .component-build/ @@ -161,9 +158,8 @@ tests/test_proto_gen_desc_sample.cwist.pb.h __pycache__/ *.pyc -# WASM service-worker example artifacts (built by example/wasm-service-worker/build.sh) -example/wasm-service-worker/app.js -example/wasm-service-worker/app.wasm +# WASM service-worker example artifacts (built by make wasm-sw-example) +example/wasm-service-worker/build/ tutorials/*/build/ build-tutorials/ rss_gate_server diff --git a/Makefile b/Makefile index b2b185c9f..6cf7fbdd8 100644 --- a/Makefile +++ b/Makefile @@ -415,22 +415,17 @@ SRCS = src/core/sstring/sstring.c \ src/sys/health/healthz.c \ $(IO_SRC) -# --- WASM (Emscripten) static library ------------------------------------- -# Socket-independent core only: app dispatch, mux/middleware, HTTP/1 -# parser/serializer, sstring/arena/mem, query, JSON, template, validation. -# Excluded: all of src/sys/io, sockets/accept, TLS/BoringSSL, QUIC/HTTP/3, -# gRPC (needs HTTP/2), WebSocket transport, threads/scheduler, compression, -# database/sync clients. Build with e.g. -# make wasm EMCC=/workspace/emsdk/upstream/emscripten/emcc -# The wasm section sits above `all` in this file; without an explicit default -# goal, bare `make` would try to build the wasm archive with emcc. +# --- WASM core sources ------------------------------------------------------- +# Socket-independent core shared by every WASI build below: app dispatch, +# mux/middleware, HTTP/1 parser/serializer, sstring/arena/mem, query, JSON, +# template, validation, sqlite. Excluded: TLS/BoringSSL, QUIC/HTTP/3, gRPC +# (needs HTTP/2), WebSocket transport, threads/scheduler, compression and the +# database/sync clients. The WASM section sits above `all` in this file; +# without an explicit default goal, bare `make` would build a WASI archive. .DEFAULT_GOAL := all -EMCC ?= emcc -EMAR ?= emar NODE ?= node RANLIB ?= ranlib -WASM_BUILD_DIR = .wasm-build WASM_SRCS = src/core/sstring/sstring.c \ src/core/seq/seq.c \ src/core/seq/seq_auth.c \ @@ -466,64 +461,12 @@ WASM_SRCS = src/core/sstring/sstring.c \ src/core/db/db.c \ lib/sqlite3/sqlite3.c \ lib/cjson/cJSON.c -WASM_OBJS = $(WASM_SRCS:%.c=$(WASM_BUILD_DIR)/%.o) -# Host pkg-config -I paths (curl/nghttp2/...) must NOT leak into the -# emscripten sysroot, so the WASM build uses its own minimal include set. +# Host pkg-config -I paths (curl/nghttp2/...) must NOT leak into the WASI +# sysroot, so the WASM builds use their own minimal include set. WASM_INCLUDE_PATHS = -I./include -I./lib -I./lib/cjson -I./lib/boringssl/include -I./lib/libttak/include -I./lib/sqlite3 -WASM_CFLAGS = -std=c17 -O2 -Wall $(WASM_INCLUDE_PATHS) $(COMMON_DEFINES) - -$(WASM_BUILD_DIR)/%.o: %.c - @mkdir -p $(dir $@) - $(EMCC) $(WASM_CFLAGS) -c -o $@ $< - -libcwist_wasm.a: $(WASM_OBJS) - $(EMAR) rcs $@ $(WASM_OBJS) - -wasm: libcwist_wasm.a - -# Manual smoke test (requires Emscripten + node; intentionally not part of -# `make test` since CI has no Emscripten toolchain). NODE is overridable so -# CI can point at a specific node binary. -wasm-smoke: libcwist_wasm.a - $(EMCC) $(WASM_CFLAGS) -o wasm_smoke.js tests/wasm_smoke.c libcwist_wasm.a - $(NODE) wasm_smoke.js - -# The struct layout contract (tests/abi_layout.h) on wasm32. Same sources as -# the native test_abi_layout; requires Emscripten + node like wasm-smoke. -wasm-abi-layout: libcwist_wasm.a - $(EMCC) $(WASM_CFLAGS) -o wasm_abi_layout.js tests/test_abi_layout.c tests/abi_layout_legacy.c - $(NODE) wasm_abi_layout.js - -# Integration test for the cwist-wasm JS wrapper (wasm/npm): builds a -# consumer-style module through include/cwist/wasm/wasm_entry.h, then drives -# it from node via the wrapper with no Emscripten glue on the JS side. -# Requires Emscripten + node, same as wasm-smoke. -# _main is in EXPORTED_FUNCTIONS on purpose: without it the linker dead-code -# eliminates main() (nothing else references it), and the app would never be -# created - dispatch would return NULL on every call. -wasm-wrapper-test: libcwist_wasm.a - $(EMCC) $(WASM_CFLAGS) -o wrapper_test.js tests/wasm_wrapper_test.c libcwist_wasm.a \ - -sEXPORTED_FUNCTIONS=_main,_cwist_wasm_dispatch,_cwist_wasm_dispose,_cwist_wasm_use_session,_malloc,_free \ - -sEXPORTED_RUNTIME_METHODS=HEAPU8,HEAPU32 -sMODULARIZE -sEXPORT_NAME=createCwistModule - $(NODE) tests/wasm_wrapper_test.js - -# Versioned npm-package tarball for the cwist-wasm JS wrapper (issue #93 -# Phase 2a). Packs wasm/npm into dist/cwist-wasm-.tgz; CI installs -# the tarball into a clean directory to prove it stands alone. -wasm-dist: wasm-wrapper-test - mkdir -p dist - npm --prefix wasm/npm pkg set version=$(VERSION) >/dev/null - npm pack ./wasm/npm --pack-destination dist >/dev/null - git checkout -- wasm/npm/package.json 2>/dev/null || true - @echo "dist/$$(ls dist | grep cwist-wasm | tail -1)" - -clean-wasm: - rm -rf $(WASM_BUILD_DIR) libcwist_wasm.a wasm_smoke.js wasm_smoke.wasm \ - wasm_abi_layout.js wasm_abi_layout.wasm \ - wrapper_test.js wrapper_test.wasm dist # --- WASI 0.2 (wasm32-wasip2) smoke ------------------------------------------------ -# Same WASM_SRCS subset as the Emscripten target, built with wasi-sdk and run +# The WASM_SRCS core plus the WASI compat layer, built with wasi-sdk and run # under a WASI host (wasmtime). The preview1 target was retired: 0.2 covers # its use, and three WASM flavors cost more than they earn (issue #203). # WASI_SDK / WASMTIME are shared with the component targets below. @@ -591,7 +534,7 @@ wasip2-smoke: $(WASIP2_ARCHIVE) echo "wasip2-smoke: PASS (socket server served /hello over wasi:sockets)" clean-wasip2: - rm -rf .wasip2-build libcwist_wasip2_*.a wasip2_smoke.wasm + rm -rf .wasip2-build libcwist_wasip2_*.a wasip2_smoke.wasm wasm_abi_layout.wasm # --- Component boundary (experimental, issue #203) -------------------------------- # wit/cwist.wit is the component-model counterpart of wasm_entry.h. Both @@ -675,6 +618,74 @@ $(COMPONENT_BUILD_DIR)/guest.component.wasm: $(COMPONENT_BUILD_DIR)/guest.o \ component-guest: $(COMPONENT_BUILD_DIR)/guest.component.wasm +# --- Service-worker example (example/wasm-service-worker) --------------------- +# The example app as a cwist-guest component. wasm-sw-example transpiles it +# twice: for node (smoke.js) and for the browser with async instantiation, +# because a service worker may not use top-level await, which jco's default +# self-instantiating output needs. esbuild then bundles sw.js with the +# adapter and the preview2-shim browser imports into site/, which is what a +# static host serves. +SW_EXAMPLE_DIR = example/wasm-service-worker +SW_BUILD_DIR = $(SW_EXAMPLE_DIR)/build + +$(SW_BUILD_DIR)/app.o: $(SW_EXAMPLE_DIR)/app.c wit-bindings + @mkdir -p $(SW_BUILD_DIR) + $(WASI_SDK)/bin/clang --target=$(WASIP2_TARGET) $(WASIP2_CFLAGS) \ + -I$(WIT_BINDINGS_DIR)/cwist-guest -c -o $@ $< + +$(SW_BUILD_DIR)/app.component.wasm: $(SW_BUILD_DIR)/app.o $(COMPONENT_BUILD_DIR)/cwist_guest.o \ + libcwist_wasip2_$(WASIP2_TARGET).a + $(WASI_SDK)/bin/clang --target=$(WASIP2_TARGET) $(WASIP2_CFLAGS) \ + -o $(SW_BUILD_DIR)/app.core.wasm \ + $(SW_BUILD_DIR)/app.o $(COMPONENT_BUILD_DIR)/cwist_guest.o \ + $(WIT_BINDINGS_DIR)/cwist-guest/cwist_guest_component_type.o \ + $(WASIP2_ARCHIVE) $(WASIP2_LDLIBS) \ + -Wl,--gc-sections -Wl,--allow-undefined -Wl,-z,stack-size=$(WASIP2_STACK_BYTES) + wasm-tools component embed wit/ --world cwist-guest $(SW_BUILD_DIR)/app.core.wasm -o $@ + +wasm-sw-example: $(SW_BUILD_DIR)/app.component.wasm + npm exec -y --package=@bytecodealliance/jco -- \ + jco transpile $(SW_BUILD_DIR)/app.component.wasm --out-dir $(SW_BUILD_DIR)/node + npm install --prefix $(SW_BUILD_DIR)/node --no-save --no-fund --no-audit \ + --silent @bytecodealliance/preview2-shim + npm exec -y --package=@bytecodealliance/jco -- \ + jco transpile $(SW_BUILD_DIR)/app.component.wasm --out-dir $(SW_BUILD_DIR)/browser \ + --instantiation async --no-nodejs-compat + mkdir -p $(SW_BUILD_DIR)/site + npm exec -y --package=esbuild -- esbuild $(SW_EXAMPLE_DIR)/sw.js \ + --bundle --platform=browser --format=iife --conditions=browser \ + --alias:@bytecodealliance/preview2-shim=./$(SW_BUILD_DIR)/node/node_modules/@bytecodealliance/preview2-shim/dist/browser/index.js \ + --outfile=$(SW_BUILD_DIR)/site/sw.js + cp $(SW_EXAMPLE_DIR)/index.html $(SW_BUILD_DIR)/browser/*.wasm $(SW_BUILD_DIR)/site/ + @echo "wasm-sw-example: serve $(SW_BUILD_DIR)/site/ over http://localhost" + +# Node smoke of the example app: the same component through the same adapter +# the service worker uses (the worker itself needs a browser). +wasm-sw-smoke: wasm-sw-example + SW_JCO_DIR=$(abspath $(SW_BUILD_DIR)/node) $(NODE) $(SW_EXAMPLE_DIR)/smoke.js + +# Browser test of the service-worker example in headless Chromium via Playwright. +wasm-sw-browser-test: wasm-sw-example + $(NODE) tests/browser/sw_example.mjs + +# The struct layout contract (tests/abi_layout.h) on wasm32, where pointers +# are 4 bytes: the native test_abi_layout covers the 64-bit targets. +wasm-abi-layout: + $(WASI_SDK)/bin/clang --target=$(WASIP2_TARGET) $(WASIP2_CFLAGS) \ + -o wasm_abi_layout.wasm tests/test_abi_layout.c tests/abi_layout_legacy.c \ + -lwasi-emulated-getpid + $(WASMTIME) run wasm_abi_layout.wasm + +# Versioned npm tarball of the cwist-wasm adapter (wasm/npm): packs it into +# dist/cwist-wasm-.tgz once the component smoke has driven the same +# adapter; CI installs the tarball into a clean directory. +wasm-dist: component-smoke + mkdir -p dist + npm --prefix wasm/npm pkg set version=$(VERSION) >/dev/null + npm pack ./wasm/npm --pack-destination dist >/dev/null + git checkout -- wasm/npm/package.json 2>/dev/null || true + @echo "dist/$$(ls dist | grep cwist-wasm | tail -1)" + component-smoke: component-guest npm exec -y --package=@bytecodealliance/jco -- \ jco transpile $(COMPONENT_BUILD_DIR)/guest.component.wasm --out-dir $(JCO_GUEST_DIR) @@ -756,7 +767,7 @@ component-stream-smoke-p3: $(MAKE) component-stream-smoke WASIP2_TARGET=wasm32-wasip3 clean-component: - rm -rf $(WIT_BINDINGS_DIR) .component-build .jco-guest .jco-guest-stream + rm -rf $(WIT_BINDINGS_DIR) .component-build .jco-guest .jco-guest-stream $(SW_BUILD_DIR) # Packaging gate for the browser bundle (issue #203, stage 2 remainder): the # transpiled component plus the cwist-wasm adapter must bundle for a browser @@ -1305,7 +1316,7 @@ TEST_TARGETS = test_worker_affinity \ test_css_composer \ test_multipart -.PHONY: all test tsan-test $(TEST_TARGETS) fuzz_seq install uninstall dist clean rebuild examples clean-examples micro-examples examples-check wasm wasm-smoke wasm-abi-layout clean-wasm wasip2-smoke clean-wasip2 wit-check jco-transpile wit-bindings component-guest component-smoke clean-component api-baseline api-check env-check +.PHONY: all test tsan-test $(TEST_TARGETS) fuzz_seq install uninstall dist clean rebuild examples clean-examples micro-examples examples-check wasm-abi-layout wasm-sw-example wasm-sw-smoke wasm-sw-browser-test wasm-dist wasip2-smoke clean-wasip2 wit-check jco-transpile wit-bindings component-guest component-smoke clean-component api-baseline api-check env-check # Regenerate docs/api/v4.0-api-baseline.txt (the recorded v4.0 public API # symbol surface). See CONTRIBUTING.md "Releases and patch releases". diff --git a/README.md b/README.md index de0d271a3..8ed38cb4a 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ how you hand that app to a host: | **WASI 0.2 edge runtime** (Wasmtime) | `cwist_app_listen` on WASI sockets, built for `wasm32-wasip2` | The same app on Wasmtime; the host owns persistence as a KV-style blob | [wasip2-kv](example/wasip2-kv) | The WASM hosts speak serialized HTTP/1.1 across a [WIT interface](wit/cwist.wit), -and the [`cwist-wasm`](wasm/npm) npm package wraps both the Emscripten module -and the jco-transpiled component for JavaScript hosts. They are newer than the +and the [`cwist-wasm`](wasm/npm) npm package wraps the jco-transpiled +component for JavaScript hosts. They are newer than the native server: read each example's README for what is and is not guaranteed yet (for example, the wasip2 demo does not promise crash-durable writes). diff --git a/docs/api/wasm-component.md b/docs/api/wasm-component.md index b39fbb83d..c4673d439 100644 --- a/docs/api/wasm-component.md +++ b/docs/api/wasm-component.md @@ -1,10 +1,9 @@ # CWIST on the WASM component model -**Status:** experimental (issue #203, v3.7 Phase 1; v3.8 defers the -production switch to v4.0). The supported browser path remains the -Emscripten bundle (`docs/api/wasm.md`); this document describes the component -pipeline that is slated to replace it once the WASI 0.3 world and unflagged -JSPI are usable end to end. +**Status:** supported (issue #203). The WASI component pipeline is the +standard WebAssembly path for CWIST in browsers (via jco transpilation), +service workers, Node, and edge runtimes. The legacy Emscripten build +has been retired. ## Why diff --git a/docs/api/wasm.md b/docs/api/wasm.md index 766958631..bd6ed6c16 100644 --- a/docs/api/wasm.md +++ b/docs/api/wasm.md @@ -1,6 +1,11 @@ -# CWIST on WASM (Emscripten) +# CWIST on WASM (Legacy Emscripten) -CWIST can be compiled to WebAssembly via Emscripten and driven entirely from +> [!NOTE] +> The Emscripten browser bundle has been superseded by the WASI component +> pipeline (issue #203). See [wasm-component.md](wasm-component.md) for the +> supported WASM target. + +CWIST historically compiled to WebAssembly via Emscripten and was driven from JS: an HTTP request goes in as bytes, the router + middleware + handlers run inside WASM, and the serialized HTTP response comes back as a zero-copy `Uint8Array` view on the WASM heap. diff --git a/example/wasm-service-worker/app.c b/example/wasm-service-worker/app.c index c8af1069c..68db4dc7b 100644 --- a/example/wasm-service-worker/app.c +++ b/example/wasm-service-worker/app.c @@ -5,8 +5,9 @@ * One C file exercising the whole WASM-relevant stack: routing, zod * validation, template rendering, cwist_db (SQLite :memory:), and signed-cookie * sessions. The host (sw.js in a browser, smoke.js under node) feeds in raw - * HTTP/1.1 request bytes through the CWIST_WASM_DEFINE_ENTRY entry points and - * receives the serialized response; no sockets are involved. + * HTTP/1.1 request bytes through the cwist-guest component exports + * (wit/cwist.wit, defined by CWIST_WASM_COMPONENT_DEFINE_GUEST) and receives + * the serialized response; no sockets are involved. * * Routes: * GET / template-rendered page (session visit counter + items) @@ -16,9 +17,9 @@ * an edge host would persist outside the WASM instance * anything else router's default 404 * - * The session signing secret is pinned by the host via _cwist_wasm_use_session - * (sw.js injects one before the first dispatch; smoke.js does the same through - * the cwist-wasm wrapper) so signed cookies verify across module restarts. + * The session signing secret is pinned by the host through the use-session + * export (sw.js and smoke.js both call handle.useSession() before the first + * dispatch) so signed cookies verify across instance restarts. */ #include @@ -30,10 +31,12 @@ #include #include #include -#include +#include #include #include +#include "cwist_guest.h" /* wit-bindgen output, generated by make wit-bindings */ + static cwist_app *g_app; static cwist_db *g_db; @@ -355,9 +358,9 @@ static int install_item_list(void) { return (rc == 0 && g_row && g_page) ? 0 : -1; } -/* File scope: the macro defines functions; g_app and g_db are populated in - * main() before any dispatch runs. */ -CWIST_WASM_DEFINE_ENTRY(g_app) +/* File scope: the macro defines the component exports; g_app and g_db are + * populated in main(), which the host runs before any dispatch. */ +CWIST_WASM_COMPONENT_DEFINE_GUEST(g_app) int main(void) { g_app = cwist_app_create(); @@ -380,7 +383,7 @@ int main(void) { if (install_item_list() != 0) return 4; cwist_app_get(g_app, "/items/list", items_view_handler); - /* The session secret is pinned by the host (_cwist_wasm_use_session) so - * cookies survive module restarts; see docs/api/wasm.md. */ + /* The session secret is pinned by the host (use-session export) so + * cookies survive instance restarts; see docs/api/wasm-component.md. */ return 0; } diff --git a/example/wasm-service-worker/build.sh b/example/wasm-service-worker/build.sh index 19800edfe..93cf53881 100755 --- a/example/wasm-service-worker/build.sh +++ b/example/wasm-service-worker/build.sh @@ -1,39 +1,12 @@ #!/bin/sh -# Build the CWIST WASM service-worker demo (app.js + app.wasm). +# Build the CWIST WASM service-worker demo: the app as a cwist-guest +# component, its node and browser transpiles, and the servable site in +# build/site/ (index.html, the bundled sw.js and the core wasm modules). # -# Prereq: the CWIST WASM archive, built at the repo root with `make wasm` -# (this script runs that for you if the archive is missing). +# Prereqs: wasi-sdk (WASI_SDK, default ~/toolchains/wasi-sdk-25.0-x86_64-linux), +# wasm-tools, wit-bindgen, node/npm. See docs/api/wasm-component.md. # -# Usage (from example/wasm-service-worker/): -# ../../libcwist_wasm.a already built: ./build.sh -# from scratch: make -C ../.. wasm && ./build.sh +# Usage: ./build.sh (same as make -C ../.. wasm-sw-example) set -eu - -DIR=$(cd "$(dirname "$0")" && pwd) -ROOT=$(cd "$DIR/../.." && pwd) -EMCC=${EMCC:-emcc} - -if [ ! -f "$ROOT/libcwist_wasm.a" ]; then - echo "libcwist_wasm.a not found - building it (make wasm)" - make -C "$ROOT" wasm -fi - -# MODULARIZE makes app.js export a factory (createCwistAppModule) instead of -# populating a bare global Module, so sw.js can inject the session secret -# before any dispatch; sw.js loads the glue with importScripts(). -# -# Exports (see wasm/npm/README.md): the standard CWIST WASM entry points plus -# malloc/free and the HEAP views the dispatch glue reads. _main must be -# exported or the linker dead-code-eliminates main() and the app - routes, -# migrations - is never created. -# Same minimal include set as the Makefile's WASM_INCLUDE_PATHS: host -# pkg-config -I paths must not leak into the emscripten sysroot. -"$EMCC" -std=c17 -O2 \ - -I"$ROOT/include" -I"$ROOT/lib" -I"$ROOT/lib/cjson" -I"$ROOT/lib/boringssl/include" \ - -I"$ROOT/lib/libttak/include" -I"$ROOT/lib/sqlite3" \ - -o "$DIR/app.js" "$DIR/app.c" "$ROOT/libcwist_wasm.a" \ - -sEXPORTED_FUNCTIONS=_main,_cwist_wasm_dispatch,_cwist_wasm_dispose,_cwist_wasm_use_session,_malloc,_free \ - -sEXPORTED_RUNTIME_METHODS=HEAPU8,HEAPU32 \ - -sMODULARIZE -sEXPORT_NAME=createCwistAppModule - -echo "built $DIR/app.js + $DIR/app.wasm" +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +exec make -C "$ROOT" wasm-sw-example "$@" diff --git a/example/wasm-service-worker/smoke.js b/example/wasm-service-worker/smoke.js index a6504e146..83bc50fd7 100644 --- a/example/wasm-service-worker/smoke.js +++ b/example/wasm-service-worker/smoke.js @@ -3,25 +3,43 @@ * Node smoke test for the WASM service-worker example app (issue #93 Phase 4). * * The Service Worker itself only runs in a browser, so this drives the same - * built module (app.js/app.wasm) through the real cwist-wasm JS wrapper - * (wasm/npm, referenced by relative path - no external npm dependency) and - * covers the app's WASM logic: routing, zod validation, template rendering, - * cwist_db, the serialized-db image endpoint, sessions, and the 404 fallthrough. + * component (app.c built as a cwist-guest component, transpiled by jco for + * node) through the real cwist-wasm adapter (wasm/npm, referenced by relative + * path) and covers the app's WASM logic: routing, zod validation, template + * rendering, cwist_db, the serialized-db image endpoint, sessions, and the 404 + * fallthrough. * - * Run: ./build.sh && node smoke.js + * Run: make wasm-sw-smoke (SW_JCO_DIR points at build/node) */ const path = require('path'); +const fs = require('fs'); +const { pathToFileURL } = require('url'); const assert = require('assert'); -const { createCwist } = require(path.join(__dirname, '..', '..', 'wasm', 'npm')); -const createCwistAppModule = require(path.join(__dirname, 'app.js')); +const { createCwistFromComponent } = require(path.join(__dirname, '..', '..', 'wasm', 'npm')); + +const JCO_DIR = path.resolve(process.env.SW_JCO_DIR || path.join(__dirname, 'build', 'node')); +/* jco emits ESM; make node treat the generated dir as a module package. */ +if (!fs.existsSync(path.join(JCO_DIR, 'package.json'))) { + fs.writeFileSync(path.join(JCO_DIR, 'package.json'), JSON.stringify({ type: 'module' })); +} const SECRET = 'cwist-sw-demo-secret-change-me-0123456789abcdef'; const decode = (u8) => new TextDecoder().decode(u8); -(async () => { - const Module = await createCwistAppModule(); - const handle = createCwist(Module); +/* One fresh component instance per call: jco's node output instantiates on + * import, so each distinct module URL is a new instance. */ +async function newInstance(tag) { + const url = pathToFileURL(path.join(JCO_DIR, 'app.component.js')); + url.search = '?instance=' + tag; + const component = await import(url.href); + component.run.run(); /* main(): routes, db schema, components */ + const handle = createCwistFromComponent(component.guest); handle.useSession(SECRET); + return handle; +} + +(async () => { + const handle = await newInstance(1); /* 1. GET / -> 200 HTML, first visit counts as visit #1 and sets a session * cookie. */ @@ -120,9 +138,7 @@ const decode = (u8) => new TextDecoder().decode(u8); /* 8. GET /items on a second "instance" with the same pinned secret still * sees the data (in-memory db is per-instance; sessions are not). */ - const Module2 = await createCwistAppModule(); - const handle2 = createCwist(Module2); - handle2.useSession(SECRET); + const handle2 = await newInstance(2); res = handle2({ method: 'GET', path: '/', headers: { Cookie: cookiePair2 } }); assert.ok(decode(res.body).includes('Session visits: 3'), 'session survives instance swap with pinned secret'); diff --git a/example/wasm-service-worker/sw.js b/example/wasm-service-worker/sw.js index da8221f96..e90cae696 100644 --- a/example/wasm-service-worker/sw.js +++ b/example/wasm-service-worker/sw.js @@ -1,141 +1,74 @@ /* - * CWIST WASM service-worker host (issue #93 Phase 4). + * CWIST WASM service-worker host (issue #93 Phase 4, component pipeline since + * issue #203). * * Intercepts same-origin GET/POST fetches (everything except the static - * assets listed in STATIC_PATHS) and dispatches them into the CWIST app - * compiled to WASM (app.js/app.wasm from build.sh). The request/response - * serialization below mirrors wasm/npm/index.js (the cwist-wasm wrapper); - * it is inlined so the worker stays self-contained - no npm install, no - * network - and loads the Emscripten glue with importScripts(). + * assets listed in STATIC_PATHS) and dispatches them into the CWIST app, + * built as a cwist-guest component (app.c, wit/cwist.wit) by + * `make wasm-sw-example`. This file is the source: esbuild bundles it with the + * cwist-wasm adapter, the jco transpile and the preview2-shim browser imports + * into build/site/sw.js, a classic worker script. + * + * jco transpiles with --instantiation async: a service worker may not use + * top-level await, which jco's default self-instantiating output needs, so the + * component is instantiated here on first use instead. * * Cookies: a Service Worker does not get the page's document cookie jar, so * this worker keeps its own. Set-Cookie from a dispatch response is captured * into the jar (and is NOT forwarded to the page - Set-Cookie is a forbidden * response-header name in constructed SW responses) and the stored Cookie * header is attached to subsequent dispatches. See "The host carries the - * cookie" in docs/api/wasm.md. + * cookie" in docs/api/wasm-component.md. */ -'use strict'; - -importScripts('./app.js'); /* MODULARIZE output: defines createCwistAppModule() */ +import { instantiate } from './build/browser/app.component.js'; +import * as wasiShim from '@bytecodealliance/preview2-shim'; +import { createCwistFromComponent } from '../../wasm/npm/index.js'; /* - * Pinned session signing secret, injected into the module before the first - * dispatch so signed session cookies survive module restarts. In a real - * deployment store this outside the bundle (KV, env var) and rotate it to - * force sign-out; anyone who knows it can forge sessions. Must be long enough - * for cwist_app_use_session() to accept it. + * Pinned session signing secret, injected before the first dispatch so signed + * session cookies survive instance restarts. In a real deployment store this + * outside the bundle (KV, env var) and rotate it to force sign-out; anyone who + * knows it can forge sessions. Must be long enough for + * cwist_app_use_session() to accept it. */ const CWIST_SESSION_SECRET = 'cwist-sw-demo-secret-change-me-0123456789abcdef'; /* Precached at install and served cache-first; all other same-origin * GET/POST requests go to the CWIST app in WASM. */ -const CACHE_NAME = 'cwist-sw-demo-v1'; -const STATIC_PATHS = new Set(['/index.html', '/sw.js', '/app.js', '/app.wasm']); +const CACHE_NAME = 'cwist-sw-demo-v2'; +const CORE_MODULES = ['app.component.core.wasm', 'app.component.core2.wasm', + 'app.component.core3.wasm', 'app.component.core4.wasm']; +const STATIC_PATHS = new Set(['/index.html', '/sw.js', ...CORE_MODULES.map((m) => '/' + m)]); -let modulePromise = null; +let handlePromise = null; const cookieJar = new Map(); /* cookie name -> value (this worker's own jar) */ -function getModule() { - if (!modulePromise) { - modulePromise = createCwistAppModule().then((mod) => { - injectSessionSecret(mod); - return mod; - }); - } - return modulePromise; -} - -/* Pin the session secret: copy the string into the WASM heap and call the - * exported _cwist_wasm_use_session (the C side copies it again). */ -function injectSessionSecret(mod) { - if (typeof mod._cwist_wasm_use_session !== 'function') return; - const bytes = new TextEncoder().encode(CWIST_SESSION_SECRET); - const ptr = mod._malloc(bytes.length + 1); - if (!ptr) throw new Error('malloc failed (session secret)'); - mod.HEAPU8.set(bytes, ptr); - mod.HEAPU8[ptr + bytes.length] = 0; - try { - if (mod._cwist_wasm_use_session(ptr) !== 0) - throw new Error('cwist_wasm_use_session failed'); - } finally { - mod._free(ptr); - } -} - -/* ---- request/response serialization (mirrors wasm/npm/index.js) ---- */ - -function buildRequestBytes(init) { - const encoder = new TextEncoder(); - const method = (init.method || 'GET').toUpperCase(); - const path = init.path || '/'; - let body = init.body; - if (typeof body === 'string') body = encoder.encode(body); - if (body == null) body = new Uint8Array(0); - - let head = method + ' ' + path + ' HTTP/1.1\r\nHost: wasm\r\n'; - let hasLength = false; - for (const [k, v] of Object.entries(init.headers || {})) { - if (k.toLowerCase() === 'content-length') hasLength = true; - head += k + ': ' + v + '\r\n'; - } - if (body.length > 0 && !hasLength) head += 'Content-Length: ' + body.length + '\r\n'; - head += 'Connection: close\r\n\r\n'; - - const headBytes = encoder.encode(head); - const out = new Uint8Array(headBytes.length + body.length); - out.set(headBytes, 0); - out.set(body, headBytes.length); - return out; -} - -function parseResponseBytes(bytes) { - const decoder = new TextDecoder(); - let sep = -1; - for (let i = 0; i + 3 < bytes.length; i++) { - if (bytes[i] === 13 && bytes[i + 1] === 10 && bytes[i + 2] === 13 && - bytes[i + 3] === 10) { - sep = i; - break; +/* The import object jco expects: 'wasi:/' (or with @0.2.0) -> + * the matching preview2-shim namespace (wasiShim.cli.terminalStdout, ...). */ +function wasiImports() { + const imports = {}; + for (const [pkg, ifaces] of Object.entries(wasiShim)) { + for (const [name, impl] of Object.entries(ifaces)) { + const kebab = name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); + imports['wasi:' + pkg + '/' + kebab] = impl; + imports['wasi:' + pkg + '/' + kebab + '@0.2.0'] = impl; } } - if (sep < 0) throw new Error('malformed dispatch response (no header terminator)'); - const lines = decoder.decode(bytes.subarray(0, sep)).split('\r\n'); - const statusMatch = /^HTTP\/\d\.\d (\d{3}) ?(.*)$/.exec(lines[0] || ''); - if (!statusMatch) throw new Error('malformed dispatch status line: ' + lines[0]); - const headers = {}; - for (let i = 1; i < lines.length; i++) { - const idx = lines[i].indexOf(':'); - if (idx > 0) headers[lines[i].slice(0, idx).trim()] = lines[i].slice(idx + 1).trim(); - } - return { - status: parseInt(statusMatch[1], 10), - statusText: statusMatch[2], - headers, - body: bytes.slice(sep + 4), /* copy: the WASM heap is reused per dispatch */ - }; + return imports; } -/* One dispatch: request bytes in, {status, headers, body} out. */ -function dispatch(mod, init) { - const reqBytes = buildRequestBytes(init); - const reqPtr = mod._malloc(reqBytes.length || 1); - if (!reqPtr) throw new Error('malloc failed (request)'); - let resPtr = 0; - let outLenPtr = 0; - try { - mod.HEAPU8.set(reqBytes, reqPtr); - outLenPtr = mod._malloc(8); - if (!outLenPtr) throw new Error('malloc failed (out-len slot)'); - resPtr = mod._cwist_wasm_dispatch(reqPtr, reqBytes.length, outLenPtr); - if (!resPtr) throw new Error('cwist dispatch failed (NULL response)'); - const resLen = mod.HEAPU32[outLenPtr >> 2]; - return parseResponseBytes(mod.HEAPU8.subarray(resPtr, resPtr + resLen)); - } finally { - if (resPtr) mod._cwist_wasm_dispose(resPtr); - if (outLenPtr) mod._free(outLenPtr); - mod._free(reqPtr); +function getHandle() { + if (!handlePromise) { + handlePromise = (async () => { + const root = await instantiate( + (name) => WebAssembly.compileStreaming(fetch('./' + name)), wasiImports()); + root.run.run(); /* main(): routes, db schema, components */ + const handle = createCwistFromComponent(root.guest); + handle.useSession(CWIST_SESSION_SECRET); + return handle; + })(); } + return handlePromise; } /* ---- the worker's own cookie jar ---- */ @@ -165,7 +98,7 @@ function cookieHeader() { /* ---- fetch interception ---- */ async function handleFetch(request) { - const mod = await getModule(); + const handle = await getHandle(); const url = new URL(request.url); const headers = {}; @@ -184,7 +117,7 @@ async function handleFetch(request) { if (request.method !== 'GET' && request.method !== 'HEAD') body = new Uint8Array(await request.arrayBuffer()); - const res = dispatch(mod, { + const res = handle({ method: request.method, path: url.pathname + url.search, headers: headers, @@ -218,10 +151,10 @@ async function handleFetch(request) { self.addEventListener('install', (event) => { event.waitUntil((async () => { const cache = await caches.open(CACHE_NAME); - await cache.addAll(['./index.html', './sw.js', './app.js', './app.wasm']); - /* Instantiation of the WASM module is lazy (first fetch); preloading - * it here removes that latency from the first dispatch. */ - await getModule(); + await cache.addAll(['./index.html', './sw.js', ...CORE_MODULES.map((m) => './' + m)]); + /* Instantiation is lazy (first fetch); doing it here removes that + * latency from the first dispatch. */ + await getHandle(); await self.skipWaiting(); })()); }); diff --git a/include/cwist/wasm/typedarray.h b/include/cwist/wasm/typedarray.h index 64321a6ea..29dc7e5ab 100644 --- a/include/cwist/wasm/typedarray.h +++ b/include/cwist/wasm/typedarray.h @@ -1,6 +1,13 @@ /** * @file typedarray.h - * @brief Emscripten-only zero-copy helpers: expose C arrays and in-memory + * @brief (Deprecated) Legacy Emscripten zero-copy helpers. + * + * @deprecated The Emscripten browser bundle has been superseded by the WASI + * component pipeline (issue #203). For new code, build for wasm32-wasip2 + * using the component pipeline. This header and its functions are retained + * for v4 LTS ABI compatibility. + * + * Emscripten-only zero-copy helpers: expose C arrays and in-memory * dispatch responses to JS as TypedArray views over the WASM heap, instead * of round-tripping through snprintf/JSON. * diff --git a/include/cwist/wasm/wasm_component.h b/include/cwist/wasm/wasm_component.h index 71369ee0b..09cc3d380 100644 --- a/include/cwist/wasm/wasm_component.h +++ b/include/cwist/wasm/wasm_component.h @@ -22,8 +22,10 @@ #if defined(__wasi__) +#include #include #include +#include #include #include @@ -72,6 +74,49 @@ static inline int cwist_wasm_component_dispatch_stream(cwist_app *app, const uin write_fn, write_ctx); } +/** + * Define the two cwist-guest world exports (dispatch, use-session) for + * @p app_ptr, a `cwist_app *` populated in main() before the first dispatch: + * the component counterpart of CWIST_WASM_DEFINE_ENTRY. Expand once at file + * scope in a translation unit that includes the wit-bindgen header + * cwist_guest.h. The response buffer handed to the canonical ABI is a libc + * allocation (cwist_alloc under __wasi__), which the ABI frees itself. + */ +#define CWIST_WASM_COMPONENT_DEFINE_GUEST(app_ptr) \ + bool exports_c4punks_cwist_guest_dispatch( \ + cwist_guest_list_u8_t *request, cwist_guest_list_u8_t *ret, \ + exports_c4punks_cwist_guest_dispatch_error_t *err) { \ + uint8_t *res_buf = NULL; \ + size_t res_len = 0; \ + if (cwist_wasm_component_dispatch((app_ptr), request->ptr, request->len, &res_buf, \ + &res_len) != 0) { \ + err->tag = EXPORTS_C4PUNKS_CWIST_GUEST_DISPATCH_ERROR_INVALID_REQUEST; \ + return false; \ + } \ + ret->ptr = res_buf; \ + ret->len = res_len; \ + return true; \ + } \ + bool exports_c4punks_cwist_guest_use_session( \ + cwist_guest_string_t *maybe_secret, exports_c4punks_cwist_guest_session_error_t *err) { \ + char secret_buf[256]; \ + const char *secret = NULL; \ + if (maybe_secret) { \ + if (maybe_secret->len >= sizeof(secret_buf)) { \ + err->tag = EXPORTS_C4PUNKS_CWIST_GUEST_SESSION_ERROR_REJECTED; \ + return false; \ + } \ + memcpy(secret_buf, maybe_secret->ptr, maybe_secret->len); \ + secret_buf[maybe_secret->len] = '\0'; \ + secret = secret_buf; \ + } \ + if (cwist_app_use_session((app_ptr), secret) != 0) { \ + err->tag = EXPORTS_C4PUNKS_CWIST_GUEST_SESSION_ERROR_REJECTED; \ + return false; \ + } \ + return true; \ + } + #else /* !__wasi__ */ /* Non-WASI builds: this header intentionally provides nothing. */ diff --git a/include/cwist/wasm/wasm_entry.h b/include/cwist/wasm/wasm_entry.h index da4691aa8..481e53f08 100644 --- a/include/cwist/wasm/wasm_entry.h +++ b/include/cwist/wasm/wasm_entry.h @@ -1,6 +1,11 @@ /** * @file wasm_entry.h - * @brief Standard WASM entry points the cwist-wasm JS wrapper calls. + * @brief (Deprecated) Legacy Emscripten WASM entry points. + * + * @deprecated The Emscripten browser bundle has been superseded by the WASI + * component pipeline (issue #203). For new code, build for wasm32-wasip2 + * using CWIST_WASM_COMPONENT_DEFINE_GUEST() from . + * This header and its functions are retained for v4 LTS ABI compatibility. * * A consumer app compiles against libcwist_wasm.a and expands * CWIST_WASM_DEFINE_ENTRY(app_symbol) exactly once, after its routes are diff --git a/tests/browser/sw_example.mjs b/tests/browser/sw_example.mjs new file mode 100644 index 000000000..9ee6f1724 --- /dev/null +++ b/tests/browser/sw_example.mjs @@ -0,0 +1,76 @@ +// Run example/wasm-service-worker in Chromium: the bundled worker must install, +// take control, and serve the page, a JSON POST and the session counter from +// the CWIST component. Run by `make wasm-sw-browser-test`. +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile, readdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { chromium } from 'playwright'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +const siteDir = resolve(here, '../../example/wasm-service-worker/build/site'); +const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.wasm': 'application/wasm' }; + +// Fixed allowlist read up front; a request URL never becomes a filesystem path. +const files = new Map(); +for (const name of await readdir(siteDir)) { + const ext = name.slice(name.lastIndexOf('.')); + if (TYPES[ext]) files.set('/' + name, [await readFile(resolve(siteDir, name)), TYPES[ext]]); +} +assert.ok(files.has('/sw.js') && files.has('/index.html'), 'run make wasm-sw-example first'); +files.set('/', files.get('/index.html')); + +const server = createServer((req, res) => { + const entry = files.get(new URL(req.url, 'http://x').pathname); + if (!entry) { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { 'Content-Type': entry[1] }).end(entry[0]); +}); +await new Promise((ok) => server.listen(0, '127.0.0.1', ok)); +const origin = `http://127.0.0.1:${server.address().port}`; + +const browser = await chromium.launch(); +try { + const context = await browser.newContext(); + const errors = []; + context.on('weberror', (e) => errors.push(String(e.error()))); + context.on('serviceworker', (worker) => worker.on('console', (m) => { + if (m.type() === 'error') errors.push(m.text()); + })); + const page = await context.newPage(); + + // First load registers the worker and reloads once it controls the page. + await page.goto(origin + '/'); + await page.waitForFunction(() => navigator.serviceWorker.controller !== null, null, + { timeout: 30000 }); + await page.waitForSelector('text=Session visits', { timeout: 30000 }); + assert.match(await page.content(), /Session visits: 1<\/strong>/, 'first visit'); + + const created = await page.evaluate(async () => { + const r = await fetch('/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'coffee beans', qty: 3 }), + }); + return { status: r.status, body: await r.json() }; + }); + assert.equal(created.status, 201, 'POST /items through the worker'); + assert.equal(created.body.ok, true); + + const items = await page.evaluate(async () => (await fetch('/items')).json()); + assert.ok(JSON.stringify(items).includes('coffee beans'), 'GET /items sees the insert'); + + // The worker's cookie jar carries the signed session across navigations. + await page.reload(); + await page.waitForSelector('text=Session visits'); + assert.match(await page.content(), /Session visits: 2<\/strong>/, 'session cookie'); + + assert.deepEqual(errors, [], 'no worker or page errors'); + console.log('wasm-sw-browser-test: PASS (worker installed; page, POST, GET and session served in Chromium)'); +} finally { + await browser.close(); + server.close(); +} diff --git a/tests/wasm_component_guest.c b/tests/wasm_component_guest.c index 45ec05969..1de370fa5 100644 --- a/tests/wasm_component_guest.c +++ b/tests/wasm_component_guest.c @@ -49,43 +49,7 @@ static void session_get_handler(cwist_http_request *req, cwist_http_response *re cwist_sstring_assign(res->body, user ? user : "anonymous"); } -/* wit-bindgen guest export: fill *ret on success (return true), *err on - * failure (return false). The canonical ABI copies the returned list out of - * guest memory and frees it via cabi_realloc, so the response buffer must be - * a libc allocation. cwist_alloc is exactly that under __wasi__. */ -bool exports_c4punks_cwist_guest_dispatch(cwist_guest_list_u8_t *request, - cwist_guest_list_u8_t *ret, - exports_c4punks_cwist_guest_dispatch_error_t *err) { - uint8_t *res_buf = NULL; - size_t res_len = 0; - if (cwist_wasm_component_dispatch(g_app, request->ptr, request->len, &res_buf, &res_len) != 0) { - err->tag = EXPORTS_C4PUNKS_CWIST_GUEST_DISPATCH_ERROR_INVALID_REQUEST; - return false; - } - ret->ptr = res_buf; - ret->len = res_len; - return true; -} - -bool exports_c4punks_cwist_guest_use_session(cwist_guest_string_t *maybe_secret, - exports_c4punks_cwist_guest_session_error_t *err) { - char buf[256]; - const char *secret = NULL; - if (maybe_secret) { - if (maybe_secret->len >= sizeof(buf)) { - err->tag = EXPORTS_C4PUNKS_CWIST_GUEST_SESSION_ERROR_REJECTED; - return false; - } - memcpy(buf, maybe_secret->ptr, maybe_secret->len); - buf[maybe_secret->len] = '\0'; - secret = buf; - } - if (cwist_app_use_session(g_app, secret) != 0) { - err->tag = EXPORTS_C4PUNKS_CWIST_GUEST_SESSION_ERROR_REJECTED; - return false; - } - return true; -} +CWIST_WASM_COMPONENT_DEFINE_GUEST(g_app) int main(void) { g_app = cwist_app_create(); diff --git a/tests/wasm_smoke.c b/tests/wasm_smoke.c deleted file mode 100644 index 184c9c64b..000000000 --- a/tests/wasm_smoke.c +++ /dev/null @@ -1,303 +0,0 @@ -/* WASM smoke test for the in-memory dispatcher (not wired into CI — CI has - * no Emscripten). Manual build & run: - * - * make wasm EMCC=/workspace/emsdk/upstream/emscripten/emcc \ - * EMAR=/workspace/emsdk/upstream/emscripten/emar - * /workspace/emsdk/upstream/emscripten/emcc -std=c17 -O2 -I./include -I./lib \ - * -o wasm_smoke.js tests/wasm_smoke.c libcwist_wasm.a - * node wasm_smoke.js - */ -#include -#include -#include -#include -#include -#include "html_views_shared.h" -#include -#include - -static const int32_t g_samples[] = {10, -20, 30, 40}; -CWIST_WASM_EXPOSE_I32(samples, g_samples, 4) -CWIST_WASM_INSTALL_VIEWS() - -/* Streaming boundary (Phase 3): accumulate response chunks on the JS side - * through the same hook shape wasm_entry.h uses (Module.cwistStreamChunk). */ -EM_JS(void, js_stream_reset, (void), { - // clang-format off - globalThis.__streamChunks = []; - Module.cwistStreamChunk = function (ptr, len) { - globalThis.__streamChunks.push(new Uint8Array(HEAPU8.buffer, ptr, len)); - return 0; - }; - // clang-format on -}); - -EM_JS(int, js_stream_total, (void), { - // clang-format off - return globalThis.__streamChunks.reduce(function (n, c) { return n + c.length; }, 0); - // clang-format on -}); - -EM_JS(int, js_chunk_bridge, (const char *data, size_t len), { - // clang-format off - if (typeof Module.cwistStreamChunk === "function") { - return Module.cwistStreamChunk(data, len) ? 1 : 0; - } - return 0; - // clang-format on -}); - -static int js_sink(void *ctx, const char *data, size_t len) { - (void)ctx; - /* Feed each slice through the JS hook so the smoke test exercises the - * same chunk path a Service Worker host would use. */ - return js_chunk_bridge(data, len); -} - -/* JS reads the response through a HEAPU8 view and the exposed struct array - * through cwistView.i32 — any mismatch throws and node exits non-zero. - * clang-format off: this brace block is a JS function body, not C. */ -EM_JS(void, js_verify, (const char *res_ptr, int res_len), { - // clang-format off - const bytes = Module.cwistView.u8(res_ptr, res_len); - const text = new TextDecoder().decode(bytes); - if (!text.startsWith("HTTP/1.1 200 OK\r\n") || !text.endsWith("hello-wasm")) { - throw new Error("bad response via TypedArray: " + JSON.stringify(text)); - } - const samples = Module.cwistView.i32(_samples_ptr(), _samples_len()); - const total = Array.from(samples).reduce((a, b) => a + b, 0); - if (total !== 60 || samples.length !== 4) { - throw new Error("bad i32 view: " + Array.from(samples).join(",")); - } - console.log("wasm_smoke: JS TypedArray views verified (" + res_len + - " response bytes, samples sum " + total + ")"); - // clang-format on -}); - -static void hello_handler(cwist_http_request *req, cwist_http_response *res) { - (void)req; - cwist_sstring_assign(res->body, "hello-wasm"); - cwist_http_header_add(&res->headers, "Content-Type", "text/plain"); -} - -static void big_handler(cwist_http_request *req, cwist_http_response *res) { - (void)req; - char block[4096]; - for (size_t i = 0; i < sizeof(block); i++) block[i] = (char)('a' + (i % 26)); - for (int i = 0; i < 38; i++) { - size_t n = sizeof(block); - if (i == 37) n = (150 * 1024) - 37 * sizeof(block); - cwist_sstring_append_len(res->body, block, n); - } -} - -static void session_handler(cwist_http_request *req, cwist_http_response *res) { - cwist_session_t *sess = cwist_session_start(req->app, req, res); - if (!sess) { - res->status_code = 500; - return; - } - const char *existing = cwist_session_get(sess, "user"); - if (existing && *existing) { - cwist_sstring_assign(res->body, existing); - } else { - const char *want = - req->query_params ? cwist_query_map_get(req->query_params, "user") : NULL; - cwist_session_set(sess, "user", want ? want : "anonymous"); - cwist_sstring_assign(res->body, "stored"); - } - cwist_session_commit(sess, res); -} - -int main(void) { - cwist_app *app = cwist_app_create(); - if (!app) return 1; - cwist_app_get(app, "/hello", hello_handler); - cwist_app_get(app, "/big", big_handler); - cwist_app_get(app, "/sess", session_handler); - /* Sessions in WASM: pin the secret so cookies verify across WASM - * instances (the host may drop and recreate the module at any time). */ - if (cwist_app_use_session(app, "wasm-smoke-pinned-secret-0123456789abcdef") != 0) return 1; - cwist_wasm_install_views(); - static const char req[] = "GET /hello HTTP/1.1\r\nHost: wasm\r\n\r\n"; - size_t res_len = 0; - const char *res_buf = cwist_wasm_dispatch_memory(app, req, sizeof(req) - 1, &res_len); - if (!res_buf) { - fprintf(stderr, "wasm_smoke: dispatch failed\n"); - cwist_app_destroy(app); - return 2; - } - - int ok = strncmp(res_buf, "HTTP/1.1 200 OK\r\n", 17) == 0 && - strstr(res_buf, "Content-Type: text/plain") != NULL && res_len >= 10 && - memcmp(res_buf + res_len - 10, "hello-wasm", 10) == 0; - if (!ok) { - fprintf(stderr, "wasm_smoke: bad response:\n%.*s\n", (int)res_len, res_buf); - cwist_wasm_free((void *)res_buf); - cwist_app_destroy(app); - return 3; - } - - /* Zero-copy handoff: JS reads the same heap bytes as a Uint8Array. */ - js_verify(res_buf, (int)res_len); - cwist_wasm_free((void *)res_buf); - - /* cwist_db round trip: build a database, serialize it to an image blob, - * reopen the image read-only through cwist_db_open_memory() (the - * WASM/edge entry point), and read the row back. */ - cwist_db *db = NULL; - if (cwist_db_open(&db, ":memory:").error.err_i16 != 0 || !db) { - fprintf(stderr, "wasm_smoke: db open failed\n"); - return 4; - } - if (cwist_db_exec(db, "CREATE TABLE kv (k TEXT PRIMARY KEY, v INTEGER)").error.err_i16 != 0 || - cwist_db_exec(db, "INSERT INTO kv (k, v) VALUES ('wasm', 42)").error.err_i16 != 0) { - fprintf(stderr, "wasm_smoke: db setup failed\n"); - cwist_db_close(db); - return 5; - } - void *image = NULL; - size_t image_len = 0; - if (cwist_db_serialize(db, &image, &image_len).error.err_i16 != 0 || !image || image_len == 0) { - fprintf(stderr, "wasm_smoke: db serialize failed\n"); - cwist_db_close(db); - return 6; - } - cwist_db_close(db); - - cwist_db *reopened = NULL; - if (cwist_db_open_memory(&reopened, image, image_len, 1).error.err_i16 != 0 || !reopened) { - fprintf(stderr, "wasm_smoke: db open_memory failed\n"); - cwist_free(image); - return 7; - } - cJSON *rows = NULL; - if (cwist_db_query(reopened, "SELECT v FROM kv WHERE k = 'wasm'", &rows).error.err_i16 != 0 || - !rows || cJSON_GetArraySize(rows) != 1) { - fprintf(stderr, "wasm_smoke: db query via image failed\n"); - if (rows) cJSON_Delete(rows); - cwist_db_close(reopened); - cwist_free(image); - return 8; - } - /* cwist_db_query returns values as strings (sqlite3_exec callback). */ - const cJSON *v = cJSON_GetObjectItem(cJSON_GetArrayItem(rows, 0), "v"); - if (!cJSON_IsString(v) || strcmp(v->valuestring, "42") != 0) { - fprintf(stderr, "wasm_smoke: db value mismatch via image\n"); - cJSON_Delete(rows); - cwist_db_close(reopened); - cwist_free(image); - return 9; - } - cJSON_Delete(rows); - cwist_db_close(reopened); - cwist_free(image); - printf("wasm_smoke: db serialize/open_memory round trip OK (%zu byte image)\n", image_len); - - /* Streaming boundary: /big (150 KiB) through the JS chunk hook must - * total exactly the buffered dispatcher's output length. */ - { - static const char big_req[] = "GET /big HTTP/1.1\r\nHost: wasm\r\n\r\n"; - size_t mem_len = 0; - char *mem = NULL; - if (cwist_app_dispatch_memory(app, big_req, sizeof(big_req) - 1, &mem, &mem_len) != 0 || - !mem) { - fprintf(stderr, "wasm_smoke: buffered /big dispatch failed\n"); - return 10; - } - - js_stream_reset(); - int src = cwist_app_dispatch_stream(app, big_req, sizeof(big_req) - 1, js_sink, NULL); - int js_total = js_stream_total(); - if (src != 0 || (size_t)js_total != mem_len) { - fprintf(stderr, "wasm_smoke: stream mismatch rc=%d js=%d mem=%zu\n", src, js_total, - mem_len); - cwist_free(mem); - return 11; - } - cwist_free(mem); - printf("wasm_smoke: streaming dispatch matches buffered (%d bytes via JS chunks)\n", - js_total); - } - - /* Session persistence model: set on one "instance" (app), read back on - * a fresh app with the same pinned secret - the signed cookie carries - * the state, so instance lifetime is irrelevant. */ - { - static const char set_req[] = "GET /sess?user=alice HTTP/1.1\r\nHost: wasm\r\n\r\n"; - size_t r1_len = 0; - char *r1 = NULL; - if (cwist_app_dispatch_memory(app, set_req, sizeof(set_req) - 1, &r1, &r1_len) != 0 || - !r1) { - fprintf(stderr, "wasm_smoke: session set dispatch failed\n"); - return 12; - } - const char *sc = strstr(r1, "\r\nSet-Cookie: cwist_session="); - if (!sc) { - fprintf(stderr, "wasm_smoke: no session cookie in response\n"); - cwist_free(r1); - return 13; - } - sc += strlen("\r\nSet-Cookie: cwist_session="); - const char *sc_end = sc; - while (*sc_end && *sc_end != '\r' && *sc_end != ';') sc_end++; - char cookie[4096]; - size_t cl = (size_t)(sc_end - sc); - if (cl >= sizeof(cookie)) { - cwist_free(r1); - return 14; - } - memcpy(cookie, sc, cl); - cookie[cl] = '\0'; - cwist_free(r1); - - cwist_app *app2 = cwist_app_create(); - if (!app2) return 15; - cwist_app_get(app2, "/sess", session_handler); - if (cwist_app_use_session(app2, "wasm-smoke-pinned-secret-0123456789abcdef") != 0) - return 16; - char get_req[4400]; - int gl = snprintf(get_req, sizeof(get_req), - "GET /sess HTTP/1.1\r\nHost: wasm\r\nCookie: cwist_session=%s\r\n\r\n", - cookie); - if (gl <= 0 || (size_t)gl >= sizeof(get_req)) return 17; - size_t r2_len = 0; - char *r2 = NULL; - if (cwist_app_dispatch_memory(app2, get_req, (size_t)gl, &r2, &r2_len) != 0 || !r2) { - fprintf(stderr, "wasm_smoke: session get dispatch failed\n"); - cwist_app_destroy(app2); - return 18; - } - if (r2_len < 5 || memcmp(r2 + r2_len - 5, "alice", 5) != 0) { - fprintf(stderr, "wasm_smoke: session did not survive instance swap\n"); - cwist_free(r2); - cwist_app_destroy(app2); - return 19; - } - cwist_free(r2); - cwist_app_destroy(app2); - printf("wasm_smoke: session cookie verifies across WASM instances (pinned secret)\n"); - } - - /* Components, scoped CSS, page/fragment/redirect responses and a hashed - * asset, checked against the same bytes as the native test_html_parity. */ - { - cwist_app *views = cwist_app_create(); - if (!views || html_views_install(views) != 0) { - fprintf(stderr, "wasm_smoke: html views install failed\n"); - return 20; - } - int failed = html_views_check(views); - html_views_teardown(); - cwist_app_destroy(views); - if (failed) { - fprintf(stderr, "wasm_smoke: html view check %d failed\n", failed); - return 21; - } - printf("wasm_smoke: HTML views render the same bytes as the native build\n"); - } - - printf("wasm_smoke: OK\n"); - cwist_app_destroy(app); - return 0; -} diff --git a/tests/wasm_wrapper_test.c b/tests/wasm_wrapper_test.c deleted file mode 100644 index a7f85be4d..000000000 --- a/tests/wasm_wrapper_test.c +++ /dev/null @@ -1,67 +0,0 @@ -/* Integration test for the cwist-wasm JS wrapper (wasm/npm/index.js). - * - * Builds a consumer-style app module via include/cwist/wasm/wasm_entry.h, - * then tests/wasm_wrapper_test.js drives it through the wrapper with no - * Emscripten-specific code on the JS side. Run: - * - * make wasm-wrapper-test - * - * clang-format off regions: EM_JS bodies are JS, not C (see typedarray.h). */ -#include -#include -#include -#include -#include - -static cwist_app *g_app; - -CWIST_WASM_INSTALL_VIEWS() - -static void hello_handler(cwist_http_request *req, cwist_http_response *res) { - (void)req; - cwist_sstring_assign(res->body, "hello-from-wrapper-test"); - cwist_http_header_add(&res->headers, "Content-Type", "text/plain"); - cwist_http_header_add(&res->headers, "X-Wrapper-Test", "yes"); -} - -static void echo_handler(cwist_http_request *req, cwist_http_response *res) { - /* Echo the request body back so the JS side can verify body plumbing. */ - if (req->body && req->body->data) { - cwist_sstring_assign(res->body, req->body->data); - } - cwist_http_header_add(&res->headers, "Content-Type", "application/octet-stream"); -} - -static void session_set_handler(cwist_http_request *req, cwist_http_response *res) { - /* Write a session value; commit() emits the signed Set-Cookie. */ - cwist_session_t *s = cwist_session_start(g_app, req, res); - if (!s) { - res->status_code = CWIST_HTTP_INTERNAL_ERROR; - return; - } - cwist_session_set(s, "user", "alice"); - cwist_session_commit(s, res); - cwist_sstring_assign(res->body, "session-set"); -} - -static void session_get_handler(cwist_http_request *req, cwist_http_response *res) { - /* Read the session back from the request cookie. */ - cwist_session_t *s = cwist_session_start(g_app, req, res); - const char *user = s ? cwist_session_get(s, "user") : NULL; - cwist_sstring_assign(res->body, user ? user : "anonymous"); -} - -/* File scope: the macro defines functions, which C does not allow inside - * another function body. g_app is populated in main() before any dispatch. */ -CWIST_WASM_DEFINE_ENTRY(g_app) - -int main(void) { - g_app = cwist_app_create(); - if (!g_app) return 1; - cwist_app_get(g_app, "/hello", hello_handler); - cwist_app_post(g_app, "/echo", echo_handler); - cwist_app_get(g_app, "/session/set", session_set_handler); - cwist_app_get(g_app, "/session/get", session_get_handler); - cwist_wasm_install_views(); - return 0; -} diff --git a/tests/wasm_wrapper_test.js b/tests/wasm_wrapper_test.js deleted file mode 100644 index 1def48a0b..000000000 --- a/tests/wasm_wrapper_test.js +++ /dev/null @@ -1,72 +0,0 @@ -/* Drives tests/wasm_wrapper_test.c (compiled to wrapper_test.js) through the - * cwist-wasm wrapper and asserts the fetch-shaped API end to end. Run by - * `make wasm-wrapper-test` after the module is built (MODULARIZE output: - * the glue exports createCwistModule() instead of populating a global). */ -'use strict'; - -const path = require('path'); -const { createCwist } = require(path.join(__dirname, '..', 'wasm', 'npm')); -const createCwistModule = require(path.join(process.cwd(), 'wrapper_test.js')); - -(async () => { - const Module = await createCwistModule(); - const handle = createCwist(Module); - - /* 1. GET with no body: status, headers, payload all verified. */ - const res = handle({ method: 'GET', path: '/hello' }); -if (res.status !== 200) throw new Error('status: ' + res.status); -if (res.headers['Content-Type'] !== 'text/plain') { - throw new Error('Content-Type: ' + res.headers['Content-Type']); -} -if (res.headers['X-Wrapper-Test'] !== 'yes') { - throw new Error('X-Wrapper-Test missing: ' + JSON.stringify(res.headers)); -} -const text = new TextDecoder().decode(res.body); -if (text !== 'hello-from-wrapper-test') throw new Error('body: ' + JSON.stringify(text)); - -/* 2. POST with a Uint8Array body: request body plumbing into C handlers. - * Avoid a leading NUL: the echo handler uses cwist_sstring_assign, which - * is strlen-based. */ -const payload = new Uint8Array([0x01, 0x02, 0xfe, 0xff]); -const echoed = handle({ method: 'POST', path: '/echo', body: payload }); -if (echoed.status !== 200) throw new Error('echo status: ' + echoed.status); -if (echoed.body.length !== payload.length || - !payload.every((b, i) => echoed.body[i] === b)) { - throw new Error('echo body mismatch: ' + Array.from(echoed.body)); -} - -/* 3. Response body must be a copy: mutate it, dispatch again, expect the - * original bytes. */ -res.body[0] = 0x58; -const again = handle({ method: 'GET', path: '/hello' }); -if (new TextDecoder().decode(again.body) !== 'hello-from-wrapper-test') { - throw new Error('body view aliasing detected'); -} - -/* 4. Host-injected session secret (issue #93): pin the key from JS, then - * roundtrip a session value through the signed cookie. */ -handle.useSession('wrapper-test-secret-0123456789abcdef'); -const setRes = handle({ method: 'GET', path: '/session/set' }); -if (setRes.status !== 200) throw new Error('session/set status: ' + setRes.status); -const setCookie = setRes.headers['Set-Cookie']; -if (!setCookie || setCookie.indexOf('cwist_session=') !== 0) { - throw new Error('missing signed session cookie: ' + JSON.stringify(setRes.headers)); -} -const cookiePair = setCookie.split(';')[0]; -const getRes = handle({ method: 'GET', path: '/session/get', headers: { Cookie: cookiePair } }); -if (new TextDecoder().decode(getRes.body) !== 'alice') { - throw new Error('session roundtrip: ' + new TextDecoder().decode(getRes.body)); -} - -/* 5. Same value, different secret -> signature must not verify. */ -handle.useSession('wrapper-test-secret-ROTATED-0123456789abcdef'); -const tampered = handle({ method: 'GET', path: '/session/get', headers: { Cookie: cookiePair } }); -if (new TextDecoder().decode(tampered.body) !== 'anonymous') { - throw new Error('rotated secret still verifies old cookie'); -} - -console.log('wasm_wrapper_test: OK (GET, POST echo, body-copy isolation, session secret)'); -})().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/wasm/npm/README.md b/wasm/npm/README.md index c3bd380ff..197739701 100644 --- a/wasm/npm/README.md +++ b/wasm/npm/README.md @@ -1,9 +1,10 @@ # cwist-wasm -First-party JS wrapper over CWIST's WASM `dispatch_memory` path (issue #93 -Phase 2). You compile your CWIST app (routes + handlers) to WebAssembly with -Emscripten; this package hides the Emscripten glue and gives you a -fetch-shaped API. +First-party JS API over a CWIST app compiled to WebAssembly as a +`cwist-guest` WASI component (`wit/cwist.wit` in the CWIST repo) and +transpiled to JS with [jco](https://github.com/bytecodealliance/jco). You +write a normal CWIST app; this package turns its dispatch export into a +fetch-shaped call. ## Install @@ -11,14 +12,15 @@ fetch-shaped API. npm install cwist-wasm ``` -## Build your app module +## Build your app as a component -Write a normal CWIST app, register routes, then define the standard entry -points once: +Register routes in `main()` and define the component exports once: ```c #include -#include +#include + +#include "cwist_guest.h" /* wit-bindgen c wit/ --world cwist-guest */ static cwist_app *g_app; @@ -28,30 +30,29 @@ static void hello(cwist_http_request *req, cwist_http_response *res) { cwist_http_header_add(&res->headers, "Content-Type", "text/plain"); } +CWIST_WASM_COMPONENT_DEFINE_GUEST(g_app) + int main(void) { g_app = cwist_app_create(); cwist_app_get(g_app, "/hello", hello); - CWIST_WASM_DEFINE_ENTRY(g_app); return 0; } ``` -Compile against `libcwist_wasm.a` (built with `make wasm` in the CWIST -repo) with the entry points and runtime methods exported: - -```sh -emcc -O2 -std=c17 -I$cwist/include -I$cwist/lib -o app.js my_app.c \ - $cwist/libcwist_wasm.a \ - -sEXPORTED_FUNCTIONS=_cwist_wasm_dispatch,_cwist_wasm_dispose,_malloc,_free \ - -sEXPORTED_RUNTIME_METHODS=HEAPU8,HEAPU32 -``` +Compile it for `wasm32-wasip2` with wasi-sdk against the CWIST WASI archive, +embed the world with `wasm-tools component embed`, and transpile with +`jco transpile`. `docs/api/wasm-component.md` in the CWIST repo has the full +recipe; `make component-smoke` and `make wasm-sw-example` are working +examples. ## Use from JS ```js -const { createCwist } = require('cwist-wasm'); -// In Node, load app.js first (e.g. require('./app.js')) so `Module` exists. -const handle = createCwist(Module); +const { createCwistFromComponent } = require('cwist-wasm'); +const component = await import('./out/app.component.js'); // jco output + +component.run.run(); // main(): registers the routes +const handle = createCwistFromComponent(component.guest); const res = handle({ method: 'GET', path: '/hello' }); console.log(res.status); // 200 @@ -59,32 +60,33 @@ console.log(res.headers['Content-Type']); // text/plain console.log(new TextDecoder().decode(res.body)); // hello from wasm ``` -Request `body` may be a string or a `Uint8Array`. The response `body` is a -copy, so it stays valid across subsequent calls even if the WASM heap is -reused. +Request `body` may be a string or a `Uint8Array`; the response `body` is a +copy, safe to keep across calls. `handle.useSession(secret)` pins the session +signing secret before the first session-bearing dispatch (`null` lets CWIST +generate a per-instance one). + +Against a 0.2 (`wasm32-wasip2`) guest `handle()` is synchronous. jco lowers +the exports of a 0.3 (`wasm32-wasip3`) guest to async functions, so against +one `handle()` returns a Promise. -## Component pipeline (experimental) +The adapter is environment-neutral JS and bundles for browsers as-is. In a +service worker, transpile with `jco transpile --instantiation async` (a +service worker may not use top-level await) and instantiate on first use; +`example/wasm-service-worker` in the CWIST repo does exactly that. -`const { createCwistFromComponent } = require('cwist-wasm/component')` binds -the same handler shape to a jco-transpiled `cwist-guest` component (issue -#203) instead of an Emscripten module. `component` is the guest interface of -the transpiled output: `{ dispatch(Uint8Array): Uint8Array, useSession(string|null) }`. -The adapter and its dependencies are environment-neutral JS, so it bundles -for browsers as-is (verified with esbuild in the CWIST repo, -`make component-browser-smoke`). One asymmetry: jco lowers exports of 0.3 -(wasip3) components to async functions, so against a 0.3 guest `handle()` -returns a Promise and `await` is required; 0.2 guests stay synchronous. -See `docs/api/wasm-component.md` in the CWIST repo for building the guest. +`require('cwist-wasm/component')` resolves to the same adapter. -### Streaming (0.3 guests) +## Streaming (0.3 guests, JSPI) -A guest built for the `cwist-guest-stream` world (wasm32-wasip3) streams a -response through its `c4punks:cwist/host` import instead of returning it. -`createStreamHost()` returns that import. Wire it in when you instantiate or -bundle the component, and give the same object to the adapter: +A guest built for the `cwist-guest-stream` world (`wasm32-wasip3` only) +streams a response through its `c4punks:cwist/host` import instead of +returning it. The import is async, so running such a guest needs JSPI, which +is still behind a flag: `node --experimental-wasm-jspi`, and the equivalent +browser flag. `createStreamHost()` returns the import; wire it in when you +instantiate or bundle the component, and give the same object to the adapter: ```js -const { createCwistFromComponent, createStreamHost } = require('cwist-wasm/component'); +const { createCwistFromComponent, createStreamHost } = require('cwist-wasm'); const host = createStreamHost(); // { sendChunk }: the c4punks:cwist/host import // ...instantiate or bundle the transpiled guest with `host` as that import... @@ -108,9 +110,16 @@ run one at a time. ## Scope -This wrapper is intentionally thin: routing, middleware, validation, and -rendering all stay in your C handlers. It only serializes requests into -HTTP/1.1 bytes, calls `cwist_app_dispatch_memory()` inside WASM, and parses -the serialized response back into JS objects. Session persistence is not -handled here, and streaming is only available on the component path above; -see `docs/api/wasm.md` in the CWIST repo for the current caveats. +This package is intentionally thin: routing, middleware, validation and +rendering all stay in your C handlers. It serializes requests into HTTP/1.1 +bytes, calls the component's dispatch export (`cwist_app_dispatch_memory()` +inside the guest), and parses the serialized response back into JS objects. + +## Migrating from the Emscripten wrapper + +Up to CWIST v4, the package's main export was `createCwist(Module)` over an +Emscripten module built against `libcwist_wasm.a`. That build is gone (issue +#203): rebuild the app as a component as above, replace +`CWIST_WASM_DEFINE_ENTRY(g_app)` with `CWIST_WASM_COMPONENT_DEFINE_GUEST(g_app)` +at file scope, and call `createCwistFromComponent(component.guest)` instead of +`createCwist(Module)`. The handle has the same shape. diff --git a/wasm/npm/component.d.ts b/wasm/npm/component.d.ts index ca73bb005..eec79565f 100644 --- a/wasm/npm/component.d.ts +++ b/wasm/npm/component.d.ts @@ -62,8 +62,7 @@ export interface CwistComponent { } /** - * Bind the wrapper to a jco-transpiled cwist-guest component. Returns the - * same handle shape as createCwist() from the package root; throws on the + * Bind the wrapper to a jco-transpiled cwist-guest component; throws on the * dispatch-error variant. */ export function createCwistFromComponent(component: CwistComponent): CwistHandle; diff --git a/wasm/npm/component.js b/wasm/npm/component.js index 064aa4319..f0af5c6d3 100644 --- a/wasm/npm/component.js +++ b/wasm/npm/component.js @@ -1,7 +1,6 @@ /* * cwist-wasm component adapter: binds a jco-transpiled cwist-guest component - * (issue #203) to the same fetch-shaped API createCwist() exposes for - * Emscripten modules. The component exports dispatch and useSession from + * (issue #203) to a fetch-shaped API. The component exports dispatch and useSession from * wit/cwist.wit; the canonical ABI hands request/response buffers across as * Uint8Array copies, so no malloc/HEAP plumbing is needed here. * @@ -9,7 +8,7 @@ * const component = await instantiate(imports); * const handle = createCwistFromComponent(component); * const res = handle({ method: 'GET', path: '/hello' }); - * // res.status, res.headers, res.body (Uint8Array) — same as createCwist() + * // res.status, res.headers, res.body (Uint8Array) * // Against a 0.3 (wasip3) component handle() returns a Promise: await it. * * Streaming (cwist-guest-stream world, 0.3 only): the guest pushes chunks @@ -25,7 +24,7 @@ */ 'use strict'; -const { buildRequestBytes, parseResponseBytes } = require('./index.js'); +const { buildRequestBytes, parseResponseBytes } = require('./http.js'); /* Internal: the dispatch runner behind each stream host. Kept off the host * object so the host is exactly the shape of the WIT import. @@ -87,7 +86,8 @@ function createStreamHost() { * Create a CWIST request handler bound to an instantiated cwist-guest * component (jco transpile output of a guest built for wit/cwist.wit). * @param {Object} component instantiated component exports - * @returns {(init: Object) => Object} same handler shape as createCwist() + * @returns {(init: Object) => Object} handler taking a fetch-like init and + * returning {status, statusText, headers, body(Uint8Array)} */ function createCwistFromComponent(component) { if (!component || typeof component !== 'object') { @@ -123,8 +123,7 @@ function createCwistFromComponent(component) { } /* Declarative form: component.cwistSessionSecret applied once at binding - * time, before any dispatch (mirrors createCwist's Module.cwistSessionSecret - * handling). */ + * time, before any dispatch. */ if (typeof component.useSession === 'function' && typeof component.cwistSessionSecret === 'string') { useSession(component.cwistSessionSecret); diff --git a/wasm/npm/http.js b/wasm/npm/http.js new file mode 100644 index 000000000..77e0893f8 --- /dev/null +++ b/wasm/npm/http.js @@ -0,0 +1,75 @@ +/* + * cwist-wasm HTTP framing: the serialized HTTP/1.1 request and response that + * cross the cwist-guest dispatch boundary (wit/cwist.wit), built from and + * parsed into the fetch-shaped objects the adapter exposes. + */ +'use strict'; + +const ENCODER = new TextEncoder(); +const DECODER = new TextDecoder(); + +/** + * Build a raw HTTP/1.1 request from a fetch-like init object. + * @param {{method?: string, path?: string, headers?: Object, body?: (string|Uint8Array)}} init + * @returns {Uint8Array} + */ +function buildRequestBytes(init) { + const method = (init.method || 'GET').toUpperCase(); + const path = init.path || '/'; + const headers = init.headers || {}; + let body = init.body; + if (typeof body === 'string') body = ENCODER.encode(body); + if (body == null) body = new Uint8Array(0); + + let head = method + ' ' + path + ' HTTP/1.1\r\nHost: wasm\r\n'; + let hasLength = false; + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === 'content-length') hasLength = true; + head += k + ': ' + v + '\r\n'; + } + if (body.length > 0 && !hasLength) head += 'Content-Length: ' + body.length + '\r\n'; + head += 'Connection: close\r\n\r\n'; + + const headBytes = ENCODER.encode(head); + const out = new Uint8Array(headBytes.length + body.length); + out.set(headBytes, 0); + out.set(body, headBytes.length); + return out; +} + +/** + * Parse a serialized HTTP/1.1 response into parts. + * @param {Uint8Array} bytes + * @returns {{status: number, statusText: string, headers: Object, body: Uint8Array}} + */ +function parseResponseBytes(bytes) { + // Header/body split: find CRLFCRLF. Responses from dispatch_memory always + // carry the full header block before the body. + let sep = -1; + for (let i = 0; i + 3 < bytes.length; i++) { + if (bytes[i] === 13 && bytes[i + 1] === 10 && bytes[i + 2] === 13 && bytes[i + 3] === 10) { + sep = i; + break; + } + } + if (sep < 0) throw new Error('cwist-wasm: malformed response (no header terminator)'); + const headText = DECODER.decode(bytes.subarray(0, sep)); + const lines = headText.split('\r\n'); + const statusMatch = /^HTTP\/\d\.\d (\d{3}) ?(.*)$/.exec(lines[0] || ''); + if (!statusMatch) throw new Error('cwist-wasm: malformed status line: ' + JSON.stringify(lines[0])); + const headers = {}; + for (let i = 1; i < lines.length; i++) { + const idx = lines[i].indexOf(':'); + if (idx > 0) headers[lines[i].slice(0, idx).trim()] = lines[i].slice(idx + 1).trim(); + } + // Copy: the caller may reuse the response buffer for the next dispatch. + const body = bytes.slice(sep + 4); + return { + status: parseInt(statusMatch[1], 10), + statusText: statusMatch[2], + headers, + body, + }; +} + +module.exports = { buildRequestBytes, parseResponseBytes }; diff --git a/wasm/npm/index.d.ts b/wasm/npm/index.d.ts index 3ffd13f67..f0a12bce0 100644 --- a/wasm/npm/index.d.ts +++ b/wasm/npm/index.d.ts @@ -1,52 +1,9 @@ -// Type definitions for the cwist-wasm JS wrapper. +// Type definitions for cwist-wasm: the component adapter (component.d.ts) +// plus the HTTP framing helpers. -export interface CwistRequestInit { - method?: string; - path?: string; - headers?: Record; - body?: string | Uint8Array; -} +import type { CwistRequestInit, CwistResponse } from './component'; -export interface CwistResponse { - status: number; - statusText: string; - headers: Record; - /** Copy of the response body; safe to keep across subsequent calls. */ - body: Uint8Array; -} - -/** Minimal shape of the instantiated Emscripten module the wrapper needs. */ -export interface CwistModule { - HEAPU8: Uint8Array; - HEAPU32: Uint32Array; - _malloc(size: number): number; - _free(ptr: number): void; - _cwist_wasm_dispatch(reqPtr: number, reqLen: number, outLenPtr: number): number; - _cwist_wasm_dispose(ptr: number): void; - /** Optional (issue #93): session secret setter from CWIST_WASM_DEFINE_ENTRY. */ - _cwist_wasm_use_session?(secretPtr: number): number; - /** - * Optional declarative session secret: a string here is applied once at - * binding time, as if handle.useSession() had been called. - */ - cwistSessionSecret?: string; -} - -export interface CwistHandle { - (init?: CwistRequestInit): CwistResponse; - /** - * Pin the session signing secret (host-injected contract; issue #93). - * Pass null to let CWIST generate a per-instance random secret (dev - * mode). Must be called before the first session-bearing dispatch. - */ - useSession(secret: string | null): void; -} - -/** - * Bind the wrapper to an instantiated Emscripten module compiled from a - * consumer app with CWIST_WASM_DEFINE_ENTRY exported. - */ -export function createCwist(mod: CwistModule): CwistHandle; +export * from './component'; /** Low-level helpers, exported for testing. */ export function buildRequestBytes(init: CwistRequestInit): Uint8Array; diff --git a/wasm/npm/index.js b/wasm/npm/index.js index a0aed4514..98fca6fd9 100644 --- a/wasm/npm/index.js +++ b/wasm/npm/index.js @@ -1,171 +1,18 @@ /* - * cwist-wasm: thin fetch-shaped wrapper over CWIST's WASM dispatch path. + * cwist-wasm: fetch-shaped JS API over a CWIST app built as a cwist-guest + * WASI component (wit/cwist.wit) and transpiled with jco. See README.md and + * docs/api/wasm-component.md. * - * The consumer compiles their own app (routes + handlers) against - * libcwist_wasm.a with Emscripten, exporting the standard entry points: + * const { createCwistFromComponent } = require('cwist-wasm'); + * const handle = createCwistFromComponent(component.guest); + * const res = handle({ method: 'GET', path: '/hello' }); * - * - _cwist_wasm_dispatch(req_ptr, req_len, out_len_ptr) -> res_ptr - * - _cwist_wasm_dispose(ptr) - * - malloc/free exported (-sEXPORTED_FUNCTIONS=_malloc,_free,...) - * - HEAPU8 and HEAPU32 in EXPORTED_RUNTIME_METHODS - * - * This module hides all of that: build the request bytes, hand them over, - * get back {status, headers, body} with body as a Uint8Array copy. + * 'cwist-wasm/component' resolves to the same adapter, for code written + * before the component path became the package's main entry. */ 'use strict'; -const ENCODER = new TextEncoder(); -const DECODER = new TextDecoder(); - -function requireFn(mod, names, what) { - for (const n of names) { - if (typeof mod[n] === 'function') return mod[n]; - } - throw new Error( - 'cwist-wasm: module does not expose ' + what + ' (looked for: ' + names.join(', ') + '). ' + - 'Compile the app with the CWIST WASM entry points exported; see wasm/npm/README.md.' - ); -} - -/** - * Build a raw HTTP/1.1 request from a fetch-like init object. - * @param {{method?: string, path?: string, headers?: Object, body?: (string|Uint8Array)}} init - * @returns {Uint8Array} - */ -function buildRequestBytes(init) { - const method = (init.method || 'GET').toUpperCase(); - const path = init.path || '/'; - const headers = init.headers || {}; - let body = init.body; - if (typeof body === 'string') body = ENCODER.encode(body); - if (body == null) body = new Uint8Array(0); - - let head = method + ' ' + path + ' HTTP/1.1\r\nHost: wasm\r\n'; - let hasLength = false; - for (const [k, v] of Object.entries(headers)) { - if (k.toLowerCase() === 'content-length') hasLength = true; - head += k + ': ' + v + '\r\n'; - } - if (body.length > 0 && !hasLength) head += 'Content-Length: ' + body.length + '\r\n'; - head += 'Connection: close\r\n\r\n'; - - const headBytes = ENCODER.encode(head); - const out = new Uint8Array(headBytes.length + body.length); - out.set(headBytes, 0); - out.set(body, headBytes.length); - return out; -} - -/** - * Parse a serialized HTTP/1.1 response into parts. - * @param {Uint8Array} bytes - * @returns {{status: number, statusText: string, headers: Object, body: Uint8Array}} - */ -function parseResponseBytes(bytes) { - // Header/body split: find CRLFCRLF. Responses from dispatch_memory always - // carry the full header block before the body. - let sep = -1; - for (let i = 0; i + 3 < bytes.length; i++) { - if (bytes[i] === 13 && bytes[i + 1] === 10 && bytes[i + 2] === 13 && bytes[i + 3] === 10) { - sep = i; - break; - } - } - if (sep < 0) throw new Error('cwist-wasm: malformed response (no header terminator)'); - const headText = DECODER.decode(bytes.subarray(0, sep)); - const lines = headText.split('\r\n'); - const statusMatch = /^HTTP\/\d\.\d (\d{3}) ?(.*)$/.exec(lines[0] || ''); - if (!statusMatch) throw new Error('cwist-wasm: malformed status line: ' + JSON.stringify(lines[0])); - const headers = {}; - for (let i = 1; i < lines.length; i++) { - const idx = lines[i].indexOf(':'); - if (idx > 0) headers[lines[i].slice(0, idx).trim()] = lines[i].slice(idx + 1).trim(); - } - // Copy: the WASM heap may be reused by the next dispatch. - const body = bytes.slice(sep + 4); - return { - status: parseInt(statusMatch[1], 10), - statusText: statusMatch[2], - headers, - body, - }; -} - -/** - * Create a CWIST request handler bound to an instantiated Emscripten module. - * @param {Object} mod instantiated Emscripten module (Module global of the - * consumer's compiled app). - * @returns {(init: Object) => Object} handler taking a fetch-like init and - * returning {status, statusText, headers, body(Uint8Array)}. - */ -function createCwist(mod) { - if (!mod || typeof mod !== 'object') throw new Error('cwist-wasm: module object required'); - const malloc = requireFn(mod, ['_malloc'], 'malloc'); - const free = requireFn(mod, ['_free'], 'free'); - const dispatch = requireFn(mod, ['_cwist_wasm_dispatch'], 'the CWIST WASM dispatch entry point'); - const cwistFree = requireFn(mod, ['_cwist_wasm_dispose'], 'the CWIST WASM dispose entry point'); - if (typeof mod.HEAPU8 !== 'object' || typeof mod.HEAPU32 !== 'object') { - throw new Error( - 'cwist-wasm: module must export HEAPU8 and HEAPU32 (add them to -sEXPORTED_RUNTIME_METHODS)' - ); - } - - /* Session secret (issue #93): the module only exports the setter when it - * was built with a current-enough CWIST_WASM_DEFINE_ENTRY; treat it as - * optional so older modules keep working. */ - const useSessionFn = - typeof mod._cwist_wasm_use_session === 'function' ? mod._cwist_wasm_use_session : null; - - function useSession(secret) { - if (!useSessionFn) { - throw new Error( - 'cwist-wasm: module does not export _cwist_wasm_use_session; rebuild with an up-to-date wasm_entry.h' - ); - } - const bytes = ENCODER.encode(secret == null ? '' : String(secret)); - const ptr = malloc(bytes.length + 1); - if (!ptr) throw new Error('cwist-wasm: malloc failed (session secret)'); - try { - mod.HEAPU8.set(bytes, ptr); - mod.HEAPU8[ptr + bytes.length] = 0; - /* NULL/empty secret -> CWIST generates a per-instance random secret. */ - const rc = useSessionFn(bytes.length > 0 ? ptr : 0); - if (rc !== 0) throw new Error('cwist-wasm: use_session failed (rc=' + rc + ')'); - } finally { - free(ptr); - } - } - - /* Declarative form: a Module.cwistSessionSecret string is applied once at - * binding time, before any dispatch. */ - if (useSessionFn && typeof mod.cwistSessionSecret === 'string') { - useSession(mod.cwistSessionSecret); - } - - function handle(init) { - const reqBytes = buildRequestBytes(init || {}); - const reqPtr = malloc(reqBytes.length || 1); - if (!reqPtr) throw new Error('cwist-wasm: malloc failed (' + reqBytes.length + ' bytes)'); - let resPtr = 0; - let outLenPtr = 0; - try { - mod.HEAPU8.set(reqBytes, reqPtr); - outLenPtr = malloc(8); - if (!outLenPtr) throw new Error('cwist-wasm: malloc failed (out-len slot)'); - resPtr = dispatch(reqPtr, reqBytes.length, outLenPtr); - if (!resPtr) throw new Error('cwist-wasm: dispatch failed (NULL response)'); - const resLen = mod.HEAPU32[outLenPtr >> 2]; - const view = mod.HEAPU8.subarray(resPtr, resPtr + resLen); - return parseResponseBytes(view); - } finally { - if (resPtr) cwistFree(resPtr); - if (outLenPtr) free(outLenPtr); - free(reqPtr); - } - } - - handle.useSession = useSession; - return handle; -} +const { createCwistFromComponent, createStreamHost } = require('./component.js'); +const { buildRequestBytes, parseResponseBytes } = require('./http.js'); -module.exports = { createCwist, buildRequestBytes, parseResponseBytes }; +module.exports = { createCwistFromComponent, createStreamHost, buildRequestBytes, parseResponseBytes }; diff --git a/wasm/npm/package.json b/wasm/npm/package.json index 68db0dac5..712d962a4 100644 --- a/wasm/npm/package.json +++ b/wasm/npm/package.json @@ -1,7 +1,7 @@ { "name": "cwist-wasm", "version": "3", - "description": "First-party JS wrapper over CWIST's WASM dispatch paths: the Emscripten dispatch_memory module and the jco-transpiled cwist-guest component.", + "description": "Fetch-shaped JS API over a CWIST app built as a cwist-guest WASI component and transpiled with jco.", "license": "MIT", "main": "index.js", "types": "index.d.ts", @@ -25,6 +25,7 @@ "index.d.ts", "component.js", "component.d.ts", + "http.js", "README.md" ], "engines": { From fedfd86685d51c98d27559f26604b7eb7b477024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=A7=84=28Lee=20Yunjin=29?= <168180007+gg582@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:34:06 +0900 Subject: [PATCH 2/4] tools(stm32-spike): Phase 0 Cortex-M7 feasibility harness (#447) (#448) Builds the socket-free CWIST core (the WASIP2 source set minus sqlite) with arm-none-eabi-gcc for Cortex-M7, links a hello firmware driven only through cwist_app_dispatch_memory(), and runs it under qemu-arm with semihosting output. The shim/ headers and fw/port.c stand in for the platform layer Phase 1 has to add; nothing in src/ or include/ changes. (cherry picked from commit 67abb2d45e54493b11dbe89a56c94bccd3b6f80d) --- .gitignore | 1 + tools/stm32-spike/fw/hello.c | 76 ++++++++ tools/stm32-spike/fw/port.c | 233 +++++++++++++++++++++++++ tools/stm32-spike/fw/stm32h743.ld | 30 ++++ tools/stm32-spike/run.sh | 70 ++++++++ tools/stm32-spike/shim/arpa/inet.h | 2 + tools/stm32-spike/shim/dirent.h | 9 + tools/stm32-spike/shim/netinet/in.h | 2 + tools/stm32-spike/shim/netinet/tcp.h | 2 + tools/stm32-spike/shim/poll.h | 13 ++ tools/stm32-spike/shim/pthread.h | 179 +++++++++++++++++++ tools/stm32-spike/shim/spike_prelude.h | 12 ++ tools/stm32-spike/shim/sys/mman.h | 15 ++ tools/stm32-spike/shim/sys/socket.h | 19 ++ tools/stm32-spike/shim/sys/uio.h | 5 + 15 files changed, 668 insertions(+) create mode 100644 tools/stm32-spike/fw/hello.c create mode 100644 tools/stm32-spike/fw/port.c create mode 100644 tools/stm32-spike/fw/stm32h743.ld create mode 100755 tools/stm32-spike/run.sh create mode 100644 tools/stm32-spike/shim/arpa/inet.h create mode 100644 tools/stm32-spike/shim/dirent.h create mode 100644 tools/stm32-spike/shim/netinet/in.h create mode 100644 tools/stm32-spike/shim/netinet/tcp.h create mode 100644 tools/stm32-spike/shim/poll.h create mode 100644 tools/stm32-spike/shim/pthread.h create mode 100644 tools/stm32-spike/shim/spike_prelude.h create mode 100644 tools/stm32-spike/shim/sys/mman.h create mode 100644 tools/stm32-spike/shim/sys/socket.h create mode 100644 tools/stm32-spike/shim/sys/uio.h diff --git a/.gitignore b/.gitignore index b9c23c266..837172e35 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,4 @@ c100k_https_client c100k_server.log c100k_client.log webtransport_echo_server +/.stm32-spike/ diff --git a/tools/stm32-spike/fw/hello.c b/tools/stm32-spike/fw/hello.c new file mode 100644 index 000000000..40338c06c --- /dev/null +++ b/tools/stm32-spike/fw/hello.c @@ -0,0 +1,76 @@ +/* Phase 0 spike firmware: build a CWIST app and drive it through + * cwist_app_dispatch_memory() only. No sockets, no listen loop. */ +#include +#include +#include +#include +#include + +extern size_t spike_heap_high_water(void); +extern void spike_log(const char *s); +extern void spike_stack_paint(void); +extern size_t spike_stack_used(void); + +static void hello_handler(cwist_http_request *req, cwist_http_response *res) { + (void)req; + cwist_http_response_set_body_ptr(res, "hello from cortex-m", 19); + cwist_http_header_add(&res->headers, "Content-Type", "text/plain"); +} + +static void free_body(const void *ptr, size_t len, void *ctx) { + (void)len; + (void)ctx; + free((void *)ptr); +} + +static void json_handler(cwist_http_request *req, cwist_http_response *res) { + (void)req; + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "board", "stm32"); + cJSON_AddNumberToObject(o, "uptime_ms", 1234); + char *s = cJSON_PrintUnformatted(o); + cwist_http_response_set_body_ptr_managed(res, s, strlen(s), free_body, NULL); + cwist_http_header_add(&res->headers, "Content-Type", "application/json"); + cJSON_Delete(o); +} + +static int probe(cwist_app *app, const char *req, const char *want) { + char *res = NULL; + size_t res_len = 0; + if (cwist_app_dispatch_memory(app, req, strlen(req), &res, &res_len) != 0 || !res) { + spike_log("dispatch failed\n"); + return 1; + } + int ok = memmem(res, res_len, want, strlen(want)) != NULL && memchr(res, 0, res_len) == NULL; + if (!ok) { spike_log("--- got:\n"); spike_log(res); spike_log("\n---\n"); } + spike_log(ok ? "PASS " : "FAIL "); + spike_log(req); + free(res); + return !ok; +} + +int main(void) { + spike_stack_paint(); + cwist_app *app = cwist_app_create(); + if (!app) { + spike_log("app_create failed\n"); + return 1; + } + cwist_app_get(app, "/hello", hello_handler); + cwist_app_get(app, "/json", json_handler); + int fail = 0; +#ifndef SPIKE_ITERS +#define SPIKE_ITERS 3 +#endif + for (int i = 0; i < SPIKE_ITERS; i++) { + fail |= probe(app, "GET /hello HTTP/1.1\r\nHost: x\r\n\r\n", "hello from cortex-m"); + fail |= probe(app, "GET /json HTTP/1.1\r\nHost: x\r\n\r\n", "\"board\":\"stm32\""); + fail |= probe(app, "GET /missing HTTP/1.1\r\nHost: x\r\n\r\n", "404"); + } + char buf[96]; + snprintf(buf, sizeof buf, "heap high water: %u bytes, stack used: %u bytes\n", + (unsigned)spike_heap_high_water(), (unsigned)spike_stack_used()); + spike_log(buf); + spike_log(fail ? "SPIKE: FAIL\n" : "SPIKE: PASS\n"); + return fail; +} diff --git a/tools/stm32-spike/fw/port.c b/tools/stm32-spike/fw/port.c new file mode 100644 index 000000000..6ffc1a8fe --- /dev/null +++ b/tools/stm32-spike/fw/port.c @@ -0,0 +1,233 @@ +/* Phase 0 spike port layer: startup, heap accounting, semihosting log, and + * ENOSYS stubs for every POSIX symbol the core still references. */ +#include +#include +#include +#include +#include +#include + +/* ---- startup ---------------------------------------------------------- */ +extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss, _estack; +extern int main(void); +extern void __libc_init_array(void); +void spike_exit(int code); +void spike_tls_init(void); + +void Reset_Handler(void) { +#if defined(__ARM_FP) && !defined(SPIKE_QEMU_USER) + /* enable CP10/CP11 (FPU) before any float code runs */ + *(volatile uint32_t *)0xE000ED88 |= (0xFu << 20); + __asm volatile("dsb; isb"); +#endif +#ifndef SPIKE_QEMU_USER /* qemu-arm loads .data at its VMA already; the LMA copy is not mapped */ + memcpy(&_sdata, &_sidata, (size_t)((char *)&_edata - (char *)&_sdata)); +#endif + memset(&_sbss, 0, (size_t)((char *)&_ebss - (char *)&_sbss)); + spike_tls_init(); + __libc_init_array(); + spike_exit(main()); +} + +static void Default_Handler(void) { + for (;;) { + } +} + +__attribute__((section(".isr_vector"), used)) const void *spike_vectors[16] = { + &_estack, (void *)Reset_Handler, (void *)Default_Handler, (void *)Default_Handler, + (void *)Default_Handler, (void *)Default_Handler, (void *)Default_Handler, +}; + +/* ---- semihosting ------------------------------------------------------ */ +static int semihost(int op, const void *arg) { + register int r0 __asm("r0") = op; + register const void *r1 __asm("r1") = arg; + __asm volatile("bkpt 0xAB" : "+r"(r0) : "r"(r1) : "memory"); + return r0; +} + +void spike_log(const char *s) { semihost(0x04 /* SYS_WRITE0 */, s); } + +void spike_exit(int code) { + /* ADP_Stopped_ApplicationExit with exit code (SYS_EXIT_EXTENDED) */ + uint32_t args[2] = {0x20026, (uint32_t)code}; + semihost(0x20 /* SYS_EXIT_EXTENDED */, args); + for (;;) { + } +} + +/* ---- heap ------------------------------------------------------------- */ +extern char _heap_start, _heap_end; +static char *heap_brk = &_heap_start; +static size_t heap_high; + +void *_sbrk(ptrdiff_t incr) { + char *prev = heap_brk; + if (heap_brk + incr > &_heap_end) { + errno = ENOMEM; + return (void *)-1; + } + heap_brk += incr; + if ((size_t)(heap_brk - &_heap_start) > heap_high) heap_high = (size_t)(heap_brk - &_heap_start); + return prev; +} + +size_t spike_heap_high_water(void) { return heap_high; } + +/* ---- time: a fake monotonic tick (SysTick would back this on hardware) --- */ +static uint64_t fake_ns; +int clock_gettime(clockid_t id, struct timespec *ts) { + (void)id; + fake_ns += 1000; + ts->tv_sec = (time_t)(fake_ns / 1000000000u); + ts->tv_nsec = (long)(fake_ns % 1000000000u); + return 0; +} + +int nanosleep(const struct timespec *req, struct timespec *rem) { + (void)rem; + fake_ns += (uint64_t)req->tv_sec * 1000000000u + (uint64_t)req->tv_nsec; + return 0; +} + +int sched_yield(void) { return 0; } + +time_t timegm(struct tm *tm) { + /* days-from-civil, UTC */ + int y = tm->tm_year + 1900, m = tm->tm_mon + 1; + y -= m <= 2; + int era = (y >= 0 ? y : y - 399) / 400; + unsigned yoe = (unsigned)(y - era * 400); + unsigned doy = (153u * (unsigned)(m + (m > 2 ? -3 : 9)) + 2u) / 5u + (unsigned)tm->tm_mday - 1u; + unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy; + long days = (long)era * 146097L + (long)doe - 719468L; + return (time_t)(days * 86400L + tm->tm_hour * 3600L + tm->tm_min * 60L + tm->tm_sec); +} + +/* ---- ENOSYS stubs: reaching any of these on hardware is a port bug ------- */ +#define ENOSYS_STUB(ret, name, ...) \ + ret name(__VA_ARGS__) { \ + spike_log("ENOSYS: " #name "\n"); \ + errno = ENOSYS; \ + return (ret)-1; \ + } + +struct msghdr; +struct pollfd; +typedef struct spike_dir DIR; +struct dirent; + +ENOSYS_STUB(ssize_t, recv, int a, void *b, size_t c, int d) +ENOSYS_STUB(ssize_t, send, int a, const void *b, size_t c, int d) +ENOSYS_STUB(ssize_t, sendmsg, int a, const struct msghdr *b, int c) +ENOSYS_STUB(int, shutdown, int a, int b) +ENOSYS_STUB(int, poll, struct pollfd *a, unsigned b, int c) +ENOSYS_STUB(int, mprotect, void *a, size_t b, int c) +ENOSYS_STUB(int, munmap, void *a, size_t b) +ENOSYS_STUB(int, closedir, DIR *a) + +void *mmap(void *a, size_t b, int c, int d, int e, long f) { + (void)a; (void)b; (void)c; (void)d; (void)e; (void)f; + spike_log("ENOSYS: mmap\n"); + errno = ENOSYS; + return (void *)-1; +} +DIR *opendir(const char *p) { (void)p; errno = ENOSYS; return NULL; } +struct dirent *readdir(DIR *d) { (void)d; return NULL; } + +/* ---- thread-local storage: one static block, since there is one thread ---- */ +/* ARM EABI variant I: TP points at an 8-byte TCB, the TLS image follows. */ +extern char _stdata, _etdata, _stbss, _etbss; +#define SPIKE_TLS_BYTES 8192 +static uint8_t spike_tls[8 + SPIKE_TLS_BYTES] __attribute__((aligned(8))); + +void spike_tls_init(void) { + size_t td = (size_t)(&_etdata - &_stdata), tb = (size_t)(&_etbss - &_stbss); + if (td + tb > SPIKE_TLS_BYTES) { + spike_log("TLS image too large\n"); + spike_exit(2); + } + memcpy(spike_tls + 8, &_stdata, td); + memset(spike_tls + 8 + td, 0, tb); +} + +__attribute__((naked)) void *__aeabi_read_tp(void) { + /* must preserve every register except r0 */ + __asm volatile("ldr r0, =spike_tls\n bx lr"); +} + +/* ---- 64-bit atomics: Cortex-M has no LDREXD, so mask interrupts ---------- */ +static inline uint32_t irq_save(void) { + uint32_t p; + __asm volatile("mrs %0, primask\n cpsid i" : "=r"(p)::"memory"); + return p; +} +static inline void irq_restore(uint32_t p) { __asm volatile("msr primask, %0" ::"r"(p) : "memory"); } + +uint64_t __atomic_load_8(const volatile void *m, int o) { + (void)o; + uint32_t p = irq_save(); + uint64_t v = *(const volatile uint64_t *)m; + irq_restore(p); + return v; +} +void __atomic_store_8(volatile void *m, uint64_t v, int o) { + (void)o; + uint32_t p = irq_save(); + *(volatile uint64_t *)m = v; + irq_restore(p); +} +uint64_t __atomic_fetch_add_8(volatile void *m, uint64_t v, int o) { + (void)o; + uint32_t p = irq_save(); + uint64_t old = *(volatile uint64_t *)m; + *(volatile uint64_t *)m = old + v; + irq_restore(p); + return old; +} +_Bool __atomic_compare_exchange_8(volatile void *m, void *exp, uint64_t des, int s, int f) { + (void)s; + (void)f; + uint32_t p = irq_save(); + uint64_t cur = *(volatile uint64_t *)m; + _Bool ok = cur == *(uint64_t *)exp; + if (ok) *(volatile uint64_t *)m = des; + else *(uint64_t *)exp = cur; + irq_restore(p); + return ok; +} + +void _init(void) {} +void _fini(void) {} + +/* ---- stack high-water: paint below the current SP, scan after the run ---- */ +#define SPIKE_STACK_PAINT (48u * 1024u) +static uint32_t *paint_lo, *paint_hi; + +__attribute__((noinline)) void spike_stack_paint(void) { + uint32_t *sp; + __asm volatile("mov %0, sp" : "=r"(sp)); + paint_hi = sp - 64; /* leave this frame alone */ + paint_lo = paint_hi - SPIKE_STACK_PAINT / 4; + for (uint32_t *p = paint_lo; p < paint_hi; p++) *p = 0xDEADBEEFu; +} + +size_t spike_stack_used(void) { + uint32_t *p = paint_lo; + while (p < paint_hi && *p == 0xDEADBEEFu) p++; + return (size_t)((char *)paint_hi - (char *)p); +} + +/* ---- wall clock: an RTC would back this; without it time() is -1 and CWIST + * emits a Date header of NUL bytes (see the Phase 0 report). ---------------- */ +struct timeval; +int _gettimeofday(struct timeval *tv, void *tz) { + (void)tz; + struct { long long tv_sec; long tv_usec; } *t = (void *)tv; + if (t) { + t->tv_sec = 1792886400LL + (long long)(fake_ns / 1000000000u); /* 2026-10-25 */ + t->tv_usec = 0; + } + return 0; +} diff --git a/tools/stm32-spike/fw/stm32h743.ld b/tools/stm32-spike/fw/stm32h743.ld new file mode 100644 index 000000000..be483494f --- /dev/null +++ b/tools/stm32-spike/fw/stm32h743.ld @@ -0,0 +1,30 @@ +/* Phase 0 spike: STM32H743-shaped memory map (2 MB flash, 512 KB AXI SRAM). + * Stack lives in DTCM so its size is visible separately from heap/data. */ +ENTRY(Reset_Handler) + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 2048K + DTCM (rw) : ORIGIN = 0x20000000, LENGTH = 128K + AXI (rw) : ORIGIN = 0x24000000, LENGTH = 512K +} + +_estack = ORIGIN(DTCM) + LENGTH(DTCM); + +SECTIONS +{ + .isr_vector : { KEEP(*(.isr_vector)) } > FLASH + .text : { *(.text*) *(.rodata*) KEEP(*(.init)) KEEP(*(.fini)) } > FLASH + .ARM.exidx : { *(.ARM.exidx*) } > FLASH + .preinit_array : { PROVIDE_HIDDEN(__preinit_array_start = .); KEEP(*(.preinit_array*)) PROVIDE_HIDDEN(__preinit_array_end = .); } > FLASH + .init_array : { PROVIDE_HIDDEN(__init_array_start = .); KEEP(*(SORT(.init_array.*))) KEEP(*(.init_array*)) PROVIDE_HIDDEN(__init_array_end = .); } > FLASH + .fini_array : { PROVIDE_HIDDEN(__fini_array_start = .); KEEP(*(.fini_array*)) PROVIDE_HIDDEN(__fini_array_end = .); } > FLASH + + .tdata : { _stdata = .; *(.tdata*) _etdata = .; } > FLASH + .tbss : { _stbss = .; *(.tbss*) _etbss = .; } > FLASH + + _sidata = LOADADDR(.data); + .data : { _sdata = .; *(.data*) _edata = .; } > AXI AT > FLASH + .bss (NOLOAD) : { _sbss = .; *(.bss*) *(COMMON) _ebss = .; } > AXI + .heap (NOLOAD) : { _heap_start = .; . = ORIGIN(AXI) + LENGTH(AXI); _heap_end = .; } > AXI +} diff --git a/tools/stm32-spike/run.sh b/tools/stm32-spike/run.sh new file mode 100755 index 000000000..c8c26b86e --- /dev/null +++ b/tools/stm32-spike/run.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# STM32 Phase 0 spike (issue #447): build the socket-free CWIST core for +# Cortex-M7 with arm-none-eabi-gcc, link a hello firmware that is driven only +# through cwist_app_dispatch_memory(), report its size, and run it under +# qemu-arm (user mode, -cpu cortex-m7) with semihosting output. +# +# Needs: arm-none-eabi-gcc (newlib-nano), lib/cjson, lib/libttak and +# lib/mbedtls checked out (headers only for mbedtls), optionally qemu-arm. +# usage: tools/stm32-spike/run.sh [iterations] +set -u +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +HERE=$ROOT/tools/stm32-spike +OUT=${OUT:-$ROOT/.stm32-spike} +ITERS=${1:-3} +CC=arm-none-eabi-gcc +ARCH=(-mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard) +# -D__wasi__ selects the existing "no fork/signals/rlimits/threads" guards; +# with no in the shim, wasi.h also defines CWIST_WASI_NO_SOCKETS. +CFLAGS=(-std=c17 -Os "${ARCH[@]}" --specs=nano.specs -ffunction-sections -fdata-sections + -fstack-usage -D_GNU_SOURCE -D_XOPEN_SOURCE=700 + -I"$ROOT/include" -I"$ROOT/lib" -I"$ROOT/lib/cjson" + -I"$ROOT/lib/libttak/include" -I"$ROOT/src/security/tls/shim" + -I"$ROOT/lib/mbedtls/include" -I"$ROOT/lib/sqlite3" + -I"$HERE/shim" # after libttak: ttak/compat/pthread.h include_next-s into it + -include "$HERE/shim/spike_prelude.h" -D__wasi__ -DCWIST_TLS_MBEDTLS) +# The WASIP2 source set minus sqlite/db (CWIST_DB=0), the WASI compat layer +# and the test client. +SRCS=( +src/core/sstring/sstring.c src/core/seq/seq.c src/core/seq/seq_auth.c +src/sys/err/error.c src/net/http/http.c src/net/http/async_tls.c src/net/http/mux.c +src/net/http/query.c src/net/http/cookie.c src/net/http/session.c src/sys/app/app.c +src/sys/app/middleware.c src/sys/app/config.c src/sys/app/logger.c src/sys/app/log_async.c +src/sys/app/shutdown.c src/sys/app/big_dumb_reply.c src/core/siphash/siphash.c +src/core/utils/json_builder.c src/core/utils/json_heal.c src/core/utils/zod.c +src/core/template/template.c src/core/html/builder.c src/core/html/css_composer.c +src/core/html/component.c src/net/http/html_response.c src/sys/app/assets.c +src/core/validation/bind.c src/core/mem/alloc.c src/core/mem/arena.c lib/cjson/cJSON.c +src/sys/metrics/metrics.c src/net/http/writer_fast.c lib/libttak/src/net/lattice.c +lib/libttak/src/shared/shared.c lib/libttak/src/timing/deadline.c src/core/log/log.c +lib/libttak/src/net/mols_control.c src/net/http/async.c src/core/mem/gc.c +lib/libttak/src/mem/epoch.c src/sys/sys_info.c lib/libttak/src/mem/mem.c +lib/libttak/src/mem/fastpath.c lib/libttak/src/mem/owner.c lib/libttak/src/mem/abstract.c +) + +rm -rf "$OUT"; mkdir -p "$OUT/obj" "$OUT/log" +fail=0 +for s in "${SRCS[@]}"; do + o="$OUT/obj/${s//\//_}"; o="${o%.c}.o" + if ! (cd "$ROOT" && $CC "${CFLAGS[@]}" -c "$s" -o "$o") >"$OUT/log/${s//\//_}.log" 2>&1; then + echo "FAIL $s (see $OUT/log/${s//\//_}.log)"; fail=$((fail+1)) + fi +done +echo "core: $(( ${#SRCS[@]} - fail ))/${#SRCS[@]} translation units compiled" +[ $fail -eq 0 ] || exit 1 +arm-none-eabi-ar rcs "$OUT/libcwist_cm7.a" "$OUT"/obj/*.o + +# qemu-arm (user mode) loads .data at its VMA and owns the FPU/SP setup. +$CC "${CFLAGS[@]}" -DSPIKE_ITERS="$ITERS" -c "$HERE/fw/hello.c" -o "$OUT/hello.o" || exit 1 +$CC -std=c17 -Os "${ARCH[@]}" -ffunction-sections -fdata-sections -fstack-usage \ + -Wno-builtin-declaration-mismatch -DSPIKE_QEMU_USER -c "$HERE/fw/port.c" -o "$OUT/port.o" || exit 1 +$CC "${ARCH[@]}" --specs=nano.specs --specs=nosys.specs -nostartfiles -T "$HERE/fw/stm32h743.ld" \ + -Wl,--gc-sections -Wl,--no-warn-rwx-segments -Wl,-Map="$OUT/hello.map" -o "$OUT/hello.elf" \ + "$OUT/hello.o" "$OUT/port.o" "$OUT/libcwist_cm7.a" -lm 2>"$OUT/link.log" || { cat "$OUT/link.log"; exit 1; } +arm-none-eabi-size -A "$OUT/hello.elf" | grep -vE "debug|comment|ARM.attr|^$" + +if command -v qemu-arm >/dev/null; then + timeout 120 qemu-arm -cpu cortex-m7 "$OUT/hello.elf" | grep -vE "^Host|^$" + exit "${PIPESTATUS[0]}" +fi +echo "qemu-arm not found: built only" diff --git a/tools/stm32-spike/shim/arpa/inet.h b/tools/stm32-spike/shim/arpa/inet.h new file mode 100644 index 000000000..fd9ba96d9 --- /dev/null +++ b/tools/stm32-spike/shim/arpa/inet.h @@ -0,0 +1,2 @@ +/* spike: empty socket header stub */ +#include diff --git a/tools/stm32-spike/shim/dirent.h b/tools/stm32-spike/shim/dirent.h new file mode 100644 index 000000000..dce5c6bed --- /dev/null +++ b/tools/stm32-spike/shim/dirent.h @@ -0,0 +1,9 @@ +/* spike: newlib's #errors on arm-none-eabi */ +#ifndef SPIKE_DIRENT_H +#define SPIKE_DIRENT_H +typedef struct spike_dir DIR; +struct dirent { char d_name[256]; }; +DIR *opendir(const char *); +struct dirent *readdir(DIR *); +int closedir(DIR *); +#endif diff --git a/tools/stm32-spike/shim/netinet/in.h b/tools/stm32-spike/shim/netinet/in.h new file mode 100644 index 000000000..fd9ba96d9 --- /dev/null +++ b/tools/stm32-spike/shim/netinet/in.h @@ -0,0 +1,2 @@ +/* spike: empty socket header stub */ +#include diff --git a/tools/stm32-spike/shim/netinet/tcp.h b/tools/stm32-spike/shim/netinet/tcp.h new file mode 100644 index 000000000..fd9ba96d9 --- /dev/null +++ b/tools/stm32-spike/shim/netinet/tcp.h @@ -0,0 +1,2 @@ +/* spike: empty socket header stub */ +#include diff --git a/tools/stm32-spike/shim/poll.h b/tools/stm32-spike/shim/poll.h new file mode 100644 index 000000000..ffee1ab7b --- /dev/null +++ b/tools/stm32-spike/shim/poll.h @@ -0,0 +1,13 @@ +/* spike: poll() stub header */ +#ifndef SPIKE_POLL_H +#define SPIKE_POLL_H +struct pollfd { int fd; short events; short revents; }; +typedef unsigned int nfds_t; +#define POLLIN 1 +#define POLLOUT 4 +#define POLLERR 8 +#define POLLHUP 16 +#define POLLPRI 2 +#define POLLNVAL 32 +int poll(struct pollfd *, nfds_t, int); +#endif diff --git a/tools/stm32-spike/shim/pthread.h b/tools/stm32-spike/shim/pthread.h new file mode 100644 index 000000000..6d323fb7c --- /dev/null +++ b/tools/stm32-spike/shim/pthread.h @@ -0,0 +1,179 @@ +/* spike shim: single-threaded pthread stubs (lifted from libttak WASI fallback) */ +#ifndef SPIKE_PTHREAD_H +#define SPIKE_PTHREAD_H +#include +#include +/* newlib sys/types.h already declares pthread types; rename ours */ +#define pthread_attr_t spike_pthread_attr_t +#define pthread_t spike_pthread_t +#define pthread_key_t spike_pthread_key_t +#define pthread_cond_t spike_pthread_cond_t +#define pthread_condattr_t spike_pthread_condattr_t +#define pthread_mutex_t spike_pthread_mutex_t +#define pthread_mutexattr_t spike_pthread_mutexattr_t +#define pthread_once_t spike_pthread_once_t +#define pthread_rwlock_t spike_pthread_rwlock_t +#define pthread_rwlockattr_t spike_pthread_rwlockattr_t +/* ----------------------------------------------------------------------- + * WASI (preview1) fallback: the sysroot ships no . Searching + * host include directories would leak glibc headers, so this branch + * provides self-contained stubs. Synchronisation primitives cannot + * contend on a single-threaded host: mutexes track state only to catch + * misuse, cond vars never block, and pthread_create fails with ENOSYS. + * + * Note: sysroots whose defines pthread types (musl-style + * bits/alltypes.h, as in wasi-libc) cannot use this fallback - the + * typedefs would collide. Such sysroots are expected to provide their + * own , which is consumed via include_next above. + * --------------------------------------------------------------------- */ + +#include /* EBUSY / EDEADLK / ENOSYS */ +#include /* size_t */ + +typedef int pthread_mutex_t; +typedef int pthread_cond_t; +typedef int pthread_t; +typedef int pthread_once_t; +typedef int pthread_attr_t; +typedef int pthread_mutexattr_t; +typedef int pthread_condattr_t; +typedef int pthread_rwlockattr_t; +typedef struct { int exclusive; } pthread_rwlock_t; + +#define PTHREAD_MUTEX_INITIALIZER 0 +#define PTHREAD_ONCE_INIT 0 +#define PTHREAD_RWLOCK_INITIALIZER { 0 } +#define PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 1 /* stub value */ + +static __inline int pthread_mutex_init(pthread_mutex_t *m, + const pthread_mutexattr_t *attr) { + (void)attr; *m = 0; return 0; +} +static __inline int pthread_mutex_lock(pthread_mutex_t *m) { + if (*m) return EDEADLK; + *m = 1; return 0; +} +static __inline int pthread_mutex_trylock(pthread_mutex_t *m) { + if (*m) return EBUSY; + *m = 1; return 0; +} +static __inline int pthread_mutex_unlock(pthread_mutex_t *m) { + *m = 0; return 0; +} +static __inline int pthread_mutex_destroy(pthread_mutex_t *m) { + (void)m; return 0; +} + +static __inline int pthread_attr_init(pthread_attr_t *attr) { + (void)attr; return 0; +} +static __inline int pthread_attr_destroy(pthread_attr_t *attr) { + (void)attr; return 0; +} +static __inline int pthread_attr_setstacksize(pthread_attr_t *attr, + size_t stacksize) { + (void)attr; (void)stacksize; return 0; +} +static __inline int pthread_attr_getstacksize(const pthread_attr_t *attr, + size_t *stacksize) { + (void)attr; if (stacksize) *stacksize = 0; return 0; +} + +static __inline int pthread_cond_init(pthread_cond_t *c, + const pthread_condattr_t *attr) { + (void)c; (void)attr; return 0; +} +static __inline int pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m) { + /* Single-threaded host: never block; the caller re-checks state. */ + (void)c; (void)m; return 0; +} +static __inline int pthread_cond_timedwait(pthread_cond_t *c, + pthread_mutex_t *m, + const struct timespec *abstime) { + (void)c; (void)m; (void)abstime; return 0; +} +static __inline int pthread_cond_signal(pthread_cond_t *c) { + (void)c; return 0; +} +static __inline int pthread_cond_broadcast(pthread_cond_t *c) { + (void)c; return 0; +} +static __inline int pthread_cond_destroy(pthread_cond_t *c) { + (void)c; return 0; +} + +static __inline int pthread_rwlock_init(pthread_rwlock_t *rw, + const pthread_rwlockattr_t *attr) { + (void)attr; rw->exclusive = 0; return 0; +} +static __inline int pthread_rwlock_rdlock(pthread_rwlock_t *rw) { + if (rw->exclusive) return EDEADLK; + return 0; +} +static __inline int pthread_rwlock_wrlock(pthread_rwlock_t *rw) { + if (rw->exclusive) return EDEADLK; + rw->exclusive = 1; return 0; +} +static __inline int pthread_rwlock_unlock(pthread_rwlock_t *rw) { + rw->exclusive = 0; return 0; +} +static __inline int pthread_rwlock_destroy(pthread_rwlock_t *rw) { + (void)rw; return 0; +} + +static __inline int pthread_rwlockattr_init(pthread_rwlockattr_t *a) { + (void)a; return 0; +} +static __inline int pthread_rwlockattr_destroy(pthread_rwlockattr_t *a) { + (void)a; return 0; +} +static __inline int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *a, + int pref) { + (void)a; (void)pref; return 0; +} + +static __inline int pthread_once(pthread_once_t *once_ctrl, + void (*init_routine)(void)) { + /* States mirror the MSVC branch: 0=new, 1=running, 2=done. A second + * call cannot arrive mid-initialisation on a single-threaded host. */ + if (*once_ctrl != 2) { + *once_ctrl = 1; + init_routine(); + *once_ctrl = 2; + } + return 0; +} + +static __inline int pthread_create(pthread_t *thread, + const void *attr, + void *(*start_routine)(void *), + void *arg) { + (void)attr; (void)start_routine; (void)arg; + if (thread) *thread = 0; + return ENOSYS; +} +static __inline int pthread_join(pthread_t thread, void **retval) { + (void)thread; (void)retval; return 0; +} +static __inline pthread_t pthread_self(void) { + return 0; +} + + +/* spike additions: TLS keys are plain slots on a single thread */ +#ifndef PTHREAD_COND_INITIALIZER +#define PTHREAD_COND_INITIALIZER 0 +#endif +typedef int spike_pthread_key_t; +static void *spike_key_slots[64]; +static int spike_key_next; +static __inline int pthread_key_create(pthread_key_t *k, void (*d)(void *)) { (void)d; if (spike_key_next >= 64) return 11; *k = spike_key_next++; return 0; } +static __inline void *pthread_getspecific(pthread_key_t k) { return spike_key_slots[k]; } +static __inline int pthread_setspecific(pthread_key_t k, const void *v) { spike_key_slots[k] = (void *)v; return 0; } +static __inline int pthread_equal(pthread_t a, pthread_t b) { return a == b; } +static __inline int pthread_attr_setdetachstate(pthread_attr_t *a, int s) { (void)a; (void)s; return 0; } +static __inline int pthread_detach(pthread_t t) { (void)t; return 0; } +#ifndef PTHREAD_CREATE_DETACHED +#define PTHREAD_CREATE_DETACHED 1 +#endif +#endif diff --git a/tools/stm32-spike/shim/spike_prelude.h b/tools/stm32-spike/shim/spike_prelude.h new file mode 100644 index 000000000..3f8f75e97 --- /dev/null +++ b/tools/stm32-spike/shim/spike_prelude.h @@ -0,0 +1,12 @@ +/* spike: newlib hides CLOCK_MONOTONIC without _POSIX_MONOTONIC_CLOCK */ +#include +#ifndef CLOCK_MONOTONIC +#define CLOCK_MONOTONIC ((clockid_t)4) +#endif +#ifndef CLOCK_MONOTONIC_RAW +#define CLOCK_MONOTONIC_RAW ((clockid_t)4) +#endif +int clock_gettime(clockid_t, struct timespec *); +int sched_yield(void); +time_t timegm(struct tm *); +int nanosleep(const struct timespec *, struct timespec *); diff --git a/tools/stm32-spike/shim/sys/mman.h b/tools/stm32-spike/shim/sys/mman.h new file mode 100644 index 000000000..0459a2101 --- /dev/null +++ b/tools/stm32-spike/shim/sys/mman.h @@ -0,0 +1,15 @@ +#ifndef SPIKE_SYS_MMAN_H +#define SPIKE_SYS_MMAN_H +#include +#define PROT_READ 1 +#define PROT_WRITE 2 +#define MAP_PRIVATE 2 +#define MAP_ANONYMOUS 0x20 +#define MAP_ANON MAP_ANONYMOUS +#define MAP_FAILED ((void *)-1) +void *mmap(void *, size_t, int, int, int, long); +int munmap(void *, size_t); + +int mprotect(void *, size_t, int); +#define PROT_NONE 0 +#endif diff --git a/tools/stm32-spike/shim/sys/socket.h b/tools/stm32-spike/shim/sys/socket.h new file mode 100644 index 000000000..c5312c444 --- /dev/null +++ b/tools/stm32-spike/shim/sys/socket.h @@ -0,0 +1,19 @@ +/* spike: socket declarations only; every call is a link-time ENOSYS stub */ +#ifndef SPIKE_SYS_SOCKET_H +#define SPIKE_SYS_SOCKET_H +#include +#include +#include +typedef unsigned int socklen_t; +struct sockaddr { unsigned short sa_family; char sa_data[14]; }; +struct msghdr { void *msg_name; socklen_t msg_namelen; struct iovec *msg_iov; int msg_iovlen; void *msg_control; socklen_t msg_controllen; int msg_flags; }; +#define MSG_DONTWAIT 0x40 +#define MSG_NOSIGNAL 0x4000 +#define SHUT_RD 0 +#define SHUT_WR 1 +#define SHUT_RDWR 2 +ssize_t recv(int, void *, size_t, int); +ssize_t send(int, const void *, size_t, int); +ssize_t sendmsg(int, const struct msghdr *, int); +int shutdown(int, int); +#endif diff --git a/tools/stm32-spike/shim/sys/uio.h b/tools/stm32-spike/shim/sys/uio.h new file mode 100644 index 000000000..71ab0efff --- /dev/null +++ b/tools/stm32-spike/shim/sys/uio.h @@ -0,0 +1,5 @@ +#ifndef SPIKE_SYS_UIO_H +#define SPIKE_SYS_UIO_H +#include +struct iovec { void *iov_base; size_t iov_len; }; +#endif From 5421dd5a528b59d4b2c8c1b67216e427fce59f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=A7=84=28Lee=20Yunjin=29?= <168180007+gg582@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:54:14 +0900 Subject: [PATCH 3/4] feat(port): bare-metal Cortex-M build on capability guards (#447 Phase 1) (#449) * feat(port): bare-metal Cortex-M build on capability guards (#447 Phase 1) - include/cwist/sys/platform.h: CWIST_PLATFORM_HOSTED / _NO_PROC / _NO_SOCKETS derived from the target, plus CWIST_BAREMETAL (inferred for ARM EABI toolchains with no OS macro). Every __wasi__/__EMSCRIPTEN__ and CWIST_WASI_NO_SOCKETS capability guard now tests these; WASI-only API sites (wasi-libc getentropy, wasi:sockets quirks) keep their own tests. The rewrite is token-identical on native, CWIST_TINY, wasip2 and Emscripten builds (preprocessed output compared TU by TU). - http.c: the socket worker runtime, send, direct-writer and receive sections compile only with sockets; bare metal keeps parse, route and serialize. Headers newlib does not ship are skipped on bare metal. - include/cwist/sys/port.h: firmware hooks cwist_port_entropy() and cwist_port_monotonic_ns(), plain link-time symbols with no default. - src/sys/port/baremetal.c: single-threaded pthread, clock_gettime, nanosleep, sched_yield, timegm and Cortex-M 64-bit __atomic helpers on top of newlib's own declarations (-D_POSIX_THREADS etc.). - Entropy: siphash seeds come from the TRNG hook on bare metal and abort on failure instead of degrading to clock+rand(); session secrets and seq-auth nonces use the hook too. - Date: no Date header when time() fails (no RTC), per RFC 9110 6.6.1, instead of 29 NUL bytes from the never-filled cache. - gc.c: hash connection handles in 64 bits; a 33-bit shift of a 32-bit uintptr_t (wasm32, Cortex-M) was undefined. - log_async takes the no-thread path on bare metal as on WASM. - make baremetal / baremetal-smoke: libcwist_baremetal.a for Cortex-M7 and a hello firmware run under qemu-arm, with and without an RTC. Replaces the Phase 0 tools/stm32-spike harness. * docs(api): record the bare-metal port hooks in the API baseline cwist_port_entropy() and cwist_port_monotonic_ns() are new additive API in ; no existing entry changes. * test(port): host unit test for the bare-metal port hooks The API test-coverage ratchet needs every public function exercised by a wired unit test; cwist_port_entropy() and cwist_port_monotonic_ns() are firmware hooks that hosted builds never call. - Move clock_gettime/nanosleep/timegm into src/sys/port/baremetal_time.c so the host can build it with those names renamed. - tests/test_baremetal_port.c builds siphash.c and baremetal_time.c as CWIST_BAREMETAL without libcwist.a and checks: the siphash seed comes only from cwist_port_entropy() and the process aborts when the hook fails; CLOCK_MONOTONIC and nanosleep() run on cwist_port_monotonic_ns(); timegm() matches known instants and the host libc across ~80 years. - clock_gettime() drops its NULL check: POSIX does not require one and glibc's nonnull attribute lets the compiler delete it anyway. (cherry picked from commit cd8998477538a97c6742a24c0ea37c0e585221c0) --- .gitignore | 4 +- Makefile | 72 +++++- docs/api/v4.0-api-baseline.txt | 8 +- include/cwist/net/http/http.h | 6 + include/cwist/net/http/writer_fast.h | 5 + include/cwist/sys/app/shutdown.h | 6 +- include/cwist/sys/platform.h | 49 ++++ include/cwist/sys/port.h | 61 +++++ include/cwist/sys/wasi.h | 6 +- src/core/mem/alloc.c | 3 +- src/core/mem/arena.c | 3 +- src/core/mem/gc.c | 13 +- src/core/seq/seq_auth.c | 13 +- src/core/siphash/siphash.c | 16 +- src/net/http/async.c | 3 + src/net/http/http.c | 58 +++-- src/net/http/session.c | 22 +- src/sys/app/app.c | 109 ++++---- src/sys/app/log_async.c | 7 +- src/sys/app/shutdown.c | 9 +- src/sys/port/baremetal.c | 220 +++++++++++++++++ src/sys/port/baremetal_time.c | 73 ++++++ tests/baremetal/board.c | 151 ++++++++++++ tests/baremetal/hello.c | 111 +++++++++ .../fw => tests/baremetal}/stm32h743.ld | 4 +- tests/test_baremetal_port.c | 145 +++++++++++ tools/stm32-spike/fw/hello.c | 76 ------ tools/stm32-spike/fw/port.c | 233 ------------------ tools/stm32-spike/run.sh | 70 ------ tools/stm32-spike/shim/arpa/inet.h | 2 - tools/stm32-spike/shim/dirent.h | 9 - tools/stm32-spike/shim/netinet/in.h | 2 - tools/stm32-spike/shim/netinet/tcp.h | 2 - tools/stm32-spike/shim/poll.h | 13 - tools/stm32-spike/shim/pthread.h | 179 -------------- tools/stm32-spike/shim/spike_prelude.h | 12 - tools/stm32-spike/shim/sys/mman.h | 15 -- tools/stm32-spike/shim/sys/socket.h | 19 -- tools/stm32-spike/shim/sys/uio.h | 5 - 39 files changed, 1074 insertions(+), 740 deletions(-) create mode 100644 include/cwist/sys/platform.h create mode 100644 include/cwist/sys/port.h create mode 100644 src/sys/port/baremetal.c create mode 100644 src/sys/port/baremetal_time.c create mode 100644 tests/baremetal/board.c create mode 100644 tests/baremetal/hello.c rename {tools/stm32-spike/fw => tests/baremetal}/stm32h743.ld (85%) create mode 100644 tests/test_baremetal_port.c delete mode 100644 tools/stm32-spike/fw/hello.c delete mode 100644 tools/stm32-spike/fw/port.c delete mode 100755 tools/stm32-spike/run.sh delete mode 100644 tools/stm32-spike/shim/arpa/inet.h delete mode 100644 tools/stm32-spike/shim/dirent.h delete mode 100644 tools/stm32-spike/shim/netinet/in.h delete mode 100644 tools/stm32-spike/shim/netinet/tcp.h delete mode 100644 tools/stm32-spike/shim/poll.h delete mode 100644 tools/stm32-spike/shim/pthread.h delete mode 100644 tools/stm32-spike/shim/spike_prelude.h delete mode 100644 tools/stm32-spike/shim/sys/mman.h delete mode 100644 tools/stm32-spike/shim/sys/socket.h delete mode 100644 tools/stm32-spike/shim/sys/uio.h diff --git a/.gitignore b/.gitignore index 837172e35..44b16a9f8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ bindings/rust/Cargo.lock # Test binaries test_sstring +test_baremetal_port test_http test_http_client test_siphash @@ -171,4 +172,5 @@ c100k_https_client c100k_server.log c100k_client.log webtransport_echo_server -/.stm32-spike/ +.baremetal-build/ + diff --git a/Makefile b/Makefile index 6cf7fbdd8..fdf78100e 100644 --- a/Makefile +++ b/Makefile @@ -536,6 +536,61 @@ wasip2-smoke: $(WASIP2_ARCHIVE) clean-wasip2: rm -rf .wasip2-build libcwist_wasip2_*.a wasip2_smoke.wasm wasm_abi_layout.wasm +# --- Bare metal (arm-none-eabi / Cortex-M, issue #447) ------------------------- +# The WASI core (WASM_SRCS + WASIP2_EXTRA_SRCS) minus sqlite/db, the test +# client and the WASI compat layer, built for a single-threaded firmware on +# newlib-nano. Requests arrive through cwist_app_dispatch_memory() only. +# The -D_POSIX_* knobs make newlib declare the pthread/timer APIs that +# src/sys/port/baremetal.c implements; _Thread_local/__thread collapse to +# plain statics (one thread: no TLS block, no __aeabi_read_tp). The +# firmware defines the hooks and links with --gc-sections. +BAREMETAL_CROSS ?= arm-none-eabi- +BAREMETAL_ARCH ?= -mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard +BAREMETAL_BUILD_DIR = .baremetal-build +BAREMETAL_ARCHIVE = libcwist_baremetal.a +BAREMETAL_CFLAGS = -std=c17 -Os -Wall $(BAREMETAL_ARCH) --specs=nano.specs \ + -ffunction-sections -fdata-sections -DCWIST_BAREMETAL \ + -D_POSIX_THREADS=200809L -D_POSIX_READER_WRITER_LOCKS=200809L \ + -D_POSIX_TIMERS=200809L -D_POSIX_MONOTONIC_CLOCK=200809L \ + -D_POSIX_PRIORITY_SCHEDULING=200809L -D_Thread_local= -D__thread= \ + $(WASM_INCLUDE_PATHS) $(COMMON_DEFINES) +BAREMETAL_SRCS = $(filter-out lib/sqlite3/sqlite3.c src/core/db/db.c src/sys/app/test_client.c,$(WASM_SRCS)) \ + $(filter-out src/sys/wasi/compat.c src/net/http/writer_fast.c,$(WASIP2_EXTRA_SRCS)) \ + src/sys/port/baremetal.c src/sys/port/baremetal_time.c +BAREMETAL_OBJS = $(BAREMETAL_SRCS:%.c=$(BAREMETAL_BUILD_DIR)/%.o) +# qemu-arm (user mode) runs the Thumb-2 firmware with semihosting output; it +# is not a board model (no NVIC/SysTick), see tests/baremetal/board.c. +QEMU_ARM ?= qemu-arm + +$(BAREMETAL_BUILD_DIR)/%.o: %.c + @mkdir -p $(dir $@) + $(BAREMETAL_CROSS)gcc $(BAREMETAL_CFLAGS) -c -o $@ $< + +$(BAREMETAL_ARCHIVE): $(BAREMETAL_OBJS) + $(BAREMETAL_CROSS)ar rcs $@ $(BAREMETAL_OBJS) + +baremetal: $(BAREMETAL_ARCHIVE) + +# Hello firmware on an STM32H743-shaped memory map: routes, JSON, 404, the +# entropy hook and a heap/stack high-water report, all through in-memory +# dispatch. hello.elf has no RTC (time() fails, so no Date header); +# hello-rtc.elf backs _gettimeofday and expects Date. +BAREMETAL_HELLO = $(BAREMETAL_BUILD_DIR)/hello.elf $(BAREMETAL_BUILD_DIR)/hello-rtc.elf +$(BAREMETAL_BUILD_DIR)/hello-rtc.elf: BOARD_FLAGS = -DBOARD_RTC +$(BAREMETAL_HELLO): $(BAREMETAL_ARCHIVE) tests/baremetal/hello.c tests/baremetal/board.c tests/baremetal/stm32h743.ld + $(BAREMETAL_CROSS)gcc $(BAREMETAL_CFLAGS) $(BOARD_FLAGS) -DBOARD_QEMU_USER -nostartfiles \ + --specs=nosys.specs -T tests/baremetal/stm32h743.ld -Wl,--gc-sections \ + -Wl,--no-warn-rwx-segments -Wl,-Map=$(@:.elf=.map) -o $@ \ + tests/baremetal/hello.c tests/baremetal/board.c $(BAREMETAL_ARCHIVE) -lm + $(BAREMETAL_CROSS)size $@ + +baremetal-smoke: $(BAREMETAL_HELLO) + $(QEMU_ARM) -cpu cortex-m7 $(BAREMETAL_BUILD_DIR)/hello.elf + $(QEMU_ARM) -cpu cortex-m7 $(BAREMETAL_BUILD_DIR)/hello-rtc.elf + +clean-baremetal: + rm -rf $(BAREMETAL_BUILD_DIR) $(BAREMETAL_ARCHIVE) + # --- Component boundary (experimental, issue #203) -------------------------------- # wit/cwist.wit is the component-model counterpart of wasm_entry.h. Both # worlds are validated wherever wit-bindgen is installed; a loud no-op @@ -1208,6 +1263,7 @@ TEST_TARGETS = test_worker_affinity \ test_seq \ test_seq_auth \ test_sha256 \ + test_baremetal_port \ test_error \ test_middleware_jwt \ test_inline_exports \ @@ -1316,7 +1372,7 @@ TEST_TARGETS = test_worker_affinity \ test_css_composer \ test_multipart -.PHONY: all test tsan-test $(TEST_TARGETS) fuzz_seq install uninstall dist clean rebuild examples clean-examples micro-examples examples-check wasm-abi-layout wasm-sw-example wasm-sw-smoke wasm-sw-browser-test wasm-dist wasip2-smoke clean-wasip2 wit-check jco-transpile wit-bindings component-guest component-smoke clean-component api-baseline api-check env-check +.PHONY: all test tsan-test $(TEST_TARGETS) fuzz_seq install uninstall dist clean rebuild examples clean-examples micro-examples examples-check wasm-abi-layout wasm-sw-example wasm-sw-smoke wasm-sw-browser-test wasm-dist wasip2-smoke clean-wasip2 baremetal baremetal-smoke clean-baremetal wit-check jco-transpile wit-bindings component-guest component-smoke clean-component api-baseline api-check env-check # Regenerate docs/api/v4.0-api-baseline.txt (the recorded v4.0 public API # symbol surface). See CONTRIBUTING.md "Releases and patch releases". @@ -1524,6 +1580,20 @@ test_sha256: tests/test_sha256.c include/cwist/core/crypto/sha256.h $(CC) $(CFLAGS) -o $@ tests/test_sha256.c ./$@ +# Bare-metal port layer on the host: siphash.c and baremetal_time.c built as +# CWIST_BAREMETAL (no libcwist.a), with baremetal_time.c's libc names renamed +# so they do not collide with the host's. +BAREMETAL_PORT_TEST_RENAMES = -Dclock_gettime=cwist_bm_clock_gettime \ + -Dnanosleep=cwist_bm_nanosleep -Dtimegm=cwist_bm_timegm +test_baremetal_port: tests/test_baremetal_port.c src/core/siphash/siphash.c src/sys/port/baremetal_time.c include/cwist/sys/port.h + $(CC) -std=c17 -Wall -Wextra -g -I./include $(COMMON_DEFINES) -DCWIST_BAREMETAL -c -o $@.siphash.o src/core/siphash/siphash.c + $(CC) -std=c17 -Wall -Wextra -g -I./include $(COMMON_DEFINES) -DCWIST_BAREMETAL $(BAREMETAL_PORT_TEST_RENAMES) \ + -c -o $@.time.o src/sys/port/baremetal_time.c + $(CC) -std=c17 -Wall -Wextra -g -I./include $(COMMON_DEFINES) -o $@ tests/test_baremetal_port.c \ + $@.siphash.o $@.time.o + rm -f $@.siphash.o $@.time.o + ./$@ + test_error: $(LIB_NAME) tests/test_error.c $(CC) $(CFLAGS) -o test_error tests/test_error.c $(LIB_NAME) $(LIBS) ./test_error diff --git a/docs/api/v4.0-api-baseline.txt b/docs/api/v4.0-api-baseline.txt index 905df318f..530e07fc2 100644 --- a/docs/api/v4.0-api-baseline.txt +++ b/docs/api/v4.0-api-baseline.txt @@ -4,7 +4,7 @@ # (parameter names dropped) and struct/union/enum layouts in member order. # `make api-check` (CI) fails when the headers no longer match this file. -== functions (781) == +== functions (783) == cwist_accept_socket cwist_alloc cwist_alloc_array @@ -609,6 +609,8 @@ cwist_pb_writer_init cwist_pb_writer_reserve cwist_pb_zigzag_decode cwist_pb_zigzag_encode +cwist_port_entropy +cwist_port_monotonic_ns cwist_query_map_clear cwist_query_map_create cwist_query_map_create_in_arena @@ -787,7 +789,7 @@ cwist_zod_parse cwist_zod_print_errors cwist_zod_validate -== function signatures (782) == +== function signatures (784) == cwist_accept_socket: cwist_error_t (int, struct sockaddr *, void (*)(int client_fd, void *ctx), void *) cwist_alloc: void * (size_t) cwist_alloc_array: void * (size_t, size_t) @@ -1393,6 +1395,8 @@ cwist_pb_writer_init: void (cwist_pb_writer *) cwist_pb_writer_reserve: int (cwist_pb_writer *, size_t) cwist_pb_zigzag_decode: int64_t (uint64_t) cwist_pb_zigzag_encode: uint64_t (int64_t) +cwist_port_entropy: int (void *, size_t) +cwist_port_monotonic_ns: uint64_t (void) cwist_query_map_clear: void (cwist_query_map *) cwist_query_map_create: cwist_query_map * (void) cwist_query_map_create_in_arena: cwist_query_map * (void *) diff --git a/include/cwist/net/http/http.h b/include/cwist/net/http/http.h index e0e467daa..310f09b72 100644 --- a/include/cwist/net/http/http.h +++ b/include/cwist/net/http/http.h @@ -14,8 +14,14 @@ #include #include #include +#include +#ifndef CWIST_BAREMETAL #include #include +#else +struct sockaddr; +struct sockaddr_in; +#endif #include #include #include diff --git a/include/cwist/net/http/writer_fast.h b/include/cwist/net/http/writer_fast.h index 2ba8d2572..8b3de4240 100644 --- a/include/cwist/net/http/writer_fast.h +++ b/include/cwist/net/http/writer_fast.h @@ -10,7 +10,12 @@ #include #include #include +#include +#ifndef CWIST_BAREMETAL #include +#else +struct iovec; +#endif #ifdef __cplusplus extern "C" { diff --git a/include/cwist/sys/app/shutdown.h b/include/cwist/sys/app/shutdown.h index 383f98887..27a284014 100644 --- a/include/cwist/sys/app/shutdown.h +++ b/include/cwist/sys/app/shutdown.h @@ -10,9 +10,11 @@ #define __CWIST_SHUTDOWN_H__ #include +#include /* WASI preview1 has no signals (wasi-libc's is a hard #error - * without emulation); signal-based shutdown is compiled out there. */ -#ifndef __wasi__ + * without emulation), nor does bare metal; signal-based shutdown is + * compiled out there. */ +#ifndef CWIST_PLATFORM_NO_PROC #include #endif diff --git a/include/cwist/sys/platform.h b/include/cwist/sys/platform.h new file mode 100644 index 000000000..141962885 --- /dev/null +++ b/include/cwist/sys/platform.h @@ -0,0 +1,49 @@ +/** + * @file platform.h + * @brief Target capability macros shared by all CWIST sources. + * + * Guards test what the target provides instead of which toolchain built it, + * so a new target (bare metal, issue #447) only has to define its row here: + * + * - CWIST_PLATFORM_HOSTED: a native POSIX host (Linux, macOS, BSD) with + * processes, signals, rlimits, threads, a filesystem and sockets. The + * server-only subsystems (listen loop, worker pool, HTTPS/H2/H3, static + * file cache, nuke DB) compile only here. + * - CWIST_PLATFORM_NO_PROC: no fork(2), signals, rlimits, getpid() or + * threads (WASI and bare metal). Emscripten keeps its own guards. + * - CWIST_PLATFORM_NO_SOCKETS: no BSD socket API; requests reach the app + * through cwist_app_dispatch_memory() only (WASI sysroots without + * wasi:sockets, and bare metal). + * - CWIST_BAREMETAL: no OS at all (arm-none-eabi + newlib on Cortex-M). + * Inferred for ARM EABI toolchains that define no OS macro; pass + * -DCWIST_BAREMETAL to force it elsewhere. The firmware supplies the + * hooks declared in . + * + * WASI flavour detection (CWIST_WASI_SOCKETS / CWIST_WASI_NO_SOCKETS) stays + * in ; guards that select a WASI-only API (getentropy via + * wasi-libc, wasi:sockets quirks) keep testing it or __wasi__ directly. + */ + +#ifndef __CWIST_SYS_PLATFORM_H__ +#define __CWIST_SYS_PLATFORM_H__ + +#include + +#if !defined(CWIST_BAREMETAL) && defined(__ARM_EABI__) && !defined(__linux__) && \ + !defined(__unix__) && !defined(__APPLE__) && !defined(_WIN32) +#define CWIST_BAREMETAL 1 +#endif + +#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) && !defined(CWIST_BAREMETAL) +#define CWIST_PLATFORM_HOSTED 1 +#endif + +#if defined(__wasi__) || defined(CWIST_BAREMETAL) +#define CWIST_PLATFORM_NO_PROC 1 +#endif + +#if defined(CWIST_WASI_NO_SOCKETS) || defined(CWIST_BAREMETAL) +#define CWIST_PLATFORM_NO_SOCKETS 1 +#endif + +#endif /* __CWIST_SYS_PLATFORM_H__ */ diff --git a/include/cwist/sys/port.h b/include/cwist/sys/port.h new file mode 100644 index 000000000..93a7c2272 --- /dev/null +++ b/include/cwist/sys/port.h @@ -0,0 +1,61 @@ +/** + * @file port.h + * @brief Hooks a bare-metal firmware supplies to CWIST (issue #447). + * + * A CWIST_BAREMETAL build has no OS to ask for time or randomness, so the + * firmware defines the functions below (typically on top of SysTick/DWT and + * the RNG peripheral). They are plain link-time symbols with no default: + * leaving one out is a link error, never a silent fallback. + * + * Everything else the core needs from the OS is either standard newlib + * (malloc via _sbrk, stdio via _write, time() via _gettimeofday) or provided + * by CWIST itself in src/sys/port/baremetal.c (single-threaded pthread, + * clock_gettime, nanosleep, 64-bit atomics for Cortex-M). + * + * The bare-metal runtime is single-threaded: call into CWIST from one + * context only (the main loop or one RTOS task), never from an ISR. + * + * Wall clock: CWIST reads it through time(). Without an RTC behind + * newlib's _gettimeofday, time() fails and responses carry no Date header, + * as RFC 9110 section 6.6.1 requires for servers without a clock. + */ + +#ifndef __CWIST_SYS_PORT_H__ +#define __CWIST_SYS_PORT_H__ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Fill a buffer with cryptographically secure random bytes. + * + * Seeds the hash-flooding defense (siphash) and generates session secrets + * and sequence-auth nonces. Back it with a hardware TRNG; a PRNG seeded from + * a constant or a timer makes those defenses guessable. + * + * @param buf Destination buffer. + * @param len Number of bytes to write. + * @return 0 when all len bytes were written, non-zero on failure. CWIST + * treats failure as fatal where no safe fallback exists. + */ +int cwist_port_entropy(void *buf, size_t len); + +/** + * @brief Monotonic time since an arbitrary epoch (usually boot). + * + * Backs clock_gettime(CLOCK_MONOTONIC), which drives timeouts, the GC and + * metrics. Must never go backwards; resolution of 1 ms or better. + * + * @return Nanoseconds since the epoch. + */ +uint64_t cwist_port_monotonic_ns(void); + +#ifdef __cplusplus +} +#endif + +#endif /* __CWIST_SYS_PORT_H__ */ diff --git a/include/cwist/sys/wasi.h b/include/cwist/sys/wasi.h index 2bf539f3a..3629b9e64 100644 --- a/include/cwist/sys/wasi.h +++ b/include/cwist/sys/wasi.h @@ -16,8 +16,10 @@ * - CWIST_WASI_NO_SOCKETS: in-memory dispatch only (preview1, Emscripten * is covered separately by its own __EMSCRIPTEN__ guards). * - * Guards that exclude fork/signals/rlimits/threads stay on plain - * `__wasi__` because neither WASI flavour provides those. + * Capability guards go through , which folds these + * into CWIST_PLATFORM_NO_SOCKETS and CWIST_PLATFORM_NO_PROC (the + * fork/signals/rlimits/threads neither WASI flavour provides) so bare-metal + * targets share them. CWIST_WASI_SOCKETS stays for wasi:sockets quirks. */ #ifndef __CWIST_SYS_WASI_H__ diff --git a/src/core/mem/alloc.c b/src/core/mem/alloc.c index 9613b7fb9..b53e2049c 100644 --- a/src/core/mem/alloc.c +++ b/src/core/mem/alloc.c @@ -1,5 +1,6 @@ #include -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#include +#if !defined(CWIST_PLATFORM_HOSTED) /* WASM hosts are single-threaded and lack libttak's mmap/pthread machinery; * allocations go straight to libc. The owner-guard bridge is native-only. */ #include diff --git a/src/core/mem/arena.c b/src/core/mem/arena.c index b8f763000..f92992d69 100644 --- a/src/core/mem/arena.c +++ b/src/core/mem/arena.c @@ -15,7 +15,8 @@ #include -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#include +#if !defined(CWIST_PLATFORM_HOSTED) /* WASM hosts are single-threaded and lack libttak's epoch/GC machinery; * the arena degenerates to a plain malloc-backed bump buffer. */ #include diff --git a/src/core/mem/gc.c b/src/core/mem/gc.c index 63d882fdb..b61d92657 100644 --- a/src/core/mem/gc.c +++ b/src/core/mem/gc.c @@ -5,7 +5,8 @@ #include #include #include -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#include +#if defined(CWIST_PLATFORM_HOSTED) #include #endif #include @@ -419,7 +420,7 @@ static cwist_full_gc_guard_t *g_full_gc_guard = NULL; * regressed the C1M reactor latency gate in CI once already). */ __attribute__((constructor)) static void cwist_full_gc_guard_init(void) { -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) static cwist_full_gc_guard_t s_wasm_guard; atomic_init(&s_wasm_guard.enabled, false); atomic_init(&s_wasm_guard.locked, false); @@ -498,7 +499,7 @@ void cwist_full_gc(bool enable) { * the page yet, so it is still writable and we are its sole writer. */ atomic_store_explicit(&g_full_gc_guard->enabled, enable, memory_order_relaxed); atomic_store_explicit(&g_full_gc_guard->locked, true, memory_order_release); -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) long page_size = sysconf(_SC_PAGESIZE); mprotect(g_full_gc_guard, (size_t)(page_size > 0 ? page_size : 4096), PROT_READ); #endif @@ -821,9 +822,11 @@ static size_t g_conn_count = 0; static _Atomic bool g_conn_hooks_registered = false; static size_t cwist_conn_slot(const void *handle, size_t mask) { - uintptr_t x = (uintptr_t)handle; + /* 64-bit mix even where pointers are 32-bit (wasm32, Cortex-M): a + * 33-bit shift of a 32-bit uintptr_t is undefined. */ + uint64_t x = (uint64_t)(uintptr_t)handle; x ^= x >> 33; - x *= (uintptr_t)0xff51afd7ed558ccdULL; + x *= 0xff51afd7ed558ccdULL; x ^= x >> 33; return (size_t)x & mask; } diff --git a/src/core/seq/seq_auth.c b/src/core/seq/seq_auth.c index c1f2279b9..4b9acd9c7 100644 --- a/src/core/seq/seq_auth.c +++ b/src/core/seq/seq_auth.c @@ -1,7 +1,8 @@ #include #include -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#include +#if !defined(CWIST_PLATFORM_HOSTED) /* WASM links no OpenSSL: HMAC-SHA256 comes from the bundled header-only * implementation and entropy from the host (crypto.getRandomValues under * Emscripten, getentropy/__wasi_random_get under WASI). */ @@ -20,8 +21,11 @@ #if defined(__wasi__) #include /* getentropy */ #endif +#if defined(CWIST_BAREMETAL) +#include +#endif -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) #define SEQ_AUTH_WASM_CRYPTO 1 #define SEQ_AUTH_TAG_CAP CWIST_SHA256_DIGEST_LEN /* Constant-time compare + secure cleanse without OpenSSL. */ @@ -275,7 +279,8 @@ void cwist_seq_auth_context_destroy(cwist_seq_auth_context_t *ctx) { * @brief Fill a buffer with cryptographically secure random bytes. * * Source is crypto.getRandomValues on Emscripten, getentropy under WASI, - * and OpenSSL RAND_bytes elsewhere. + * the firmware TRNG (cwist_port_entropy) on bare metal, and OpenSSL + * RAND_bytes elsewhere. * * @param out Output buffer. * @param len Number of bytes to write; must be non-zero and at most INT_MAX. @@ -288,6 +293,8 @@ bool cwist_seq_auth_random(uint8_t *out, size_t len) { return seq_auth_random_js(out, (int)len) == 1; #elif defined(__wasi__) return getentropy(out, len) == 0; +#elif defined(CWIST_BAREMETAL) + return cwist_port_entropy(out, len) == 0; #else return RAND_bytes(out, (int)len) == 1; #endif diff --git a/src/core/siphash/siphash.c b/src/core/siphash/siphash.c index 78dd3e12b..4943ebcdb 100644 --- a/src/core/siphash/siphash.c +++ b/src/core/siphash/siphash.c @@ -2,7 +2,10 @@ #include #include #include -#include +#include +#ifdef CWIST_BAREMETAL +#include +#endif #include #include #include @@ -124,10 +127,18 @@ static inline uint64_t rotl64(uint64_t v, unsigned int r) { /** * @brief Fill a buffer with entropy from /dev/urandom or a timing-based fallback. + * + * Bare metal has neither /dev/urandom nor a clock worth mixing at boot, so it + * takes the firmware TRNG (cwist_port_entropy) and aborts if that fails: a + * guessable seed would silently disable the hash-flooding defense. + * * @param buf Destination buffer to fill. * @param len Number of bytes to generate. */ static void cwist_entropy_fill(uint8_t *buf, size_t len) { +#ifdef CWIST_BAREMETAL + if (cwist_port_entropy(buf, len) != 0) abort(); +#else size_t filled = 0; int fd = open("/dev/urandom", O_RDONLY); if (fd >= 0) { @@ -143,7 +154,7 @@ static void cwist_entropy_fill(uint8_t *buf, size_t len) { clock_gettime(CLOCK_MONOTONIC, &ts); /* WASI preview1 has no getppid(); the fallback entropy degrades to * pid + clock only, which is acceptable for a last-resort mixer. */ -#if defined(__wasi__) +#if defined(CWIST_PLATFORM_NO_PROC) /* WASI preview1 has no getpid(); the fallback entropy degrades to pure * clock mixing, which is acceptable for a last-resort mixer. */ uint64_t fallbacks[2] = {(uint64_t)ts.tv_nsec, (uint64_t)ts.tv_sec ^ (uint64_t)ts.tv_nsec}; @@ -160,6 +171,7 @@ static void cwist_entropy_fill(uint8_t *buf, size_t len) { idx++; } } +#endif } /** diff --git a/src/net/http/async.c b/src/net/http/async.c index 44cfb2df3..00013f190 100644 --- a/src/net/http/async.c +++ b/src/net/http/async.c @@ -30,7 +30,10 @@ #include #include #include +#include +#ifndef CWIST_BAREMETAL #include +#endif #include enum cwist_async_state { CWIST_ASYNC_ST_PENDING = 0, CWIST_ASYNC_ST_CLAIMED }; diff --git a/src/net/http/http.c b/src/net/http/http.c index dbcdf0861..f0c82e600 100644 --- a/src/net/http/http.c +++ b/src/net/http/http.c @@ -35,26 +35,34 @@ #include #include #include +#include +#ifndef CWIST_BAREMETAL /* newlib ships no poll/socket/inet headers */ #include -#ifndef __wasi__ +#endif +#ifndef CWIST_PLATFORM_NO_PROC #include #endif #include +#ifdef CWIST_BAREMETAL +time_t timegm(struct tm *tm); /* newlib has none: src/sys/port/baremetal.c */ +#endif #include #include -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) #include #endif #include #include #include +#ifndef CWIST_BAREMETAL #include #include #include #include +#endif #include -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC #include #endif #include @@ -222,6 +230,7 @@ long get_optimal_thread_count(void) { return threads_per_worker; } +#ifndef CWIST_PLATFORM_NO_SOCKETS /* socket worker runtime: thread pool, live-connection registry, async connections */ #define HTTP_TASKS_PER_THREAD 32768 typedef struct { @@ -305,7 +314,7 @@ long cwist_http_inflight_count(void) { static long cwist_http_inflight_limit(void) { long base = g_http_thread_count > 0 ? g_http_thread_count : get_optimal_thread_count(); long floor = base * CWIST_HTTP_INFLIGHT_PER_THREAD; -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC struct rlimit rl; if (getrlimit(RLIMIT_NOFILE, &rl) != 0) return floor; long budget = (rl.rlim_cur == RLIM_INFINITY) @@ -388,7 +397,7 @@ typedef struct http_live_conn { struct http_live_conn *prev, *next; } http_live_conn_t; -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC static pthread_mutex_t g_live_lock = PTHREAD_MUTEX_INITIALIZER; static http_live_conn_t *g_live_head; static _Atomic bool g_draining = false; @@ -398,7 +407,7 @@ static pthread_cond_t g_drain_cond = PTHREAD_COND_INITIALIZER; static _Atomic long g_live_count; static __thread http_live_conn_t *t_live_conn; -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC static void http_live_dec(void) { long prev = atomic_fetch_sub_explicit(&g_live_count, 1, memory_order_acq_rel); if (atomic_load_explicit(&g_draining, memory_order_acquire)) { @@ -428,7 +437,7 @@ void cwist_http_live_idle_leave(void) { /** Run @p handler on a dup of @p fd with the connection registered. */ static void http_live_serve(int fd, void (*handler)(int, void *), void *ctx) { -#ifdef __wasi__ +#ifdef CWIST_PLATFORM_NO_PROC handler(fd, ctx); #else int hfd = fd >= 0 ? dup(fd) : -1; @@ -462,7 +471,7 @@ static void http_live_serve(int fd, void (*handler)(int, void *), void *ctx) { /** shutdown(@p how) every registered connection, or only the idle ones. */ static void http_live_kick(int how, bool idle_only) { -#ifdef __wasi__ +#ifdef CWIST_PLATFORM_NO_PROC (void)how; (void)idle_only; #else @@ -476,7 +485,7 @@ static void http_live_kick(int how, bool idle_only) { /** Wait until no handler is serving a classic connection. */ static void http_live_drain(void) { -#ifdef __wasi__ +#ifdef CWIST_PLATFORM_NO_PROC return; #else if (atomic_load_explicit(&g_live_count, memory_order_acquire) == 0) return; @@ -1522,6 +1531,7 @@ bool cwist_http_pool_submit_async(int client_fd, cwist_async_handler_t handler, } /* --- End Async Connection Path --- */ +#endif /* !CWIST_PLATFORM_NO_SOCKETS (socket worker runtime) */ /** * @file http.c @@ -2009,15 +2019,19 @@ void cwist_http_header_free_all(cwist_http_header_node *head) { /** * @brief Copy the cached RFC 7231 IMF-fixdate string for the current second. * @param out_buf Destination buffer; receives 29 date bytes plus NUL. + * @return false when the host has no wall clock (time() fails, e.g. a board + * without an RTC); the caller then omits Date, as RFC 9110 section + * 6.6.1 requires of servers without a clock. * @note Each thread caches its own (second, string) pair, so the once-per-second * recompute needs no lock: at a second boundary every worker recomputes * its own copy once and concurrent one-time recomputes yield the same value. */ -static void cwist_get_cached_date_header(char out_buf[36]) { +static bool cwist_get_cached_date_header(char out_buf[36]) { static _Thread_local time_t cached_sec = (time_t)-1; static _Thread_local char date_str[36]; time_t now = time(NULL); + if (now == (time_t)-1) return false; if (now != cached_sec) { struct tm gmt; #if defined(_WIN32) @@ -2030,6 +2044,7 @@ static void cwist_get_cached_date_header(char out_buf[36]) { } memcpy(out_buf, date_str, 30); out_buf[29] = '\0'; + return true; } /* --- Request Lifecycle --- */ @@ -2130,7 +2145,7 @@ cwist_sstring *cwist_get_client_ip_from_fd(int fd) { // If unavailable, return localhost if (fd <= 0) return s; -#ifndef CWIST_WASI_NO_SOCKETS +#ifndef CWIST_PLATFORM_NO_SOCKETS /* WASI preview1 has no socket peer addresses; the caller only needs a * placeholder when running under a WASM host. */ struct sockaddr_storage addr; @@ -2511,9 +2526,8 @@ static size_t serialize_headers(cwist_http_response *res, char *buf, size_t buf_ curr = curr->next; } - if (!have_date && offset + 37 <= buf_size) { - char date_str[36]; - cwist_get_cached_date_header(date_str); /* 29 bytes, NUL-terminated */ + char date_str[36]; + if (!have_date && offset + 37 <= buf_size && cwist_get_cached_date_header(date_str)) { memcpy(buf + offset, "Date: ", 6); memcpy(buf + offset + 6, date_str, 29); buf[offset + 35] = '\r'; @@ -2596,6 +2610,7 @@ size_t cwist_http_serialize_headers(cwist_http_response *res, char *buf, size_t return serialize_headers(res, buf, buf_size); } +#ifndef CWIST_PLATFORM_NO_SOCKETS /* socket send paths; in-memory dispatch serializes instead */ #include // For writev and BSD sendfile /* --- Optional TCP_CORK coalescing layer (cleartext HTTP/1.1) ------------- @@ -2712,7 +2727,7 @@ static int cwist_http_sendmsg_all(int fd, struct iovec *iov, int iovcnt, int fla wasi_done: cwist_free(buf); return rc; -#elif !defined(CWIST_WASI_NO_SOCKETS) +#elif !defined(CWIST_PLATFORM_NO_SOCKETS) /* Speculative zero-latency fast-path attempt: * Completes immediately for non-saturated sockets without entering poll() loops. */ size_t fast_sent = 0; @@ -3946,6 +3961,8 @@ cwist_async_send_status_t cwist_http_send_response_coalesced(int client_fd, return keep_alive ? CWIST_ASYNC_SEND_KEEPALIVE : CWIST_ASYNC_SEND_CLOSE; } +#endif /* !CWIST_PLATFORM_NO_SOCKETS (socket send paths) */ + /** * @brief Map an HTTP status code to its RFC reason phrase. * @return Static reason phrase, or NULL for unknown codes. @@ -4018,6 +4035,7 @@ const char *cwist_http_status_reason(int status) { } } +#ifndef CWIST_PLATFORM_NO_SOCKETS /* direct socket writers */ /** * @brief Send a minimal error response with Connection: close, used to answer * malformed requests (400/413/417/431/501) before the connection is dropped. @@ -4071,6 +4089,8 @@ static void http_send_100_continue(int fd) { (void)cwist_http_sendmsg_all(fd, &iov, 1, flags); } +#endif /* !CWIST_PLATFORM_NO_SOCKETS (direct socket writers) */ + /** * @brief Materialize an HTTP response into a contiguous string for debugging or TLS writes. * @param res Response object to stringify. @@ -4542,6 +4562,7 @@ int cwist_http_response_stream_end(cwist_http_response *res) { return 0; } +#ifndef CWIST_PLATFORM_NO_SOCKETS /* socket receive (blocking and reactor) */ /** * @brief Read and reassemble a chunked transfer-encoded body. * @return 0 on success, -1 on error. @@ -5151,6 +5172,7 @@ cwist_recv_status_t cwist_http_receive_request_nb(cwist_http_async_conn_t *conn, } /* --- End Non-blocking Request Assembly --- */ +#endif /* !CWIST_PLATFORM_NO_SOCKETS (socket receive) */ typedef struct { const char *ext; @@ -5320,7 +5342,7 @@ const char CWIST_BLOB_500[] = /* Everything from here on is the socket server runtime: WASI preview1 has * no sockets, so the whole section compiles out there. WASM hosts drive * requests through cwist_app_dispatch_memory() instead. */ -#ifndef CWIST_WASI_NO_SOCKETS +#ifndef CWIST_PLATFORM_NO_SOCKETS bool cwist_reuseport_enabled(void) { const char *env = getenv("CWIST_REUSEPORT"); @@ -5464,7 +5486,7 @@ static void cwist_accept_error_backoff(int err) { * @param handler_func Request handler callback. * @param ctx Opaque callback context. */ -#if !defined(__wasi__) +#if !defined(CWIST_PLATFORM_NO_PROC) static void handle_client_forking(int client_fd, void (*handler_func)(int, void *), void *ctx) { pid_t pid = fork(); if (pid == 0) { @@ -5573,7 +5595,7 @@ cwist_error_t cwist_http_server_loop(int server_fd, cwist_server_config *config, return err; } -#if !defined(__wasi__) +#if !defined(CWIST_PLATFORM_NO_PROC) if (config->use_forking) { while (atomic_load(&g_cwist_running)) { int client_fd = http_accept_blocking(server_fd); diff --git a/src/net/http/session.c b/src/net/http/session.c index 42c6213df..0853819a1 100644 --- a/src/net/http/session.c +++ b/src/net/http/session.c @@ -8,7 +8,11 @@ #include #include "async_gc.h" #include -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#include +#ifdef CWIST_BAREMETAL +#include +#endif +#if !defined(CWIST_PLATFORM_HOSTED) /* WASM links no OpenSSL: verify cookie signatures with the bundled * header-only SHA-256/HMAC instead. */ #include @@ -145,7 +149,7 @@ static int base64_decode(const char *in, uint8_t *out, size_t out_len) { */ static bool session_hmac_sha256(const char *key, size_t key_len, const char *msg, size_t msg_len, uint8_t out[32]) { -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) return cwist_hmac_sha256((const uint8_t *)key, key_len, (const uint8_t *)msg, msg_len, out); #else unsigned int len = 32; @@ -203,6 +207,18 @@ static char *generate_secret(size_t len) { return secret; } #endif +#ifdef CWIST_BAREMETAL + unsigned char *pbuf CWIST_DEFER_FREE = cwist_alloc(len); + if (pbuf && cwist_port_entropy(pbuf, len) == 0) { + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) { + secret[i * 2] = hex[pbuf[i] >> 4]; + secret[i * 2 + 1] = hex[pbuf[i] & 0x0F]; + } + secret[len * 2] = '\0'; + return secret; + } +#endif #ifdef __EMSCRIPTEN__ if (fd < 0 && cwist_session_entropy_js(secret, (int)len) == 1) return secret; #endif @@ -297,7 +313,7 @@ static bool verify_signature(cwist_app *app, const char *payload_b64, const char strlen(payload_b64), expected)) { return false; } -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) /* No OpenSSL on WASM: use a portable constant-time loop. */ unsigned int diff = 0; for (size_t i = 0; i < 32; i++) diff |= sig[i] ^ expected[i]; diff --git a/src/sys/app/app.c b/src/sys/app/app.c index baff93a87..b8e31d6a2 100644 --- a/src/sys/app/app.c +++ b/src/sys/app/app.c @@ -62,21 +62,26 @@ #define PATH_MAX 4096 #endif #include -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#include +#if defined(CWIST_PLATFORM_HOSTED) #include #include #endif #include #include +#ifndef CWIST_BAREMETAL /* newlib ships no poll/inet/dirent headers */ #include +#endif #if defined(__GLIBC__) #include #endif +#ifndef CWIST_BAREMETAL #include #include #include +#endif #include -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC #include #endif #include @@ -97,7 +102,7 @@ * @brief Tune system resource limits to handle high concurrency loads. */ static void cwist_app_tune_system(void) { -#ifdef __wasi__ +#ifdef CWIST_PLATFORM_NO_PROC /* WASI preview1 has no rlimit; fd budgeting is the host's concern. */ #else struct rlimit rl; @@ -139,7 +144,7 @@ static void cwist_app_tune_system(void) { } #endif -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) /** * @brief Read the current libttak tick count used for static-file retirement deadlines. * @return Monotonic tick value compatible with libttak memory APIs. @@ -149,7 +154,7 @@ static inline uint64_t cwist_app_mem_now(void) { } #endif -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) /* The static-file memory cache rides on libttak's mem tree and a watcher * thread; WASM hosts get neither (cwist_prepare_static() always declines). */ /** @@ -778,7 +783,7 @@ static bool cwist_static_match_entry(const cwist_static_dir *entry, const char * */ static bool cwist_prepare_static(cwist_app *app, cwist_http_request *req, cwist_static_request_info *info) { -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) /* No filesystem-backed static cache in WASM hosts. */ (void)app; (void)req; @@ -808,7 +813,7 @@ static bool cwist_prepare_static(cwist_app *app, cwist_http_request *req, #endif } -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) /** * @brief Recursively scan a static root directory to size or populate the fixed-memory cache. * @param fs_root Filesystem directory to scan. @@ -978,7 +983,7 @@ static char *cwist_normalize_directory(const char *directory) { return copy; } -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) /** * @brief Cleanup hook used when a response borrows a static-file cache payload. * @param ptr Borrowed body pointer. @@ -1212,7 +1217,7 @@ static void cwist_static_handler(cwist_http_request *req, cwist_http_response *r #include #include -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) /** * @brief Allocate and initialize the top-level CWIST application object. * @return Newly created application, or NULL when allocation fails. @@ -1328,7 +1333,7 @@ cwist_app *cwist_app_create(void) { app->scheduler = NULL; app->grpc_routes = NULL; -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) cwist_app_refresh_https_request_handler(app); #endif @@ -1458,14 +1463,14 @@ void cwist_app_configure_bdr(cwist_app *app, size_t max_bytes, time_t max_entry_ */ void cwist_app_destroy(cwist_app *app) { if (!app) return; -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) cwist_multiport_destroy_owned_subapps(app); cwist_multiport_unlink_app(app); #endif if (app->cert_path) cwist_free(app->cert_path); if (app->key_path) cwist_free(app->key_path); if (app->tls_groups) cwist_free(app->tls_groups); -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) if (app->ssl_ctx) cwist_https_destroy_context(app->ssl_ctx); #ifdef CWIST_HTTP3 if (app->h3_ctx) cwist_http3_destroy_context(app->h3_ctx); @@ -1507,7 +1512,7 @@ void cwist_app_destroy(cwist_app *app) { curr_s = next; } -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) if (app->mem_manager) { __atomic_store_n(&app->mem_manager->watcher_running, false, __ATOMIC_RELEASE); // If thread was started, join it. @@ -1539,7 +1544,7 @@ void cwist_app_destroy(cwist_app *app) { cwist_assets_destroy(app->assets); app->assets = NULL; -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) && defined(CWIST_DB) +#if defined(CWIST_PLATFORM_HOSTED) && defined(CWIST_DB) if (app->nuke_enabled) { cwist_nuke_close(); } @@ -1568,7 +1573,7 @@ void cwist_app_destroy(cwist_app *app) { if (app->session_secret) cwist_free(app->session_secret); if (app->session_name) cwist_free(app->session_name); -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) #ifdef CWIST_DB if (app->db_pool) { cwist_db_pool_destroy((cwist_db_pool_t *)app->db_pool); @@ -1639,7 +1644,7 @@ static void execute_chain(cwist_app *app, cwist_http_request *req, cwist_http_re * @return Tagged CWIST error describing success or failure. */ cwist_error_t cwist_app_use_https(cwist_app *app, const char *cert_path, const char *key_path) { -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) (void)cert_path; (void)key_path; cwist_error_t err = make_error(CWIST_ERR_INT16); @@ -1684,7 +1689,7 @@ cwist_error_t cwist_app_use_https(cwist_app *app, const char *cert_path, const c * application. See the public contract in app.h. */ cwist_error_t cwist_app_reload_tls(cwist_app *app, const char *cert_path, const char *key_path) { -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) (void)cert_path; (void)key_path; cwist_error_t err = make_error(CWIST_ERR_INT16); @@ -1760,7 +1765,7 @@ cwist_error_t cwist_app_use_https2(cwist_app *app, bool enabled) { } app->use_https2 = enabled; -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) err.error.err_i16 = -1; /* HTTP/2 transport is native-only */ return err; #else @@ -1783,7 +1788,7 @@ cwist_error_t cwist_app_use_https3(cwist_app *app, bool enabled) { } app->use_https3 = enabled; -#if defined(__EMSCRIPTEN__) || defined(__wasi__) || !defined(CWIST_HTTP3) +#if !defined(CWIST_PLATFORM_HOSTED) || !defined(CWIST_HTTP3) err.error.err_i16 = -1; /* HTTP/3 transport is native-only, or compiled out (CWIST_HTTP3=0) */ return err; #else @@ -1818,7 +1823,7 @@ cwist_error_t cwist_app_use_http3(cwist_app *app, bool enabled) { } app->use_http3 = enabled; -#if defined(__EMSCRIPTEN__) || defined(__wasi__) || !defined(CWIST_HTTP3) +#if !defined(CWIST_PLATFORM_HOSTED) || !defined(CWIST_HTTP3) err.error.err_i16 = -1; /* HTTP/3 transport is native-only, or compiled out (CWIST_HTTP3=0) */ return err; #else @@ -1830,7 +1835,7 @@ cwist_error_t cwist_app_use_http3(cwist_app *app, bool enabled) { void cwist_app_use_webtransport(cwist_app *app, cwist_webtransport_handler_func handler) { if (!app) return; app->wt_handler = handler; -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) && defined(CWIST_HTTP3) +#if defined(CWIST_PLATFORM_HOSTED) && defined(CWIST_HTTP3) if (app->h3_ctx) { cwist_http3_set_webtransport_handler(app->h3_ctx, handler); } @@ -1850,7 +1855,7 @@ cwist_error_t cwist_app_use_db(cwist_app *app, const char *db_path) { return err; } -#if defined(__EMSCRIPTEN__) || defined(__wasi__) || !defined(CWIST_DB) +#if !defined(CWIST_PLATFORM_HOSTED) || !defined(CWIST_DB) err.error.err_i16 = -1; /* SQLite lives outside the minimal WASM core, or is compiled out (CWIST_DB=0) */ return err; @@ -1889,7 +1894,7 @@ cwist_error_t cwist_app_use_nuke_db(cwist_app *app, const char *db_path, int syn return err; } -#if defined(__EMSCRIPTEN__) || defined(__wasi__) || !defined(CWIST_DB) +#if !defined(CWIST_PLATFORM_HOSTED) || !defined(CWIST_DB) (void)sync_interval_ms; err.error.err_i16 = -1; /* NUKE DB needs native libttak memory, or is compiled out (CWIST_DB=0) */ @@ -1942,7 +1947,7 @@ cwist_error_t cwist_app_use_nuke_db(cwist_app *app, const char *db_path, int syn */ cwist_db *cwist_app_get_db(cwist_app *app) { if (!app) return NULL; -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) && defined(CWIST_DB) +#if defined(CWIST_PLATFORM_HOSTED) && defined(CWIST_DB) if (app->nuke_enabled) { app->db->conn = cwist_nuke_get_db(); } @@ -1956,7 +1961,7 @@ cwist_error_t cwist_app_use_db_pool(cwist_app *app, const char *db_path, size_t err.error.err_i16 = -1; return err; } -#if defined(__EMSCRIPTEN__) || defined(__wasi__) || !defined(CWIST_DB) +#if !defined(CWIST_PLATFORM_HOSTED) || !defined(CWIST_DB) (void)db_path; (void)max_conns; err.error.err_i16 = @@ -1987,7 +1992,7 @@ cwist_error_t cwist_app_use_redis(cwist_app *app, const char *host, int port, si err.error.err_i16 = -1; return err; } -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) (void)host; (void)port; (void)max_conns; @@ -2018,7 +2023,7 @@ cwist_error_t cwist_app_use_scheduler(cwist_app *app, size_t worker_count, size_ err.error.err_i16 = -1; return err; } -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) (void)worker_count; (void)queue_capacity; err.error.err_i16 = -1; /* worker pools need native threads */ @@ -2775,7 +2780,7 @@ static void internal_route_handler(cwist_app *app, cwist_http_request *req, if (res) res->endpoint_opts = req->endpoint_opts; #ifdef CWIST_WS if (found_route->ws_async_on_message && req->async_conn) { -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) /* C1M path (issue #181): the route handler runs on the reactor * thread, so invoking a blocking ws_handler here would park the * whole worker in recv(). Complete the upgrade, send the 101 @@ -2811,7 +2816,7 @@ static void internal_route_handler(cwist_app *app, cwist_http_request *req, } #endif } else if (found_route->ws_handler) { -#if defined(__EMSCRIPTEN__) || defined(__wasi__) +#if !defined(CWIST_PLATFORM_HOSTED) /* WebSocket upgrades need a live socket; in-memory dispatch has none. */ res->status_code = CWIST_HTTP_BAD_REQUEST; cwist_sstring_assign(res->body, "WebSocket Upgrade Failed"); @@ -2867,7 +2872,7 @@ static void internal_route_handler(cwist_app *app, cwist_http_request *req, cwist_static_request_info static_info = {0}; if (cwist_prepare_static(app, req, &static_info)) { -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) req->endpoint_opts = CWIST_ENDPOINT_FILE; if (res) res->endpoint_opts = req->endpoint_opts; execute_chain(app, req, res, cwist_static_handler, &static_info); @@ -2889,7 +2894,7 @@ static void internal_route_handler(cwist_app *app, cwist_http_request *req, } } -#if !defined(__EMSCRIPTEN__) && !defined(CWIST_WASI_NO_SOCKETS) +#if !defined(__EMSCRIPTEN__) && !defined(CWIST_PLATFORM_NO_SOCKETS) /* Everything below up to the multiport section is socket serving machinery: * TLS/plain connection handlers, h2/h3 bridges, async flush paths. */ static void static_http2_route_bridge(void *user_ctx, cwist_http_request *req, @@ -2915,7 +2920,7 @@ static void static_http3_route_bridge(void *user_ctx, cwist_http_request *req, } #endif -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC /* TLS connection handlers: BoringSSL is not linked on WASI, and the only * call sites (cwist_app_listen's SSL branch, multiport) are compiled out * there. */ @@ -3084,7 +3089,7 @@ typedef enum { APP_SERVE_DETACH /* Upgraded fd handed to another owner (WS async); do not close or re-arm. */ } app_serve_result_t; -#ifndef CWIST_WASI_NO_SOCKETS +#ifndef CWIST_PLATFORM_NO_SOCKETS /* Socket-server request serving: preview1 hosts dispatch in memory instead. */ static app_serve_result_t app_serve_parsed_request(cwist_app *app, int client_fd, cwist_http_request *req, @@ -3306,7 +3311,7 @@ static unsigned int app_http_yield_batch(void) { return (unsigned int)v; } -#ifndef CWIST_WASI_NO_SOCKETS +#ifndef CWIST_PLATFORM_NO_SOCKETS /* h2c connection handoff: the blocking HTTP/2 server runs on a detached * thread per connection so an abandoned connection's idle poll never pins * a reactor loop. Owns the fd, the sniffed-byte replay, and the app. */ @@ -3495,9 +3500,9 @@ cwist_async_action_t cwist_app_http_handler_async(int client_fd, cwist_http_asyn return app_async_flush_exit( client_fd, conn, conn->peer_eof ? CWIST_ASYNC_CLOSE : CWIST_ASYNC_REARM, !conn->peer_eof); } -#endif /* CWIST_WASI_NO_SOCKETS */ +#endif /* CWIST_PLATFORM_NO_SOCKETS */ -#ifndef CWIST_WASI_NO_SOCKETS +#ifndef CWIST_PLATFORM_NO_SOCKETS void cwist_app_http_handler(int client_fd, void *ctx) { cwist_app *app = (cwist_app *)ctx; @@ -3616,10 +3621,10 @@ void cwist_app_http_handler(int client_fd, void *ctx) { close(client_fd); } -#endif /* CWIST_WASI_NO_SOCKETS (socket serving machinery) */ +#endif /* CWIST_PLATFORM_NO_SOCKETS (socket serving machinery) */ #endif /* __EMSCRIPTEN__ (socket serving machinery) */ -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) /* Multiport listeners are socket/UDP machinery; WASM hosts never bind. */ #ifndef CWIST_MULTIPORT_MAX_PORTS #define CWIST_MULTIPORT_MAX_PORTS 64 @@ -4546,7 +4551,7 @@ int cwist_app_multiport(cwist_app **app_ref, unsigned short public_port, cwist_m } #endif /* __EMSCRIPTEN__ (multiport) */ -#if !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#if defined(CWIST_PLATFORM_HOSTED) struct h3_thread_payload { int udp_fd; cwist_app *app; @@ -4624,19 +4629,19 @@ int cwist_app_listen(cwist_app *app, int port) { return cwist_app_listen_ex(app, port, 0, -1); } -#if !defined(__EMSCRIPTEN__) && !defined(CWIST_WASI_NO_SOCKETS) +#if !defined(__EMSCRIPTEN__) && !defined(CWIST_PLATFORM_NO_SOCKETS) static int app_listen_serve(cwist_app *app, int port, int workers_override, int c1m_override); #endif int cwist_app_listen_ex(cwist_app *app, int port, int workers_override, int c1m_override) { -#if defined(__EMSCRIPTEN__) || defined(CWIST_WASI_NO_SOCKETS) +#if defined(__EMSCRIPTEN__) || defined(CWIST_PLATFORM_NO_SOCKETS) (void)port; (void)workers_override; (void)c1m_override; if (app) app->port = port; return -1; /* WASM hosts drive requests through cwist_app_dispatch_memory() */ #else -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC // Ignore SIGPIPE signal(SIGPIPE, SIG_IGN); #endif @@ -4650,7 +4655,7 @@ int cwist_app_listen_ex(cwist_app *app, int port, int workers_override, int c1m_ #endif } -#if !defined(__EMSCRIPTEN__) && !defined(CWIST_WASI_NO_SOCKETS) +#if !defined(__EMSCRIPTEN__) && !defined(CWIST_PLATFORM_NO_SOCKETS) /* Body of cwist_app_listen_ex() between installing and restoring the * shutdown signal handlers. */ static int app_listen_serve(cwist_app *app, int port, int workers_override, int c1m_override) { @@ -4698,7 +4703,7 @@ static int app_listen_serve(cwist_app *app, int port, int workers_override, int /* Bind the HTTP/3 UDP socket before forking as well. The thread that * services it is started per-process after the fork. */ int udp_fd = -1; -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC if (app->h3_ctx && (app->use_http3 || app->use_https3)) { udp_fd = socket(AF_INET, SOCK_DGRAM, 0); if (udp_fd >= 0) { @@ -4790,14 +4795,14 @@ static int app_listen_serve(cwist_app *app, int port, int workers_override, int * that worker's first request. The calibrated g_tsc_freq_ghz/g_tsc_scale * globals are plain process memory, so fork children inherit the values * copy-on-write and never repeat the sleep. */ -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC (void)ttak_get_tick_count_ns(); #endif bool is_worker_child = false; pid_t worker_pids[workers > 1 ? workers - 1 : 1]; size_t worker_count = 0; -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC int child_idx = 0; for (int i = 1; i < workers; i++) { pid_t pid = fork(); @@ -4839,7 +4844,7 @@ static int app_listen_serve(cwist_app *app, int port, int workers_override, int /* The static cache owns a libttak cleanup thread as well as the watcher. * Initialize it only after all worker forks so no child inherits mutexes * or a pthread handle whose owning thread exists only in the parent. */ -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC cwist_mem_init(app); // Per-process threads start here. Each worker gets its own watcher and @@ -4877,7 +4882,7 @@ static int app_listen_serve(cwist_app *app, int port, int workers_override, int // Check config for non-blocking scale mode (default enabled) const char *c1m = getenv("CWIST_C1M_MODE"); -#ifdef __wasi__ +#ifdef CWIST_PLATFORM_NO_PROC /* The C1M reactor is epoll/eventfd-based; WASI hosts run the blocking * accept loop instead. */ bool use_c1m = false; @@ -4889,7 +4894,7 @@ static int app_listen_serve(cwist_app *app, int port, int workers_override, int use_c1m = false; } } -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC if (c1m_override >= 0) use_c1m = c1m_override != 0; #else (void)c1m_override; @@ -4897,7 +4902,7 @@ static int app_listen_serve(cwist_app *app, int port, int workers_override, int if (use_c1m) { cwist_async_server_loop(server_fd, app); } else { -#ifdef __wasi__ +#ifdef CWIST_PLATFORM_NO_PROC if (app->use_ssl) { fprintf(stderr, "TLS is not available on WASI (no BoringSSL); serve cleartext.\n"); __atomic_store_n(&g_cwist_listen_fd, -1, __ATOMIC_SEQ_CST); @@ -4932,7 +4937,7 @@ static int app_listen_serve(cwist_app *app, int port, int workers_override, int if (app->h3_ctx) { __atomic_store_n(&app->h3_ctx->running, 0, __ATOMIC_RELEASE); } -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC if (h3_thread_started) { pthread_join(h3_tid, NULL); } @@ -4961,7 +4966,7 @@ static int app_listen_serve(cwist_app *app, int port, int workers_override, int int worker_result = 0; /* Parent process reaps worker children so they do not become zombies. */ -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC if (!is_worker_child && workers > 1) { /* SIGTERM is delivered to the supervisor only. Ask every worker to * leave its inherited accept loop before waiting for it; otherwise a diff --git a/src/sys/app/log_async.c b/src/sys/app/log_async.c index 9ab976a67..574ce93ee 100644 --- a/src/sys/app/log_async.c +++ b/src/sys/app/log_async.c @@ -25,11 +25,12 @@ #include #include #include +#include -#if defined(__wasm__) +#if defined(__wasm__) || defined(CWIST_BAREMETAL) /* WASM builds have no fork() and, without the threads proposal, no - * background thread: async logging never starts and every line is written - * synchronously. */ + * background thread (bare metal has neither): async logging never starts + * and every line is written synchronously. */ bool cwist_log_async_start(size_t buffer_bytes) { (void)buffer_bytes; return false; diff --git a/src/sys/app/shutdown.c b/src/sys/app/shutdown.c index 7bdec5c78..ce79cc5e8 100644 --- a/src/sys/app/shutdown.c +++ b/src/sys/app/shutdown.c @@ -9,7 +9,8 @@ #include #include #include -#ifndef __wasi__ +#include +#ifndef CWIST_PLATFORM_NO_PROC #include #endif @@ -66,7 +67,7 @@ void cwist_shutdown_request(void) { * been reused for an unrelated file. */ int fd = __atomic_exchange_n(&g_cwist_listen_fd, -1, __ATOMIC_SEQ_CST); if (fd >= 0) { -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC /* close() alone does not interrupt an accept() already blocked in * another thread on Linux; shutdown() makes it return (EINVAL), * which the accept loops treat as the end of the server. */ @@ -77,7 +78,7 @@ void cwist_shutdown_request(void) { __atomic_exchange_n(&g_cwist_udp_fd, -1, __ATOMIC_SEQ_CST); } -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC /** * @brief Signal handler for SIGTERM/SIGINT; forwards to cwist_shutdown_request(). * @param sig Signal number (ignored). @@ -88,7 +89,7 @@ static void cwist_shutdown_handler(int sig) { } #endif -#ifndef __wasi__ +#ifndef CWIST_PLATFORM_NO_PROC /* What SIGTERM/SIGINT did before CWIST installed its handlers, so * cwist_shutdown_restore_handlers() can put it back. Saved only on the first * install after a restore: a second install would otherwise record CWIST's diff --git a/src/sys/port/baremetal.c b/src/sys/port/baremetal.c new file mode 100644 index 000000000..87616b9a2 --- /dev/null +++ b/src/sys/port/baremetal.c @@ -0,0 +1,220 @@ +/** + * @file baremetal.c + * @brief OS services the core expects but newlib on bare metal lacks. + * + * Built only into the CWIST_BAREMETAL archive (make baremetal). newlib + * declares the POSIX thread and timer APIs once the build enables them + * (-D_POSIX_THREADS, -D_POSIX_TIMERS, ...; see BAREMETAL_CFLAGS) but ships + * no implementation, so this file provides one for a single-threaded + * runtime, on top of the firmware hooks in : + * + * - pthread: mutexes and rwlocks cannot contend, condition variables never + * block (callers re-check their predicate), pthread_create() fails with + * EAGAIN so every background-thread path takes its no-thread fallback. + * Same contract as libttak's WASI preview1 stubs. + * - clock_gettime(), nanosleep() and timegm() live in baremetal_time.c, + * which the host unit test (tests/test_baremetal_port.c) also builds. + * - sched_yield() is a no-op. + * - 64-bit __atomic_* helpers: ARMv7-M/ARMv8-M have no LDREXD/STREXD, so + * GCC emits libcalls that libgcc does not implement for Cortex-M. + */ + +#include + +#if defined(CWIST_BAREMETAL) + +#include + +#include +#include +#include +#include +#include +#include +#include + +/* --- pthread: one thread, nothing contends --------------------------------- */ + +int pthread_mutex_init(pthread_mutex_t *m, const pthread_mutexattr_t *attr) { + (void)attr; + *m = 0; + return 0; +} +int pthread_mutex_destroy(pthread_mutex_t *m) { (void)m; return 0; } +int pthread_mutex_lock(pthread_mutex_t *m) { (void)m; return 0; } +int pthread_mutex_trylock(pthread_mutex_t *m) { (void)m; return 0; } +int pthread_mutex_unlock(pthread_mutex_t *m) { (void)m; return 0; } + +int pthread_rwlock_init(pthread_rwlock_t *rw, const pthread_rwlockattr_t *attr) { + (void)attr; + *rw = 0; + return 0; +} +int pthread_rwlock_destroy(pthread_rwlock_t *rw) { (void)rw; return 0; } +int pthread_rwlock_rdlock(pthread_rwlock_t *rw) { (void)rw; return 0; } +int pthread_rwlock_wrlock(pthread_rwlock_t *rw) { (void)rw; return 0; } +int pthread_rwlock_unlock(pthread_rwlock_t *rw) { (void)rw; return 0; } + +int pthread_cond_init(pthread_cond_t *c, const pthread_condattr_t *attr) { + (void)attr; + *c = 0; + return 0; +} +int pthread_cond_destroy(pthread_cond_t *c) { (void)c; return 0; } +int pthread_cond_signal(pthread_cond_t *c) { (void)c; return 0; } +int pthread_cond_broadcast(pthread_cond_t *c) { (void)c; return 0; } +int pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m) { + (void)c; + (void)m; + return 0; /* never block: no other thread could signal */ +} +int pthread_cond_timedwait(pthread_cond_t *c, pthread_mutex_t *m, const struct timespec *abstime) { + (void)c; + (void)m; + (void)abstime; + return ETIMEDOUT; +} +int pthread_condattr_init(pthread_condattr_t *a) { (void)a; return 0; } +int pthread_condattr_destroy(pthread_condattr_t *a) { (void)a; return 0; } +int pthread_condattr_setclock(pthread_condattr_t *a, clockid_t id) { + (void)a; + (void)id; + return 0; +} + +int pthread_attr_init(pthread_attr_t *a) { + memset(a, 0, sizeof(*a)); + return 0; +} +int pthread_attr_destroy(pthread_attr_t *a) { (void)a; return 0; } +int pthread_attr_setdetachstate(pthread_attr_t *a, int state) { + (void)a; + (void)state; + return 0; +} +int pthread_attr_setstacksize(pthread_attr_t *a, size_t size) { + (void)a; + (void)size; + return 0; +} + +int pthread_create(pthread_t *t, const pthread_attr_t *attr, void *(*fn)(void *), void *arg) { + (void)t; + (void)attr; + (void)fn; + (void)arg; + return EAGAIN; /* no threads: callers fall back to inline/no worker */ +} +int pthread_join(pthread_t t, void **ret) { + (void)t; + (void)ret; + return ESRCH; +} +int pthread_detach(pthread_t t) { + (void)t; + return ESRCH; +} +pthread_t pthread_self(void) { return (pthread_t)1; } +int pthread_equal(pthread_t a, pthread_t b) { return a == b; } + +int pthread_once(pthread_once_t *once, void (*init)(void)) { + if (!once->init_executed) { + once->init_executed = 1; + init(); + } + return 0; +} + +#define CWIST_BAREMETAL_KEYS 32 +static void *g_key_values[CWIST_BAREMETAL_KEYS]; +static unsigned g_key_count; + +int pthread_key_create(pthread_key_t *key, void (*destructor)(void *)) { + (void)destructor; /* the only thread never exits */ + if (g_key_count >= CWIST_BAREMETAL_KEYS) return EAGAIN; + *key = (pthread_key_t)g_key_count++; + return 0; +} +void *pthread_getspecific(pthread_key_t key) { + return (unsigned)key < g_key_count ? g_key_values[key] : NULL; +} +int pthread_setspecific(pthread_key_t key, const void *value) { + if ((unsigned)key >= g_key_count) return EINVAL; + g_key_values[key] = (void *)value; + return 0; +} + +int sched_yield(void) { return 0; } + +/* --- 64-bit atomics (Cortex-M: mask interrupts around the access) ------------ */ + +#if defined(__ARM_ARCH_7M__) || defined(__ARM_ARCH_7EM__) || defined(__ARM_ARCH_8M_MAIN__) || \ + defined(__ARM_ARCH_8_1M_MAIN__) + +static inline uint32_t atomic_irq_save(void) { + uint32_t primask; + __asm volatile("mrs %0, primask\n\tcpsid i" : "=r"(primask)::"memory"); + return primask; +} +static inline void atomic_irq_restore(uint32_t primask) { + __asm volatile("msr primask, %0" ::"r"(primask) : "memory"); +} + +/* GCC's libcall ABI for 8-byte atomics; the memory-order argument is moot + * with interrupts masked on a single core. */ +uint64_t __atomic_load_8(const volatile void *mem, int order) { + (void)order; + uint32_t s = atomic_irq_save(); + uint64_t v = *(const volatile uint64_t *)mem; + atomic_irq_restore(s); + return v; +} +void __atomic_store_8(volatile void *mem, uint64_t val, int order) { + (void)order; + uint32_t s = atomic_irq_save(); + *(volatile uint64_t *)mem = val; + atomic_irq_restore(s); +} +uint64_t __atomic_exchange_8(volatile void *mem, uint64_t val, int order) { + (void)order; + uint32_t s = atomic_irq_save(); + uint64_t old = *(volatile uint64_t *)mem; + *(volatile uint64_t *)mem = val; + atomic_irq_restore(s); + return old; +} +bool __atomic_compare_exchange_8(volatile void *mem, void *expected, uint64_t desired, bool weak, + int success, int failure) { + (void)weak; + (void)success; + (void)failure; + uint32_t s = atomic_irq_save(); + uint64_t cur = *(volatile uint64_t *)mem; + bool ok = cur == *(uint64_t *)expected; + if (ok) + *(volatile uint64_t *)mem = desired; + else + *(uint64_t *)expected = cur; + atomic_irq_restore(s); + return ok; +} + +#define CWIST_BAREMETAL_FETCH_OP(name, op) \ + uint64_t __atomic_fetch_##name##_8(volatile void *mem, uint64_t val, int order) { \ + (void)order; \ + uint32_t s = atomic_irq_save(); \ + uint64_t old = *(volatile uint64_t *)mem; \ + *(volatile uint64_t *)mem = old op val; \ + atomic_irq_restore(s); \ + return old; \ + } +CWIST_BAREMETAL_FETCH_OP(add, +) +CWIST_BAREMETAL_FETCH_OP(sub, -) +CWIST_BAREMETAL_FETCH_OP(and, &) +CWIST_BAREMETAL_FETCH_OP(or, |) +CWIST_BAREMETAL_FETCH_OP(xor, ^) +#undef CWIST_BAREMETAL_FETCH_OP + +#endif /* ARMv7-M / ARMv8-M mainline */ + +#endif /* CWIST_BAREMETAL */ diff --git a/src/sys/port/baremetal_time.c b/src/sys/port/baremetal_time.c new file mode 100644 index 000000000..fe12b6a44 --- /dev/null +++ b/src/sys/port/baremetal_time.c @@ -0,0 +1,73 @@ +/** + * @file baremetal_time.c + * @brief clock_gettime(), nanosleep() and timegm() for CWIST_BAREMETAL. + * + * newlib declares these once -D_POSIX_TIMERS / -D_POSIX_MONOTONIC_CLOCK are + * set (see BAREMETAL_CFLAGS) but implements none of them: + * + * - clock_gettime(CLOCK_MONOTONIC) comes from cwist_port_monotonic_ns(); + * CLOCK_REALTIME from gettimeofday(), i.e. the firmware's RTC if any. + * - nanosleep() busy-waits on the monotonic clock. + * - timegm() is Howard Hinnant's days_from_civil. + * + * Kept apart from baremetal.c so tests/test_baremetal_port.c can build it on + * the host with the three names renamed (-Dclock_gettime=... etc.). + */ + +#include + +#if defined(CWIST_BAREMETAL) + +#include + +#include +#include +#include +#include + +int clock_gettime(clockid_t id, struct timespec *ts) { + if (id == CLOCK_REALTIME) { + struct timeval tv; + if (gettimeofday(&tv, NULL) != 0) return -1; + ts->tv_sec = tv.tv_sec; + ts->tv_nsec = (long)tv.tv_usec * 1000L; + return 0; + } + if (id != CLOCK_MONOTONIC) { + errno = EINVAL; + return -1; + } + uint64_t ns = cwist_port_monotonic_ns(); + ts->tv_sec = (time_t)(ns / 1000000000u); + ts->tv_nsec = (long)(ns % 1000000000u); + return 0; +} + +int nanosleep(const struct timespec *req, struct timespec *rem) { + if (!req || req->tv_nsec < 0 || req->tv_nsec >= 1000000000L) { + errno = EINVAL; + return -1; + } + uint64_t until = cwist_port_monotonic_ns() + (uint64_t)req->tv_sec * 1000000000u + + (uint64_t)req->tv_nsec; + while (cwist_port_monotonic_ns() < until) { + } + if (rem) rem->tv_sec = 0, rem->tv_nsec = 0; + return 0; +} + +/* Days from 1970-01-01 to y-m-d (proleptic Gregorian), Howard Hinnant's + * days_from_civil. */ +time_t timegm(struct tm *tm) { + long long y = (long long)tm->tm_year + 1900; + int m = tm->tm_mon + 1; + y -= m <= 2; + long long era = (y >= 0 ? y : y - 399) / 400; + unsigned yoe = (unsigned)(y - era * 400); + unsigned doy = (153u * (unsigned)(m > 2 ? m - 3 : m + 9) + 2u) / 5u + (unsigned)tm->tm_mday - 1u; + unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy; + long long days = era * 146097LL + (long long)doe - 719468LL; + return (time_t)(days * 86400LL + tm->tm_hour * 3600LL + tm->tm_min * 60LL + tm->tm_sec); +} + +#endif /* CWIST_BAREMETAL */ diff --git a/tests/baremetal/board.c b/tests/baremetal/board.c new file mode 100644 index 000000000..1e0da6307 --- /dev/null +++ b/tests/baremetal/board.c @@ -0,0 +1,151 @@ +/* Minimal Cortex-M "board" for the bare-metal smoke firmware: startup code, + * a bounded _sbrk heap with high-water accounting, semihosting output, stack + * painting, and the hooks. + * + * Under qemu-arm (user mode, BOARD_QEMU_USER) the emulator loads .data at its + * VMA and owns SP and the FPU, so the reset path skips the LMA copy and the + * CPACR write. On real hardware the entropy hook must read the RNG + * peripheral; the xorshift here only exists to make the smoke deterministic. + */ +#include + +#include +#include +#include +#include +#include + +extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss, _estack; +extern char _heap_start, _heap_end; +extern int main(void); +extern void __libc_init_array(void); +void board_exit(int code); + +void Reset_Handler(void) { +#if defined(__ARM_FP) && !defined(BOARD_QEMU_USER) + *(volatile uint32_t *)0xE000ED88 |= (0xFu << 20); /* CPACR: enable CP10/CP11 */ + __asm volatile("dsb\n\tisb"); +#endif +#ifndef BOARD_QEMU_USER + memcpy(&_sdata, &_sidata, (size_t)((char *)&_edata - (char *)&_sdata)); +#endif + memset(&_sbss, 0, (size_t)((char *)&_ebss - (char *)&_sbss)); + __libc_init_array(); + board_exit(main()); +} + +static void Default_Handler(void) { + for (;;) { + } +} + +__attribute__((section(".isr_vector"), used)) const void *board_vectors[16] = { + &_estack, + (void *)Reset_Handler, + (void *)Default_Handler, /* NMI */ + (void *)Default_Handler, /* HardFault */ + (void *)Default_Handler, /* MemManage */ + (void *)Default_Handler, /* BusFault */ + (void *)Default_Handler, /* UsageFault */ +}; + +/* --- semihosting ------------------------------------------------------------ */ + +static int semihost(int op, const void *arg) { + register int r0 __asm("r0") = op; + register const void *r1 __asm("r1") = arg; + __asm volatile("bkpt 0xAB" : "+r"(r0) : "r"(r1) : "memory"); + return r0; +} + +void board_log(const char *s) { semihost(0x04 /* SYS_WRITE0 */, s); } + +void board_exit(int code) { + uint32_t args[2] = {0x20026 /* ADP_Stopped_ApplicationExit */, (uint32_t)code}; + semihost(0x20 /* SYS_EXIT_EXTENDED */, args); + for (;;) { + } +} + +/* newlib's stdio and abort() land here. */ +int _write(int fd, const char *buf, int len) { + (void)fd; /* stdout and stderr both go to the debug console */ + for (int i = 0; i < len; i++) semihost(0x03 /* SYS_WRITEC */, &buf[i]); + return len; +} + +void _exit(int code) { board_exit(code); } + +/* -nostartfiles drops crti/crtn; __libc_init_array still calls these. */ +void _init(void) {} +void _fini(void) {} + +/* --- heap ----------------------------------------------------------------- */ + +static char *heap_brk = &_heap_start; +static size_t heap_high; + +void *_sbrk(ptrdiff_t incr) { + char *prev = heap_brk; + if (heap_brk + incr > &_heap_end) { + errno = ENOMEM; + return (void *)-1; + } + heap_brk += incr; + if ((size_t)(heap_brk - &_heap_start) > heap_high) heap_high = (size_t)(heap_brk - &_heap_start); + return prev; +} + +size_t board_heap_high_water(void) { return heap_high; } + +/* --- stack high-water: paint below the current SP, scan after the run ------ */ + +#define BOARD_STACK_PAINT (48u * 1024u) +static uint32_t *paint_lo, *paint_hi; + +__attribute__((noinline)) void board_stack_paint(void) { + uint32_t *sp; + __asm volatile("mov %0, sp" : "=r"(sp)); + paint_hi = sp - 64; /* keep clear of this frame */ + paint_lo = paint_hi - BOARD_STACK_PAINT / 4; + for (uint32_t *p = paint_lo; p < paint_hi; p++) *p = 0xDEADBEEFu; +} + +size_t board_stack_used(void) { + uint32_t *p = paint_lo; + while (p < paint_hi && *p == 0xDEADBEEFu) p++; + return (size_t)((char *)paint_hi - (char *)p); +} + +/* --- CWIST port hooks ------------------------------------------------------- */ + +static uint64_t fake_ns; +static uint32_t rng_state = 0x9E3779B9u; +unsigned board_entropy_calls; + +uint64_t cwist_port_monotonic_ns(void) { + fake_ns += 1000; /* SysTick/DWT would back this on hardware */ + return fake_ns; +} + +int cwist_port_entropy(void *buf, size_t len) { + board_entropy_calls++; + uint8_t *out = buf; + for (size_t i = 0; i < len; i++) { + rng_state ^= rng_state << 13; + rng_state ^= rng_state >> 17; + rng_state ^= rng_state << 5; + out[i] = (uint8_t)rng_state; + } + return 0; +} + +#ifdef BOARD_RTC +/* An RTC behind newlib's time(); without it time() fails and CWIST omits Date. */ +int _gettimeofday(struct timeval *tv, void *tz) { + (void)tz; + tv->tv_sec = 1792886400 + (time_t)(fake_ns / 1000000000u); /* 2026-10-25 */ + tv->tv_usec = 0; + return 0; +} +#endif diff --git a/tests/baremetal/hello.c b/tests/baremetal/hello.c new file mode 100644 index 000000000..a46f5f684 --- /dev/null +++ b/tests/baremetal/hello.c @@ -0,0 +1,111 @@ +/* Bare-metal smoke firmware (issue #447): a CWIST app on Cortex-M driven only + * through cwist_app_dispatch_memory(). Checks routing, JSON bodies, the 404 + * path, the entropy hook, and the Date header contract (present with an RTC, + * absent without one), then reports heap/stack high-water marks. Run with + * `make baremetal-smoke`. */ +#include +#include +#include + +#include +#include +#include + +#ifndef HELLO_ITERS +#define HELLO_ITERS 3 +#endif + +extern void board_log(const char *s); +extern size_t board_heap_high_water(void); +extern void board_stack_paint(void); +extern size_t board_stack_used(void); +extern unsigned board_entropy_calls; + +static void hello_handler(cwist_http_request *req, cwist_http_response *res) { + (void)req; + cwist_http_response_set_body_ptr(res, "hello from cortex-m", 19); + cwist_http_header_add(&res->headers, "Content-Type", "text/plain"); +} + +static void free_body(const void *ptr, size_t len, void *ctx) { + (void)len; + (void)ctx; + free((void *)ptr); +} + +static void json_handler(cwist_http_request *req, cwist_http_response *res) { + (void)req; + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "board", "stm32"); + cJSON_AddNumberToObject(o, "uptime_ms", 1234); + char *s = cJSON_PrintUnformatted(o); + cwist_http_response_set_body_ptr_managed(res, s, strlen(s), free_body, NULL); + cwist_http_header_add(&res->headers, "Content-Type", "application/json"); + cJSON_Delete(o); +} + +static int check(const char *what, int ok) { + board_log(ok ? "PASS " : "FAIL "); + board_log(what); + board_log("\n"); + return !ok; +} + +static int probe(cwist_app *app, const char *path, const char *want) { + char req[96]; + snprintf(req, sizeof req, "GET %s HTTP/1.1\r\nHost: x\r\n\r\n", path); + char *res = NULL; + size_t res_len = 0; + if (cwist_app_dispatch_memory(app, req, strlen(req), &res, &res_len) != 0 || !res) + return check(path, 0); + int fail = 0; + char what[64]; + snprintf(what, sizeof what, "%s body", path); + fail |= check(what, memmem(res, res_len, want, strlen(want)) != NULL); + /* NUL bytes once leaked through an unset Date cache (no RTC). */ + snprintf(what, sizeof what, "%s has no NUL bytes", path); + fail |= check(what, memchr(res, 0, res_len) == NULL); +#ifdef BOARD_RTC + snprintf(what, sizeof what, "%s carries Date (RTC)", path); + fail |= check(what, memmem(res, res_len, "\r\nDate: ", 8) != NULL); +#else + snprintf(what, sizeof what, "%s omits Date (no RTC)", path); + fail |= check(what, memmem(res, res_len, "\r\nDate:", 7) == NULL); +#endif + free(res); + return fail; +} + +int main(void) { + board_stack_paint(); + cwist_app *app = cwist_app_create(); + if (!app) return check("cwist_app_create", 0); + cwist_app_get(app, "/hello", hello_handler); + cwist_app_get(app, "/json", json_handler); + + int fail = 0; + for (int i = 0; i < HELLO_ITERS; i++) { + fail |= probe(app, "/hello", "hello from cortex-m"); + /* the query string makes the parser build a seeded query map */ + fail |= probe(app, "/json?verbose=1", "\"board\":\"stm32\""); + fail |= probe(app, "/missing", " 404 "); + } + fail |= check("query-map hash seed drawn from cwist_port_entropy", board_entropy_calls > 0); + + /* No threads: async logging must decline and lines go out synchronously + * through newlib's _write (semihosting here, a UART on a board). */ + fail |= check("async logging declines without threads", !cwist_log_async_start(4096)); + cwist_logger *log = cwist_logger_create("fw"); + cwist_logger_set_colors(log, false); + cwist_logger_info(log, "served %d requests", HELLO_ITERS * 3); + cwist_logger_destroy(log); + fflush(NULL); + cwist_app_destroy(app); + + char buf[96]; + snprintf(buf, sizeof buf, "heap high water: %u bytes, stack used: %u bytes\n", + (unsigned)board_heap_high_water(), (unsigned)board_stack_used()); + board_log(buf); + board_log(fail ? "baremetal-smoke: FAIL\n" : "baremetal-smoke: PASS\n"); + return fail; +} diff --git a/tools/stm32-spike/fw/stm32h743.ld b/tests/baremetal/stm32h743.ld similarity index 85% rename from tools/stm32-spike/fw/stm32h743.ld rename to tests/baremetal/stm32h743.ld index be483494f..b5b7bab6e 100644 --- a/tools/stm32-spike/fw/stm32h743.ld +++ b/tests/baremetal/stm32h743.ld @@ -1,4 +1,4 @@ -/* Phase 0 spike: STM32H743-shaped memory map (2 MB flash, 512 KB AXI SRAM). +/* Bare-metal smoke: STM32H743-shaped memory map (2 MB flash, 512 KB AXI SRAM). * Stack lives in DTCM so its size is visible separately from heap/data. */ ENTRY(Reset_Handler) @@ -20,8 +20,6 @@ SECTIONS .init_array : { PROVIDE_HIDDEN(__init_array_start = .); KEEP(*(SORT(.init_array.*))) KEEP(*(.init_array*)) PROVIDE_HIDDEN(__init_array_end = .); } > FLASH .fini_array : { PROVIDE_HIDDEN(__fini_array_start = .); KEEP(*(.fini_array*)) PROVIDE_HIDDEN(__fini_array_end = .); } > FLASH - .tdata : { _stdata = .; *(.tdata*) _etdata = .; } > FLASH - .tbss : { _stbss = .; *(.tbss*) _etbss = .; } > FLASH _sidata = LOADADDR(.data); .data : { _sdata = .; *(.data*) _edata = .; } > AXI AT > FLASH diff --git a/tests/test_baremetal_port.c b/tests/test_baremetal_port.c new file mode 100644 index 000000000..2d0b8e569 --- /dev/null +++ b/tests/test_baremetal_port.c @@ -0,0 +1,145 @@ +/* Host-side tests for the bare-metal port layer (issue #447). + * + * Built without libcwist.a: siphash.c and src/sys/port/baremetal_time.c are + * compiled with -DCWIST_BAREMETAL, and baremetal_time.c's libc names are + * renamed (clock_gettime -> cwist_bm_clock_gettime, ...) so they do not + * collide with the host libc. This test supplies the firmware hooks from + * and checks how CWIST consumes them: + * + * - the siphash seed is drawn from cwist_port_entropy() and the process + * aborts when the hook fails, rather than degrading to a guessable seed; + * - CLOCK_MONOTONIC and nanosleep() run on cwist_port_monotonic_ns(); + * - timegm() matches known instants on both sides of the epoch. + * + * The cross-compiled firmware path is covered by `make baremetal-smoke`. */ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int cwist_bm_clock_gettime(clockid_t id, struct timespec *ts); +int cwist_bm_nanosleep(const struct timespec *req, struct timespec *rem); +time_t cwist_bm_timegm(struct tm *tm); + +/* --- firmware hooks ---------------------------------------------------- */ + +static unsigned g_entropy_calls; +static int g_entropy_fail; +static uint8_t g_entropy_byte; + +int cwist_port_entropy(void *buf, size_t len) { + g_entropy_calls++; + if (g_entropy_fail) return -1; + uint8_t *out = buf; + for (size_t i = 0; i < len; i++) out[i] = g_entropy_byte++; + return 0; +} + +static uint64_t g_now_ns; +static uint64_t g_tick_ns; /* added on every read, like a free-running timer */ + +uint64_t cwist_port_monotonic_ns(void) { + g_now_ns += g_tick_ns; + return g_now_ns; +} + +/* --- entropy ------------------------------------------------------------ */ + +static void test_seed_comes_from_hook(void) { + uint8_t a[16], b[16]; + g_entropy_calls = 0; + g_entropy_byte = 0; + cwist_generate_hash_seed(a); + assert(g_entropy_calls == 1); + g_entropy_byte = 0; + cwist_generate_hash_seed(b); + assert(g_entropy_calls == 2); + /* same hook bytes -> same key: nothing but the hook feeds the seed */ + assert(memcmp(a, b, sizeof(a)) == 0); + g_entropy_byte = 0x80; + cwist_generate_hash_seed(b); + assert(memcmp(a, b, sizeof(a)) != 0); +} + +static void test_seed_fails_closed(void) { + fflush(NULL); + pid_t pid = fork(); + assert(pid >= 0); + if (pid == 0) { + g_entropy_fail = 1; + uint8_t key[16]; + cwist_generate_hash_seed(key); + _exit(0); /* reaching here means a seed was invented */ + } + int status = 0; + assert(waitpid(pid, &status, 0) == pid); + assert(WIFSIGNALED(status) && WTERMSIG(status) == SIGABRT); +} + +/* --- monotonic clock ------------------------------------------------------- */ + +static void test_monotonic_clock(void) { + struct timespec ts; + g_tick_ns = 0; + g_now_ns = 5ull * 1000000000ull + 250; + assert(cwist_bm_clock_gettime(CLOCK_MONOTONIC, &ts) == 0); + assert(ts.tv_sec == 5 && ts.tv_nsec == 250); + + errno = 0; + assert(cwist_bm_clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ts) == -1 && errno == EINVAL); +} + +static void test_nanosleep_waits_on_hook(void) { + g_now_ns = 0; + g_tick_ns = 1000; /* 1 us per read */ + struct timespec req = {.tv_sec = 0, .tv_nsec = 50000}, rem = {1, 1}; + assert(cwist_bm_nanosleep(&req, &rem) == 0); + assert(g_now_ns >= 50000); + assert(rem.tv_sec == 0 && rem.tv_nsec == 0); + + struct timespec bad = {.tv_sec = 0, .tv_nsec = 1000000000L}; + errno = 0; + assert(cwist_bm_nanosleep(&bad, NULL) == -1 && errno == EINVAL); + g_tick_ns = 0; +} + +/* --- timegm --------------------------------------------------------------- */ + +static time_t utc(int y, int mon, int d, int h, int mi, int s) { + struct tm tm = {.tm_year = y - 1900, .tm_mon = mon - 1, .tm_mday = d, + .tm_hour = h, .tm_min = mi, .tm_sec = s}; + return cwist_bm_timegm(&tm); +} + +static void test_timegm(void) { + assert(utc(1970, 1, 1, 0, 0, 0) == 0); + assert(utc(2000, 2, 29, 12, 0, 0) == 951825600); + assert(utc(2026, 10, 25, 0, 0, 0) == 1792886400); + assert(utc(1969, 12, 31, 23, 59, 59) == -1); + assert(utc(1900, 3, 1, 0, 0, 0) == -2203891200); + /* agrees with the host libc across a range of days */ + for (int day = -800; day < 30000; day += 37) { + time_t t = (time_t)day * 86400 + 3723; + struct tm tm; + assert(gmtime_r(&t, &tm)); + assert(cwist_bm_timegm(&tm) == t); + } +} + +int main(void) { + test_seed_comes_from_hook(); + test_seed_fails_closed(); + test_monotonic_clock(); + test_nanosleep_waits_on_hook(); + test_timegm(); + printf("test_baremetal_port: all passed\n"); + return 0; +} diff --git a/tools/stm32-spike/fw/hello.c b/tools/stm32-spike/fw/hello.c deleted file mode 100644 index 40338c06c..000000000 --- a/tools/stm32-spike/fw/hello.c +++ /dev/null @@ -1,76 +0,0 @@ -/* Phase 0 spike firmware: build a CWIST app and drive it through - * cwist_app_dispatch_memory() only. No sockets, no listen loop. */ -#include -#include -#include -#include -#include - -extern size_t spike_heap_high_water(void); -extern void spike_log(const char *s); -extern void spike_stack_paint(void); -extern size_t spike_stack_used(void); - -static void hello_handler(cwist_http_request *req, cwist_http_response *res) { - (void)req; - cwist_http_response_set_body_ptr(res, "hello from cortex-m", 19); - cwist_http_header_add(&res->headers, "Content-Type", "text/plain"); -} - -static void free_body(const void *ptr, size_t len, void *ctx) { - (void)len; - (void)ctx; - free((void *)ptr); -} - -static void json_handler(cwist_http_request *req, cwist_http_response *res) { - (void)req; - cJSON *o = cJSON_CreateObject(); - cJSON_AddStringToObject(o, "board", "stm32"); - cJSON_AddNumberToObject(o, "uptime_ms", 1234); - char *s = cJSON_PrintUnformatted(o); - cwist_http_response_set_body_ptr_managed(res, s, strlen(s), free_body, NULL); - cwist_http_header_add(&res->headers, "Content-Type", "application/json"); - cJSON_Delete(o); -} - -static int probe(cwist_app *app, const char *req, const char *want) { - char *res = NULL; - size_t res_len = 0; - if (cwist_app_dispatch_memory(app, req, strlen(req), &res, &res_len) != 0 || !res) { - spike_log("dispatch failed\n"); - return 1; - } - int ok = memmem(res, res_len, want, strlen(want)) != NULL && memchr(res, 0, res_len) == NULL; - if (!ok) { spike_log("--- got:\n"); spike_log(res); spike_log("\n---\n"); } - spike_log(ok ? "PASS " : "FAIL "); - spike_log(req); - free(res); - return !ok; -} - -int main(void) { - spike_stack_paint(); - cwist_app *app = cwist_app_create(); - if (!app) { - spike_log("app_create failed\n"); - return 1; - } - cwist_app_get(app, "/hello", hello_handler); - cwist_app_get(app, "/json", json_handler); - int fail = 0; -#ifndef SPIKE_ITERS -#define SPIKE_ITERS 3 -#endif - for (int i = 0; i < SPIKE_ITERS; i++) { - fail |= probe(app, "GET /hello HTTP/1.1\r\nHost: x\r\n\r\n", "hello from cortex-m"); - fail |= probe(app, "GET /json HTTP/1.1\r\nHost: x\r\n\r\n", "\"board\":\"stm32\""); - fail |= probe(app, "GET /missing HTTP/1.1\r\nHost: x\r\n\r\n", "404"); - } - char buf[96]; - snprintf(buf, sizeof buf, "heap high water: %u bytes, stack used: %u bytes\n", - (unsigned)spike_heap_high_water(), (unsigned)spike_stack_used()); - spike_log(buf); - spike_log(fail ? "SPIKE: FAIL\n" : "SPIKE: PASS\n"); - return fail; -} diff --git a/tools/stm32-spike/fw/port.c b/tools/stm32-spike/fw/port.c deleted file mode 100644 index 6ffc1a8fe..000000000 --- a/tools/stm32-spike/fw/port.c +++ /dev/null @@ -1,233 +0,0 @@ -/* Phase 0 spike port layer: startup, heap accounting, semihosting log, and - * ENOSYS stubs for every POSIX symbol the core still references. */ -#include -#include -#include -#include -#include -#include - -/* ---- startup ---------------------------------------------------------- */ -extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss, _estack; -extern int main(void); -extern void __libc_init_array(void); -void spike_exit(int code); -void spike_tls_init(void); - -void Reset_Handler(void) { -#if defined(__ARM_FP) && !defined(SPIKE_QEMU_USER) - /* enable CP10/CP11 (FPU) before any float code runs */ - *(volatile uint32_t *)0xE000ED88 |= (0xFu << 20); - __asm volatile("dsb; isb"); -#endif -#ifndef SPIKE_QEMU_USER /* qemu-arm loads .data at its VMA already; the LMA copy is not mapped */ - memcpy(&_sdata, &_sidata, (size_t)((char *)&_edata - (char *)&_sdata)); -#endif - memset(&_sbss, 0, (size_t)((char *)&_ebss - (char *)&_sbss)); - spike_tls_init(); - __libc_init_array(); - spike_exit(main()); -} - -static void Default_Handler(void) { - for (;;) { - } -} - -__attribute__((section(".isr_vector"), used)) const void *spike_vectors[16] = { - &_estack, (void *)Reset_Handler, (void *)Default_Handler, (void *)Default_Handler, - (void *)Default_Handler, (void *)Default_Handler, (void *)Default_Handler, -}; - -/* ---- semihosting ------------------------------------------------------ */ -static int semihost(int op, const void *arg) { - register int r0 __asm("r0") = op; - register const void *r1 __asm("r1") = arg; - __asm volatile("bkpt 0xAB" : "+r"(r0) : "r"(r1) : "memory"); - return r0; -} - -void spike_log(const char *s) { semihost(0x04 /* SYS_WRITE0 */, s); } - -void spike_exit(int code) { - /* ADP_Stopped_ApplicationExit with exit code (SYS_EXIT_EXTENDED) */ - uint32_t args[2] = {0x20026, (uint32_t)code}; - semihost(0x20 /* SYS_EXIT_EXTENDED */, args); - for (;;) { - } -} - -/* ---- heap ------------------------------------------------------------- */ -extern char _heap_start, _heap_end; -static char *heap_brk = &_heap_start; -static size_t heap_high; - -void *_sbrk(ptrdiff_t incr) { - char *prev = heap_brk; - if (heap_brk + incr > &_heap_end) { - errno = ENOMEM; - return (void *)-1; - } - heap_brk += incr; - if ((size_t)(heap_brk - &_heap_start) > heap_high) heap_high = (size_t)(heap_brk - &_heap_start); - return prev; -} - -size_t spike_heap_high_water(void) { return heap_high; } - -/* ---- time: a fake monotonic tick (SysTick would back this on hardware) --- */ -static uint64_t fake_ns; -int clock_gettime(clockid_t id, struct timespec *ts) { - (void)id; - fake_ns += 1000; - ts->tv_sec = (time_t)(fake_ns / 1000000000u); - ts->tv_nsec = (long)(fake_ns % 1000000000u); - return 0; -} - -int nanosleep(const struct timespec *req, struct timespec *rem) { - (void)rem; - fake_ns += (uint64_t)req->tv_sec * 1000000000u + (uint64_t)req->tv_nsec; - return 0; -} - -int sched_yield(void) { return 0; } - -time_t timegm(struct tm *tm) { - /* days-from-civil, UTC */ - int y = tm->tm_year + 1900, m = tm->tm_mon + 1; - y -= m <= 2; - int era = (y >= 0 ? y : y - 399) / 400; - unsigned yoe = (unsigned)(y - era * 400); - unsigned doy = (153u * (unsigned)(m + (m > 2 ? -3 : 9)) + 2u) / 5u + (unsigned)tm->tm_mday - 1u; - unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy; - long days = (long)era * 146097L + (long)doe - 719468L; - return (time_t)(days * 86400L + tm->tm_hour * 3600L + tm->tm_min * 60L + tm->tm_sec); -} - -/* ---- ENOSYS stubs: reaching any of these on hardware is a port bug ------- */ -#define ENOSYS_STUB(ret, name, ...) \ - ret name(__VA_ARGS__) { \ - spike_log("ENOSYS: " #name "\n"); \ - errno = ENOSYS; \ - return (ret)-1; \ - } - -struct msghdr; -struct pollfd; -typedef struct spike_dir DIR; -struct dirent; - -ENOSYS_STUB(ssize_t, recv, int a, void *b, size_t c, int d) -ENOSYS_STUB(ssize_t, send, int a, const void *b, size_t c, int d) -ENOSYS_STUB(ssize_t, sendmsg, int a, const struct msghdr *b, int c) -ENOSYS_STUB(int, shutdown, int a, int b) -ENOSYS_STUB(int, poll, struct pollfd *a, unsigned b, int c) -ENOSYS_STUB(int, mprotect, void *a, size_t b, int c) -ENOSYS_STUB(int, munmap, void *a, size_t b) -ENOSYS_STUB(int, closedir, DIR *a) - -void *mmap(void *a, size_t b, int c, int d, int e, long f) { - (void)a; (void)b; (void)c; (void)d; (void)e; (void)f; - spike_log("ENOSYS: mmap\n"); - errno = ENOSYS; - return (void *)-1; -} -DIR *opendir(const char *p) { (void)p; errno = ENOSYS; return NULL; } -struct dirent *readdir(DIR *d) { (void)d; return NULL; } - -/* ---- thread-local storage: one static block, since there is one thread ---- */ -/* ARM EABI variant I: TP points at an 8-byte TCB, the TLS image follows. */ -extern char _stdata, _etdata, _stbss, _etbss; -#define SPIKE_TLS_BYTES 8192 -static uint8_t spike_tls[8 + SPIKE_TLS_BYTES] __attribute__((aligned(8))); - -void spike_tls_init(void) { - size_t td = (size_t)(&_etdata - &_stdata), tb = (size_t)(&_etbss - &_stbss); - if (td + tb > SPIKE_TLS_BYTES) { - spike_log("TLS image too large\n"); - spike_exit(2); - } - memcpy(spike_tls + 8, &_stdata, td); - memset(spike_tls + 8 + td, 0, tb); -} - -__attribute__((naked)) void *__aeabi_read_tp(void) { - /* must preserve every register except r0 */ - __asm volatile("ldr r0, =spike_tls\n bx lr"); -} - -/* ---- 64-bit atomics: Cortex-M has no LDREXD, so mask interrupts ---------- */ -static inline uint32_t irq_save(void) { - uint32_t p; - __asm volatile("mrs %0, primask\n cpsid i" : "=r"(p)::"memory"); - return p; -} -static inline void irq_restore(uint32_t p) { __asm volatile("msr primask, %0" ::"r"(p) : "memory"); } - -uint64_t __atomic_load_8(const volatile void *m, int o) { - (void)o; - uint32_t p = irq_save(); - uint64_t v = *(const volatile uint64_t *)m; - irq_restore(p); - return v; -} -void __atomic_store_8(volatile void *m, uint64_t v, int o) { - (void)o; - uint32_t p = irq_save(); - *(volatile uint64_t *)m = v; - irq_restore(p); -} -uint64_t __atomic_fetch_add_8(volatile void *m, uint64_t v, int o) { - (void)o; - uint32_t p = irq_save(); - uint64_t old = *(volatile uint64_t *)m; - *(volatile uint64_t *)m = old + v; - irq_restore(p); - return old; -} -_Bool __atomic_compare_exchange_8(volatile void *m, void *exp, uint64_t des, int s, int f) { - (void)s; - (void)f; - uint32_t p = irq_save(); - uint64_t cur = *(volatile uint64_t *)m; - _Bool ok = cur == *(uint64_t *)exp; - if (ok) *(volatile uint64_t *)m = des; - else *(uint64_t *)exp = cur; - irq_restore(p); - return ok; -} - -void _init(void) {} -void _fini(void) {} - -/* ---- stack high-water: paint below the current SP, scan after the run ---- */ -#define SPIKE_STACK_PAINT (48u * 1024u) -static uint32_t *paint_lo, *paint_hi; - -__attribute__((noinline)) void spike_stack_paint(void) { - uint32_t *sp; - __asm volatile("mov %0, sp" : "=r"(sp)); - paint_hi = sp - 64; /* leave this frame alone */ - paint_lo = paint_hi - SPIKE_STACK_PAINT / 4; - for (uint32_t *p = paint_lo; p < paint_hi; p++) *p = 0xDEADBEEFu; -} - -size_t spike_stack_used(void) { - uint32_t *p = paint_lo; - while (p < paint_hi && *p == 0xDEADBEEFu) p++; - return (size_t)((char *)paint_hi - (char *)p); -} - -/* ---- wall clock: an RTC would back this; without it time() is -1 and CWIST - * emits a Date header of NUL bytes (see the Phase 0 report). ---------------- */ -struct timeval; -int _gettimeofday(struct timeval *tv, void *tz) { - (void)tz; - struct { long long tv_sec; long tv_usec; } *t = (void *)tv; - if (t) { - t->tv_sec = 1792886400LL + (long long)(fake_ns / 1000000000u); /* 2026-10-25 */ - t->tv_usec = 0; - } - return 0; -} diff --git a/tools/stm32-spike/run.sh b/tools/stm32-spike/run.sh deleted file mode 100755 index c8c26b86e..000000000 --- a/tools/stm32-spike/run.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -# STM32 Phase 0 spike (issue #447): build the socket-free CWIST core for -# Cortex-M7 with arm-none-eabi-gcc, link a hello firmware that is driven only -# through cwist_app_dispatch_memory(), report its size, and run it under -# qemu-arm (user mode, -cpu cortex-m7) with semihosting output. -# -# Needs: arm-none-eabi-gcc (newlib-nano), lib/cjson, lib/libttak and -# lib/mbedtls checked out (headers only for mbedtls), optionally qemu-arm. -# usage: tools/stm32-spike/run.sh [iterations] -set -u -ROOT=$(cd "$(dirname "$0")/../.." && pwd) -HERE=$ROOT/tools/stm32-spike -OUT=${OUT:-$ROOT/.stm32-spike} -ITERS=${1:-3} -CC=arm-none-eabi-gcc -ARCH=(-mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard) -# -D__wasi__ selects the existing "no fork/signals/rlimits/threads" guards; -# with no in the shim, wasi.h also defines CWIST_WASI_NO_SOCKETS. -CFLAGS=(-std=c17 -Os "${ARCH[@]}" --specs=nano.specs -ffunction-sections -fdata-sections - -fstack-usage -D_GNU_SOURCE -D_XOPEN_SOURCE=700 - -I"$ROOT/include" -I"$ROOT/lib" -I"$ROOT/lib/cjson" - -I"$ROOT/lib/libttak/include" -I"$ROOT/src/security/tls/shim" - -I"$ROOT/lib/mbedtls/include" -I"$ROOT/lib/sqlite3" - -I"$HERE/shim" # after libttak: ttak/compat/pthread.h include_next-s into it - -include "$HERE/shim/spike_prelude.h" -D__wasi__ -DCWIST_TLS_MBEDTLS) -# The WASIP2 source set minus sqlite/db (CWIST_DB=0), the WASI compat layer -# and the test client. -SRCS=( -src/core/sstring/sstring.c src/core/seq/seq.c src/core/seq/seq_auth.c -src/sys/err/error.c src/net/http/http.c src/net/http/async_tls.c src/net/http/mux.c -src/net/http/query.c src/net/http/cookie.c src/net/http/session.c src/sys/app/app.c -src/sys/app/middleware.c src/sys/app/config.c src/sys/app/logger.c src/sys/app/log_async.c -src/sys/app/shutdown.c src/sys/app/big_dumb_reply.c src/core/siphash/siphash.c -src/core/utils/json_builder.c src/core/utils/json_heal.c src/core/utils/zod.c -src/core/template/template.c src/core/html/builder.c src/core/html/css_composer.c -src/core/html/component.c src/net/http/html_response.c src/sys/app/assets.c -src/core/validation/bind.c src/core/mem/alloc.c src/core/mem/arena.c lib/cjson/cJSON.c -src/sys/metrics/metrics.c src/net/http/writer_fast.c lib/libttak/src/net/lattice.c -lib/libttak/src/shared/shared.c lib/libttak/src/timing/deadline.c src/core/log/log.c -lib/libttak/src/net/mols_control.c src/net/http/async.c src/core/mem/gc.c -lib/libttak/src/mem/epoch.c src/sys/sys_info.c lib/libttak/src/mem/mem.c -lib/libttak/src/mem/fastpath.c lib/libttak/src/mem/owner.c lib/libttak/src/mem/abstract.c -) - -rm -rf "$OUT"; mkdir -p "$OUT/obj" "$OUT/log" -fail=0 -for s in "${SRCS[@]}"; do - o="$OUT/obj/${s//\//_}"; o="${o%.c}.o" - if ! (cd "$ROOT" && $CC "${CFLAGS[@]}" -c "$s" -o "$o") >"$OUT/log/${s//\//_}.log" 2>&1; then - echo "FAIL $s (see $OUT/log/${s//\//_}.log)"; fail=$((fail+1)) - fi -done -echo "core: $(( ${#SRCS[@]} - fail ))/${#SRCS[@]} translation units compiled" -[ $fail -eq 0 ] || exit 1 -arm-none-eabi-ar rcs "$OUT/libcwist_cm7.a" "$OUT"/obj/*.o - -# qemu-arm (user mode) loads .data at its VMA and owns the FPU/SP setup. -$CC "${CFLAGS[@]}" -DSPIKE_ITERS="$ITERS" -c "$HERE/fw/hello.c" -o "$OUT/hello.o" || exit 1 -$CC -std=c17 -Os "${ARCH[@]}" -ffunction-sections -fdata-sections -fstack-usage \ - -Wno-builtin-declaration-mismatch -DSPIKE_QEMU_USER -c "$HERE/fw/port.c" -o "$OUT/port.o" || exit 1 -$CC "${ARCH[@]}" --specs=nano.specs --specs=nosys.specs -nostartfiles -T "$HERE/fw/stm32h743.ld" \ - -Wl,--gc-sections -Wl,--no-warn-rwx-segments -Wl,-Map="$OUT/hello.map" -o "$OUT/hello.elf" \ - "$OUT/hello.o" "$OUT/port.o" "$OUT/libcwist_cm7.a" -lm 2>"$OUT/link.log" || { cat "$OUT/link.log"; exit 1; } -arm-none-eabi-size -A "$OUT/hello.elf" | grep -vE "debug|comment|ARM.attr|^$" - -if command -v qemu-arm >/dev/null; then - timeout 120 qemu-arm -cpu cortex-m7 "$OUT/hello.elf" | grep -vE "^Host|^$" - exit "${PIPESTATUS[0]}" -fi -echo "qemu-arm not found: built only" diff --git a/tools/stm32-spike/shim/arpa/inet.h b/tools/stm32-spike/shim/arpa/inet.h deleted file mode 100644 index fd9ba96d9..000000000 --- a/tools/stm32-spike/shim/arpa/inet.h +++ /dev/null @@ -1,2 +0,0 @@ -/* spike: empty socket header stub */ -#include diff --git a/tools/stm32-spike/shim/dirent.h b/tools/stm32-spike/shim/dirent.h deleted file mode 100644 index dce5c6bed..000000000 --- a/tools/stm32-spike/shim/dirent.h +++ /dev/null @@ -1,9 +0,0 @@ -/* spike: newlib's #errors on arm-none-eabi */ -#ifndef SPIKE_DIRENT_H -#define SPIKE_DIRENT_H -typedef struct spike_dir DIR; -struct dirent { char d_name[256]; }; -DIR *opendir(const char *); -struct dirent *readdir(DIR *); -int closedir(DIR *); -#endif diff --git a/tools/stm32-spike/shim/netinet/in.h b/tools/stm32-spike/shim/netinet/in.h deleted file mode 100644 index fd9ba96d9..000000000 --- a/tools/stm32-spike/shim/netinet/in.h +++ /dev/null @@ -1,2 +0,0 @@ -/* spike: empty socket header stub */ -#include diff --git a/tools/stm32-spike/shim/netinet/tcp.h b/tools/stm32-spike/shim/netinet/tcp.h deleted file mode 100644 index fd9ba96d9..000000000 --- a/tools/stm32-spike/shim/netinet/tcp.h +++ /dev/null @@ -1,2 +0,0 @@ -/* spike: empty socket header stub */ -#include diff --git a/tools/stm32-spike/shim/poll.h b/tools/stm32-spike/shim/poll.h deleted file mode 100644 index ffee1ab7b..000000000 --- a/tools/stm32-spike/shim/poll.h +++ /dev/null @@ -1,13 +0,0 @@ -/* spike: poll() stub header */ -#ifndef SPIKE_POLL_H -#define SPIKE_POLL_H -struct pollfd { int fd; short events; short revents; }; -typedef unsigned int nfds_t; -#define POLLIN 1 -#define POLLOUT 4 -#define POLLERR 8 -#define POLLHUP 16 -#define POLLPRI 2 -#define POLLNVAL 32 -int poll(struct pollfd *, nfds_t, int); -#endif diff --git a/tools/stm32-spike/shim/pthread.h b/tools/stm32-spike/shim/pthread.h deleted file mode 100644 index 6d323fb7c..000000000 --- a/tools/stm32-spike/shim/pthread.h +++ /dev/null @@ -1,179 +0,0 @@ -/* spike shim: single-threaded pthread stubs (lifted from libttak WASI fallback) */ -#ifndef SPIKE_PTHREAD_H -#define SPIKE_PTHREAD_H -#include -#include -/* newlib sys/types.h already declares pthread types; rename ours */ -#define pthread_attr_t spike_pthread_attr_t -#define pthread_t spike_pthread_t -#define pthread_key_t spike_pthread_key_t -#define pthread_cond_t spike_pthread_cond_t -#define pthread_condattr_t spike_pthread_condattr_t -#define pthread_mutex_t spike_pthread_mutex_t -#define pthread_mutexattr_t spike_pthread_mutexattr_t -#define pthread_once_t spike_pthread_once_t -#define pthread_rwlock_t spike_pthread_rwlock_t -#define pthread_rwlockattr_t spike_pthread_rwlockattr_t -/* ----------------------------------------------------------------------- - * WASI (preview1) fallback: the sysroot ships no . Searching - * host include directories would leak glibc headers, so this branch - * provides self-contained stubs. Synchronisation primitives cannot - * contend on a single-threaded host: mutexes track state only to catch - * misuse, cond vars never block, and pthread_create fails with ENOSYS. - * - * Note: sysroots whose defines pthread types (musl-style - * bits/alltypes.h, as in wasi-libc) cannot use this fallback - the - * typedefs would collide. Such sysroots are expected to provide their - * own , which is consumed via include_next above. - * --------------------------------------------------------------------- */ - -#include /* EBUSY / EDEADLK / ENOSYS */ -#include /* size_t */ - -typedef int pthread_mutex_t; -typedef int pthread_cond_t; -typedef int pthread_t; -typedef int pthread_once_t; -typedef int pthread_attr_t; -typedef int pthread_mutexattr_t; -typedef int pthread_condattr_t; -typedef int pthread_rwlockattr_t; -typedef struct { int exclusive; } pthread_rwlock_t; - -#define PTHREAD_MUTEX_INITIALIZER 0 -#define PTHREAD_ONCE_INIT 0 -#define PTHREAD_RWLOCK_INITIALIZER { 0 } -#define PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 1 /* stub value */ - -static __inline int pthread_mutex_init(pthread_mutex_t *m, - const pthread_mutexattr_t *attr) { - (void)attr; *m = 0; return 0; -} -static __inline int pthread_mutex_lock(pthread_mutex_t *m) { - if (*m) return EDEADLK; - *m = 1; return 0; -} -static __inline int pthread_mutex_trylock(pthread_mutex_t *m) { - if (*m) return EBUSY; - *m = 1; return 0; -} -static __inline int pthread_mutex_unlock(pthread_mutex_t *m) { - *m = 0; return 0; -} -static __inline int pthread_mutex_destroy(pthread_mutex_t *m) { - (void)m; return 0; -} - -static __inline int pthread_attr_init(pthread_attr_t *attr) { - (void)attr; return 0; -} -static __inline int pthread_attr_destroy(pthread_attr_t *attr) { - (void)attr; return 0; -} -static __inline int pthread_attr_setstacksize(pthread_attr_t *attr, - size_t stacksize) { - (void)attr; (void)stacksize; return 0; -} -static __inline int pthread_attr_getstacksize(const pthread_attr_t *attr, - size_t *stacksize) { - (void)attr; if (stacksize) *stacksize = 0; return 0; -} - -static __inline int pthread_cond_init(pthread_cond_t *c, - const pthread_condattr_t *attr) { - (void)c; (void)attr; return 0; -} -static __inline int pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m) { - /* Single-threaded host: never block; the caller re-checks state. */ - (void)c; (void)m; return 0; -} -static __inline int pthread_cond_timedwait(pthread_cond_t *c, - pthread_mutex_t *m, - const struct timespec *abstime) { - (void)c; (void)m; (void)abstime; return 0; -} -static __inline int pthread_cond_signal(pthread_cond_t *c) { - (void)c; return 0; -} -static __inline int pthread_cond_broadcast(pthread_cond_t *c) { - (void)c; return 0; -} -static __inline int pthread_cond_destroy(pthread_cond_t *c) { - (void)c; return 0; -} - -static __inline int pthread_rwlock_init(pthread_rwlock_t *rw, - const pthread_rwlockattr_t *attr) { - (void)attr; rw->exclusive = 0; return 0; -} -static __inline int pthread_rwlock_rdlock(pthread_rwlock_t *rw) { - if (rw->exclusive) return EDEADLK; - return 0; -} -static __inline int pthread_rwlock_wrlock(pthread_rwlock_t *rw) { - if (rw->exclusive) return EDEADLK; - rw->exclusive = 1; return 0; -} -static __inline int pthread_rwlock_unlock(pthread_rwlock_t *rw) { - rw->exclusive = 0; return 0; -} -static __inline int pthread_rwlock_destroy(pthread_rwlock_t *rw) { - (void)rw; return 0; -} - -static __inline int pthread_rwlockattr_init(pthread_rwlockattr_t *a) { - (void)a; return 0; -} -static __inline int pthread_rwlockattr_destroy(pthread_rwlockattr_t *a) { - (void)a; return 0; -} -static __inline int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *a, - int pref) { - (void)a; (void)pref; return 0; -} - -static __inline int pthread_once(pthread_once_t *once_ctrl, - void (*init_routine)(void)) { - /* States mirror the MSVC branch: 0=new, 1=running, 2=done. A second - * call cannot arrive mid-initialisation on a single-threaded host. */ - if (*once_ctrl != 2) { - *once_ctrl = 1; - init_routine(); - *once_ctrl = 2; - } - return 0; -} - -static __inline int pthread_create(pthread_t *thread, - const void *attr, - void *(*start_routine)(void *), - void *arg) { - (void)attr; (void)start_routine; (void)arg; - if (thread) *thread = 0; - return ENOSYS; -} -static __inline int pthread_join(pthread_t thread, void **retval) { - (void)thread; (void)retval; return 0; -} -static __inline pthread_t pthread_self(void) { - return 0; -} - - -/* spike additions: TLS keys are plain slots on a single thread */ -#ifndef PTHREAD_COND_INITIALIZER -#define PTHREAD_COND_INITIALIZER 0 -#endif -typedef int spike_pthread_key_t; -static void *spike_key_slots[64]; -static int spike_key_next; -static __inline int pthread_key_create(pthread_key_t *k, void (*d)(void *)) { (void)d; if (spike_key_next >= 64) return 11; *k = spike_key_next++; return 0; } -static __inline void *pthread_getspecific(pthread_key_t k) { return spike_key_slots[k]; } -static __inline int pthread_setspecific(pthread_key_t k, const void *v) { spike_key_slots[k] = (void *)v; return 0; } -static __inline int pthread_equal(pthread_t a, pthread_t b) { return a == b; } -static __inline int pthread_attr_setdetachstate(pthread_attr_t *a, int s) { (void)a; (void)s; return 0; } -static __inline int pthread_detach(pthread_t t) { (void)t; return 0; } -#ifndef PTHREAD_CREATE_DETACHED -#define PTHREAD_CREATE_DETACHED 1 -#endif -#endif diff --git a/tools/stm32-spike/shim/spike_prelude.h b/tools/stm32-spike/shim/spike_prelude.h deleted file mode 100644 index 3f8f75e97..000000000 --- a/tools/stm32-spike/shim/spike_prelude.h +++ /dev/null @@ -1,12 +0,0 @@ -/* spike: newlib hides CLOCK_MONOTONIC without _POSIX_MONOTONIC_CLOCK */ -#include -#ifndef CLOCK_MONOTONIC -#define CLOCK_MONOTONIC ((clockid_t)4) -#endif -#ifndef CLOCK_MONOTONIC_RAW -#define CLOCK_MONOTONIC_RAW ((clockid_t)4) -#endif -int clock_gettime(clockid_t, struct timespec *); -int sched_yield(void); -time_t timegm(struct tm *); -int nanosleep(const struct timespec *, struct timespec *); diff --git a/tools/stm32-spike/shim/sys/mman.h b/tools/stm32-spike/shim/sys/mman.h deleted file mode 100644 index 0459a2101..000000000 --- a/tools/stm32-spike/shim/sys/mman.h +++ /dev/null @@ -1,15 +0,0 @@ -#ifndef SPIKE_SYS_MMAN_H -#define SPIKE_SYS_MMAN_H -#include -#define PROT_READ 1 -#define PROT_WRITE 2 -#define MAP_PRIVATE 2 -#define MAP_ANONYMOUS 0x20 -#define MAP_ANON MAP_ANONYMOUS -#define MAP_FAILED ((void *)-1) -void *mmap(void *, size_t, int, int, int, long); -int munmap(void *, size_t); - -int mprotect(void *, size_t, int); -#define PROT_NONE 0 -#endif diff --git a/tools/stm32-spike/shim/sys/socket.h b/tools/stm32-spike/shim/sys/socket.h deleted file mode 100644 index c5312c444..000000000 --- a/tools/stm32-spike/shim/sys/socket.h +++ /dev/null @@ -1,19 +0,0 @@ -/* spike: socket declarations only; every call is a link-time ENOSYS stub */ -#ifndef SPIKE_SYS_SOCKET_H -#define SPIKE_SYS_SOCKET_H -#include -#include -#include -typedef unsigned int socklen_t; -struct sockaddr { unsigned short sa_family; char sa_data[14]; }; -struct msghdr { void *msg_name; socklen_t msg_namelen; struct iovec *msg_iov; int msg_iovlen; void *msg_control; socklen_t msg_controllen; int msg_flags; }; -#define MSG_DONTWAIT 0x40 -#define MSG_NOSIGNAL 0x4000 -#define SHUT_RD 0 -#define SHUT_WR 1 -#define SHUT_RDWR 2 -ssize_t recv(int, void *, size_t, int); -ssize_t send(int, const void *, size_t, int); -ssize_t sendmsg(int, const struct msghdr *, int); -int shutdown(int, int); -#endif diff --git a/tools/stm32-spike/shim/sys/uio.h b/tools/stm32-spike/shim/sys/uio.h deleted file mode 100644 index 71ab0efff..000000000 --- a/tools/stm32-spike/shim/sys/uio.h +++ /dev/null @@ -1,5 +0,0 @@ -#ifndef SPIKE_SYS_UIO_H -#define SPIKE_SYS_UIO_H -#include -struct iovec { void *iov_base; size_t iov_len; }; -#endif From b11a06ee116e9266cc0a1475699870d1a3428e80 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Mon, 26 Oct 2026 19:54:57 +0900 Subject: [PATCH 4/4] test(http3): add connection migration checks (#450) (cherry picked from commit cb9d8d45b57706fbcf74cf20beea67f3da105e89) --- .github/workflows/interop.yml | 23 +++ .gitignore | 1 + Makefile | 15 +- docs/http3-migration.md | 81 +++++++++ scripts/ci/http3_migration_e2e.py | 268 ++++++++++++++++++++++++++++++ tests/http3_migration_server.c | 58 +++++++ 6 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 docs/http3-migration.md create mode 100755 scripts/ci/http3_migration_e2e.py create mode 100644 tests/http3_migration_server.c diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 7a88456d7..cc49d56b1 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -57,3 +57,26 @@ jobs: - name: Echo session, stream, datagram, refusal and close run: make -j"$(nproc)" webtransport-e2e CWIST_WEBTRANSPORT=server PYTHON="$RUNNER_TEMP/wt/bin/python" + + http3-migration: + name: HTTP/3 connection migration (aioquic) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake libcurl4-openssl-dev libnghttp2-dev libbrotli-dev libzstd-dev + + - name: Install aioquic + run: | + python3 -m venv "$RUNNER_TEMP/h3-migration" + "$RUNNER_TEMP/h3-migration/bin/pip" install aioquic==1.4.0 + + - name: Test requests through 400 address changes + run: make -j"$(nproc)" http3-migration-e2e CWIST_WEBRTC=0 PYTHON="$RUNNER_TEMP/h3-migration/bin/python" diff --git a/.gitignore b/.gitignore index 44b16a9f8..504264751 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,7 @@ test_grpc_client test_grpc_stream test_http2 test_http3 +/http3_migration_server test_http_chunked test_http_fairness test_http_pipeline diff --git a/Makefile b/Makefile index fdf78100e..56a0d9ed8 100644 --- a/Makefile +++ b/Makefile @@ -1983,7 +1983,7 @@ clean: @echo "Cleaning up build artifacts..." rm -f $(OBJS) $(LIB_NAME) $(PC_FILE) rm -rf include/cwist/vendor - rm -f $(TEST_TARGETS) + rm -f $(TEST_TARGETS) http3_migration_server rm -rf $(TUTORIALS_BUILD_DIR) rm -f $(CJSON_DIR)/cJSON.o $(CJSON_LIB) rm -rf $(URIPARSER_BUILD_DIR) @@ -2535,6 +2535,19 @@ webtransport_echo_server: endif PYTHON ?= python3 +HTTP3_MIGRATION_ARGS ?= +.PHONY: http3-migration-e2e +ifeq ($(CWIST_HTTP3),1) +http3_migration_server: $(LIB_NAME) tests/http3_migration_server.c + $(CC) $(CFLAGS) -o $@ tests/http3_migration_server.c $(LIB_NAME) $(LIBS) + +http3-migration-e2e: http3_migration_server + $(PYTHON) scripts/ci/http3_migration_e2e.py ./http3_migration_server $(HTTP3_MIGRATION_ARGS) +else +http3-migration-e2e: + @echo "CWIST_HTTP3=0: skipping $@" +endif + webtransport-e2e: webtransport_echo_server ifeq ($(CWIST_HAVE_LSQUIC_WT),1) @echo "webtransport-e2e: skipped: lib/lsquic has lsquic_wt.h, whose session API negotiates newer WebTransport drafts than aioquic's draft-02" diff --git a/docs/http3-migration.md b/docs/http3-migration.md new file mode 100644 index 000000000..aaccff3c5 --- /dev/null +++ b/docs/http3-migration.md @@ -0,0 +1,81 @@ +# HTTP/3 connection migration + +## Result for issue #407 + +The HTTP/3 server uses the connection and stream contexts that lsquic provides. +It does not use a client address as the key for request state. +`cwist_http3_server_loop()` passes each packet's peer address to lsquic. +The output callback sends each packet to the address that lsquic selects. +lsquic performs path validation and selects the connection by its Connection ID. + +The address-change test passes with the existing HTTP/3 implementation on `dev`. +No change to the transport implementation is necessary for these test cases. +The missing part was an end-to-end test and a CI check. +The test does not prove that all mobile network changes are supported. + +## Run the test + +Install Python 3.11 or later and `aioquic==1.4.0` in a virtual environment. +Then run: + +```sh +make -j4 http3-migration-e2e PYTHON=/path/to/venv/bin/python +``` + +The fixture uses an ephemeral certificate and a loopback UDP listener. +It does not need a certificate file, an external service, or root access. +Certificate verification is disabled only in the test client. + +The default test uses `127.0.0.1` and `127.0.0.2` as source addresses. +Linux permits both addresses without a separate interface alias. +On macOS, select a second IPv4 address that is already configured on this host: + +```sh +make http3-migration-e2e PYTHON=/path/to/venv/bin/python \ + HTTP3_MIGRATION_ARGS="--migration-host " +``` + +Do not use an address from another host. +The test fails if the second address cannot be bound. It does not skip the test. +With `CWIST_HTTP3=0`, Make skips this target because HTTP/3 is not available. + +## Test coverage + +Two clients connect through separate UDP proxies to one CWIST HTTP/3 server. +Each client first completes a request to identify its server-side connection. +Each client then performs 200 address changes: + +1. Open three POST streams. Send a different binary prefix on each stream. +2. Keep each request open without FIN. Wait for a QUIC PING acknowledgment. +3. Change the proxy's source socket. Close the old socket. +4. Alternate port-only changes with IP address changes. +5. On the first IP change, wait for a spare server Connection ID. + Change the Connection ID and check that it actually changed. +6. Wait for a PING acknowledgment through the new socket. +7. Send the remaining binary data and FIN on each original stream. +8. Check the HTTP status, the complete response body, and the connection identity. + +The default run checks 400 address changes and 1,200 in-flight requests. +The two initial requests are additional checks. +The two clients must keep different server-side connection identities. +Each client must keep its original identity through all address changes. +The server must stop with exit code zero. + +The test uses the server's Connection ID only through lsquic. +The fixture's response header identifies the `lsquic_conn_t` object, not a wire CID. +A wire CID can change without a change to that object. + +The test waits for a spare Connection ID because lsquic limits CID issue rate. +Calling aioquic's `change_connection_id()` without a spare CID has no effect. +The script checks private aioquic CID fields for this reason. CI pins aioquic 1.4.0. + +Use `HTTP3_MIGRATION_ARGS="--rounds N"` to change the number of test rounds. +The script limits the complete run to 120 seconds and each PING or response to 10 seconds. +This is a bounded migration-churn test, not a long-duration production soak test. + +## Limits + +The test covers IPv4 NAT rebinding and an IPv4 source-address change. +It does not cover IPv4-to-IPv6 changes, packet loss, a server address change, +a load balancer, or a real Wi-Fi-to-cellular transition. +`Interop / HTTP/3 connection migration (aioquic)` runs the default test on Linux CI. diff --git a/scripts/ci/http3_migration_e2e.py b/scripts/ci/http3_migration_e2e.py new file mode 100755 index 000000000..e02078323 --- /dev/null +++ b/scripts/ci/http3_migration_e2e.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Test HTTP/3 requests while a UDP proxy changes the client address. + +Requires Python 3.11 or later and aioquic==1.4.0. No root access is necessary. +On macOS, use --migration-host with a second configured local IPv4 address. +""" + +import argparse +import asyncio +import os +import ssl +import sys + +from aioquic.asyncio.client import connect +from aioquic.asyncio.protocol import QuicConnectionProtocol +from aioquic.h3.connection import H3Connection +from aioquic.h3.events import DataReceived, HeadersReceived +from aioquic.quic.configuration import QuicConfiguration +from aioquic.quic.events import ConnectionTerminated + +TIMEOUT = 10 +STREAMS_PER_ROUND = 3 + + +class Client(QuicConnectionProtocol): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.h3 = H3Connection(self._quic) + self.responses = {} + self.headers = {} + self.bodies = {} + self.peer_cid_ready = asyncio.Event() + + def datagram_received(self, data, addr): + super().datagram_received(data, addr) + if self._quic._peer_cid_available: + self.peer_cid_ready.set() + + def quic_event_received(self, event): + if isinstance(event, ConnectionTerminated): + for response in self.responses.values(): + if not response.done(): + response.set_exception(RuntimeError(str(event))) + for item in self.h3.handle_event(event): + if isinstance(item, HeadersReceived): + self.headers[item.stream_id] = dict(item.headers) + elif isinstance(item, DataReceived): + self.bodies[item.stream_id].extend(item.data) + if isinstance(item, (HeadersReceived, DataReceived)) and item.stream_ended: + response = self.responses[item.stream_id] + if not response.done(): + response.set_result( + ( + self.headers[item.stream_id], + bytes(self.bodies[item.stream_id]), + ) + ) + + def start_request(self, prefix): + stream = self._quic.get_next_available_stream_id() + response = asyncio.get_running_loop().create_future() + self.responses[stream] = response + self.bodies[stream] = bytearray() + self.h3.send_headers( + stream, + [ + (b":method", b"POST"), + (b":scheme", b"https"), + (b":authority", b"localhost"), + (b":path", b"/echo"), + ], + ) + self.h3.send_data(stream, prefix, end_stream=False) + self.transmit() + return stream, response + + def finish_request(self, stream, suffix): + self.h3.send_data(stream, suffix, end_stream=True) + self.transmit() + + async def check_response(self, stream, expected_body, connection=None): + response = self.responses[stream] + headers, body = await asyncio.wait_for(response, TIMEOUT) + del self.responses[stream], self.headers[stream], self.bodies[stream] + assert headers[b":status"] == b"200", headers + assert body == expected_body, "request data changed during migration" + current = headers[b"x-test-connection"] + if connection is not None: + assert current == connection, "migration created another connection" + return current + + def clear_responses(self): + for response in self.responses.values(): + if response.done() and not response.cancelled(): + response.exception() + else: + response.cancel() + self.responses.clear() + self.headers.clear() + self.bodies.clear() + + +class Upstream(asyncio.DatagramProtocol): + def __init__(self, nat): + self.nat = nat + + def datagram_received(self, data, addr): + if self.nat.client is not None: + self.nat.front.sendto(data, self.nat.client) + + def error_received(self, exc): + self.nat.errors.append(str(exc)) + + +class NAT(asyncio.DatagramProtocol): + """Keep the front address fixed and change the server-side source socket.""" + + def __init__(self, server_port): + self.server = ("127.0.0.1", server_port) + self.client = None + self.front = None + self.upstream = None + self.errors = [] + self.addresses = [] + + def connection_made(self, transport): + self.front = transport + + def datagram_received(self, data, addr): + self.client = addr + self.upstream.sendto(data) + + async def rebind(self, host): + transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( + lambda: Upstream(self), local_addr=(host, 0), remote_addr=self.server + ) + address = transport.get_extra_info("sockname") + if self.addresses: + assert address != self.addresses[-1], "NAT source address did not change" + self.addresses.append(address) + old = self.upstream + self.upstream = transport + if old is not None: + old.close() + + async def start(self): + await self.rebind("127.0.0.1") + await asyncio.get_running_loop().create_datagram_endpoint( + lambda: self, local_addr=("127.0.0.1", 0) + ) + return self.front.get_extra_info("sockname")[1] + + def close(self): + if self.front is not None: + self.front.close() + if self.upstream is not None: + self.upstream.close() + + +async def exercise(server_port, rounds, label, migration_host): + nat = NAT(server_port) + try: + port = await nat.start() + config = QuicConfiguration(is_client=True, alpn_protocols=["h3"]) + config.verify_mode = ssl.CERT_NONE + async with connect( + "127.0.0.1", port, configuration=config, create_protocol=Client + ) as client: + try: + # Record the server connection before the first address change. + stream, _ = client.start_request(label.encode()) + client.finish_request(stream, b"/baseline") + connection = await client.check_response( + stream, f"{label}/baseline".encode() + ) + for turn in range(rounds): + requests = [] + for index in range(STREAMS_PER_ROUND): + prefix = ( + f"{label}/{turn}/{index}:".encode() + bytes(range(256)) * 16 + ) + suffix = os.urandom(8192) + stream, response = client.start_request(prefix) + requests.append((stream, response, prefix, suffix)) + await asyncio.wait_for(client.ping(), TIMEOUT) + assert all(not response.done() for _, response, _, _ in requests), ( + "server completed a request before FIN" + ) + # Alternate port-only changes with IP address changes. + host = nat.addresses[-1][0] + if turn % 2: + host = migration_host if host == "127.0.0.1" else "127.0.0.1" + await nat.rebind(host) + if turn == 1: + # lsquic limits the rate of new Connection IDs. + await asyncio.wait_for(client.peer_cid_ready.wait(), TIMEOUT) + old_cid = client._quic._peer_cid.cid + client.change_connection_id() + assert client._quic._peer_cid.cid != old_cid, ( + "CID did not change" + ) + await asyncio.wait_for(client.ping(), TIMEOUT) + for stream, _, _, suffix in requests: + client.finish_request(stream, suffix) + for stream, _, prefix, suffix in requests: + await client.check_response(stream, prefix + suffix, connection) + assert not nat.errors, nat.errors + print( + f"[PASS] {label}: {rounds} migrations, " + f"{STREAMS_PER_ROUND * rounds} complete in-flight requests" + ) + return connection + finally: + client.clear_responses() + finally: + nat.close() + + +async def run(server, rounds, migration_host): + proc = await asyncio.create_subprocess_exec(server, stdout=asyncio.subprocess.PIPE) + try: + line = await asyncio.wait_for(proc.stdout.readline(), TIMEOUT) + assert line.startswith(b"READY "), line + port = int(line.split()[1]) + async with asyncio.TaskGroup() as group: + clients = [ + group.create_task(exercise(port, rounds, label, migration_host)) + for label in ("client-a", "client-b") + ] + ids = [task.result() for task in clients] + assert ids[0] != ids[1], "two clients share connection state" + assert proc.returncode is None, f"server exited with {proc.returncode}" + print("[PASS] concurrent clients retain separate connection state") + finally: + if proc.returncode is None: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), TIMEOUT) + except TimeoutError: + proc.kill() + await proc.wait() + raise + assert proc.returncode == 0, f"server exited with {proc.returncode}" + print("[PASS] server stopped without a process error") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("server") + parser.add_argument("--rounds", type=int, default=200) + parser.add_argument( + "--migration-host", + default="127.0.0.2", + help="second configured local IPv4 address", + ) + args = parser.parse_args() + if args.rounds < 2: + parser.error("--rounds must be at least 2") + if args.migration_host == "127.0.0.1": + parser.error("--migration-host must differ from 127.0.0.1") + asyncio.run( + asyncio.wait_for(run(args.server, args.rounds, args.migration_host), 120) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/http3_migration_server.c b/tests/http3_migration_server.c new file mode 100644 index 000000000..3cb99a209 --- /dev/null +++ b/tests/http3_migration_server.c @@ -0,0 +1,58 @@ +/** @file http3_migration_server.c + * @brief HTTP/3 echo server for the connection migration test. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static cwist_http3_context *server_ctx; +static lsquic_conn_t *connections[16]; +static unsigned connection_count; + +static void stop_server(int sig) { + (void)sig; + if (server_ctx) __atomic_store_n(&server_ctx->running, 0, __ATOMIC_RELEASE); +} + +static void echo_request(void *user_ctx, cwist_http_request *req, cwist_http_response *res) { + (void)user_ctx; + lsquic_conn_t *conn = lsquic_stream_conn((lsquic_stream_t *)req->private_data); + unsigned id; + for (id = 0; id < connection_count; ++id) { + if (connections[id] == conn) break; + } + assert(id < sizeof(connections) / sizeof(connections[0])); + if (id == connection_count) connections[connection_count++] = conn; + char value[32]; + snprintf(value, sizeof(value), "%u", id); + cwist_http_header_add(&res->headers, "x-test-connection", value); + res->status_code = CWIST_HTTP_OK; + cwist_sstring_assign_len(res->body, req->body->data, req->body->size); +} + +int main(void) { + cwist_error_t err = cwist_http3_init_context_ephemeral(&server_ctx); + if (err.error.err_i16 != 0 || !server_ctx) return 1; + int fd = socket(AF_INET, SOCK_DGRAM, 0); + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (fd < 0 || bind(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) return 1; + socklen_t len = sizeof(addr); + if (getsockname(fd, (struct sockaddr *)&addr, &len) != 0) return 1; + signal(SIGTERM, stop_server); + signal(SIGINT, stop_server); + printf("READY %u\n", ntohs(addr.sin_port)); + fflush(stdout); + err = cwist_http3_server_loop(fd, server_ctx, echo_request, NULL); + cwist_http3_destroy_context(server_ctx); + close(fd); + return err.error.err_i16 != 0; +}