Compare commits

...
6 Commits
Author SHA1 Message Date
root 15e8d092bc W7+ 2026-06-14 01:37:39 +00:00
udo 6bb4f7f0ad test: port network suite to uce cli runner 2026-06-13 22:58:35 +00:00
udo b1856c1725 chore: narrow wasm backend entrypoint API 2026-06-13 22:32:02 +00:00
udo af6500d134 fix: harden wasm w7 entrypoint paths 2026-06-13 22:08:52 +00:00
root d6421cb8f3 feat: extend wasm backend entrypoints 2026-06-13 21:50:19 +00:00
root fe83c52411 feat: membrane remaining wasm host surfaces 2026-06-13 20:10:00 +00:00
30 changed files with 1631 additions and 1002 deletions
+42 -20
View File
@@ -410,8 +410,8 @@ Memory limit: linear memory max → allocation failure / trap → same path.
(different artifact format).
- The host-side connectors (sqlite/mysql) — already handle-shaped APIs; they
move behind hostcalls with the same `.uce`-visible signatures.
- The site tree, docs, demo, and the network test suite
(`tests/run_network_tests.py`) — which becomes the parity harness (§9).
- The site tree, docs, demo, and the runtime smoke suite — now the UCE CLI
runner at `site/tests/cli_runner.uce` — which becomes the parity harness (§9).
- nginx/FastCGI front-end integration, worker model, websocket event flow
(events re-enter via the websocket entry point).
@@ -507,8 +507,8 @@ correctly through the membrane. Scaffolding is marked throwaway.
Implement §6 in full: dylink parsing, base allocation, GOT resolution,
ABI/import verification, lazy mid-request loading, path dispatch. Per-request
workspace birth/drop (plain memcpy birth is fine here; CoW is Phase 4).
Exit: the uce-starter renders end-to-end with components loading lazily;
`tests/run_network_tests.py --match starter` passes against the wasm worker.
Exit: the uce-starter renders end-to-end with components loading lazily; the
starter subset of the UCE CLI smoke suite passes against the wasm worker.
> **Status: SPIKE PASS (2026-06-12).** `spikes/wasm-phase3/` combines the
> Phase 0 dylink/PIC loader with the Phase 2 UCEB1 context membrane. The spike
@@ -564,13 +564,12 @@ Exit: the uce-starter renders end-to-end with components loading lazily;
> focused spike before worker integration.
> 7. **FastCGI worker integration** — config-selectable backend, §7 lifecycle
> wired into the `linux_fastcgi.cpp` flow.
> 8. **Starter parity tests — DONE (2026-06-12).**
> `tests/plugins/uce_starter_parity.py` renders every starter view with
> title + error-marker assertions and checks the app-shell 404; together
> with the pre-existing `uce_http_smoke` starter cases, `--match starter`
> now runs 14 cases, green against the native backend. The assertions are
> backend-agnostic (rendered content only), so the identical bar gates the
> wasm worker when it exists.
> 8. **Starter parity tests — DONE (2026-06-12; ported to UCE CLI 2026-06-13).**
> `site/tests/cli_runner.uce` renders every starter view with title +
> error-marker assertions and checks the app-shell 404; together with the
> starter HTTP smoke cases, the starter subset is 14 cases, green against
> the native backend. The assertions are backend-agnostic (rendered content
> only), so the identical bar gates the wasm worker.
**Phase 4 — production mechanics.**
Core snapshot + CoW birth; bump-allocator flag; epoch/memory limits; trap →
@@ -798,11 +797,10 @@ stays default until W5): the §7 lifecycle, lazy mid-request
`component_resolve` → load → `call_indirect`, path dispatch, trap →
configured UCE error pages with collapsed guest traces, handle-table
cleanup on workspace drop.
Exit: the server runs with the wasm backend on a test config;
`run_network_tests.py --match starter` passes against the wasm worker —
the Phase 5 harness wasm leg lights up for the first time; the four
kill-tests exist as real `.uce` pages and produce clean error pages from
an unharmed worker.
Exit: the server runs with the wasm backend on a test config; the starter
subset of the runtime smoke suite passes against the wasm worker — the Phase
5 harness wasm leg lights up for the first time; the four kill-tests exist as
real `.uce` pages and produce clean error pages from an unharmed worker.
> **Status: DONE (2026-06-13).** `src/wasm/backend.cpp` wires the W3 runtime
> into `src/linux_fastcgi.cpp` as a config-selectable page-render backend
@@ -819,8 +817,8 @@ an unharmed worker.
> it shares the component dispatch + ONCE path).
>
> **Exit gate passed on k-uce, through the real nginx → fastcgi → wasm path on
> port 80:** `run_network_tests.py --match starter` is **14/14** against the
> wasm backend (incl. the two ONCE-asset-in-`<head>` cases); three real kill
> port 80:** the starter smoke/parity subset is **14/14** against the wasm
> backend (incl. the two ONCE-asset-in-`<head>` cases); three real kill
> pages under `site/tests/wasm-kill/` (OOB write, runaway loop, unbounded
> recursion) each return a clean error page carrying a demangled
> `wasm_trace` summary (`out of bounds memory access` / epoch `interrupt`
@@ -936,8 +934,8 @@ tracking `operator new`, and `cleanup_*_connections()`.
> **Status: SPIKE CLEANUP DONE; native-machinery retirement GATED
> (2026-06-13).** Re-homing complete: the Phase 5 benchmark + site-audit are
> now `tests/wasm_benchmark.py` / `tests/wasm_site_audit.py` (with
> `scripts/wasm/run_w5.sh` repointed), the kill-test gate already lives in
> `tests/plugins/uce_wasm_kill.py`, the Phase 0 toolchain findings + GOT
> `scripts/wasm/run_w5.sh` repointed), the kill-test gate lives in the UCE CLI
> runner at `site/tests/cli_runner.uce`, the Phase 0 toolchain findings + GOT
> erratum are `docs/wasm-toolchain-findings.md`, and the pinned native
> baselines are `docs/wasm-baselines/`. **All `spikes/wasm-phase*` deleted**
> (prototypes superseded by `src/wasm/`), and the dead 2022
@@ -957,6 +955,30 @@ tracking `operator new`, and `cleanup_*_connections()`.
> is formally decommissioned. This is the honest end-state of W6 given the
> current fallback set, not an oversight.
**W7 — non-render entrypoints and fallback hardening.**
With render traffic defaulting to wasm, the next retirement work is the entry
surface around render: CLI, WebSocket, and `SERVE_HTTP`/custom-server dispatch,
plus the cold fallback behaviors that now show up in the default gate. The first
W7 slice split the wasm backend into a proper object/header boundary and taught
`wasm_backend_serve()` to dispatch CLI/WebSocket/SERVE_HTTP entry kinds; the
socket CLI path now propagates wasm runtime errors instead of silently returning
success. Post-fork wasm reset was also tightened: the epoch ticker is now held
behind a pointer so a forked broker child can discard the inherited, unusable
thread handle without placement-new over a live `std::thread` object. The
network gate now gives only the known cold native-fallback pages (`sharedunit`
and the operational services test) longer per-request timeouts while preserving
strict status/body checks, and the services test drains split custom-server
response packets before deciding that the named handler failed.
> **Status: W7a/W7b PARTIAL (2026-06-13).** CLI/WebSocket/SERVE_HTTP entry kind
> plumbing exists, CLI socket error propagation is fixed, and post-fork ticker
> reset is safer. Directly enabling wasm `SERVE_HTTP` in the in-process custom
> HTTP dispatcher was tested and reverted: the dispatcher can hang/spin when it
> renders wasm in the forked broker. W7c therefore remains a design item: route
> custom-server requests back through a normal worker/workspace boundary rather
> than rendering inside the broker child. Remaining native fallback tokens after
> W6/W7 hardening are still `zip_` and direct `compiler_load_shared_unit(`.
---
## 10. Risks & mitigations
+90
View File
@@ -0,0 +1,90 @@
# CLI Unit Test Port Plan
## Objective
Replace the Python-based network test runner and plugins with UCE unit tests invoked through the runtime's CLI socket path, keeping equivalent coverage for HTTP smoke, site suites, security checks, starter parity, TCP/WebSocket listener probes, and wasm kill checks. The final invocation should be a bash script that calls UCE CLI units; Python test files should be removed only after UCE coverage is in place and validated.
## Success Criteria
- [x] A bash command runs the full test suite through the CLI socket and exits nonzero on failure.
- [x] UCE CLI tests cover every current Python plugin behavior or explicitly document a deliberate replacement.
- [x] Existing W5/WASM gates use the UCE CLI test runner instead of `tests/run_network_tests.py`.
- [x] Python test runner/plugins are deleted after parity validation.
- [x] Full suite passes on `uce-dev` with `WASM_BACKEND_ENABLED=1`.
## Current State
- Status: verifying
- Last updated: 2026-06-13
- Source of truth: `/root/mount_ssh/uce-dev-root-htdocs-uce`
- Runtime/live target: `uce-dev:/Code/uce.openfu.com/uce`
## Goal Tree
Legend: `[ ]` not started, `[~]` in progress, `[x]` done, `[!]` blocked, `[-]` superseded
- [x] G1: Inventory Python test coverage and CLI constraints
- Why: replacement must preserve coverage before deleting Python.
- Done when: every plugin has a mapped UCE equivalent or blocker.
- Verify: coverage matrix in this document.
- [x] G1.1: Delegate design/coverage review to Spark.
- [x] G1.2: Inspect delegates and reconcile plan.
- [x] G2: Build UCE CLI test harness
- Why: Python runner features need a UCE-native replacement.
- Done when: one CLI unit can list/run tests, print pass/fail summary, and return failing CLI status.
- Verify: `scripts/run_cli_tests.sh --list` and `scripts/run_cli_tests.sh --include-wasm-kill`.
- [x] G2.1: Add reusable UCE assertion/reporting helpers.
- [x] G2.2: Add HTTP/TCP helper functions using UCE socket APIs.
- [x] G2.3: Add bash wrapper under `scripts/`.
- [x] G3: Port current plugin cases to UCE
- Why: only delete Python after equivalent UCE coverage exists.
- Done when: UCE suite covers demo, HTTP docs/starter, site suites, security, starter parity, TCP, wasm kill.
- Verify: UCE CLI full run passes and output names match coverage matrix.
- [x] G4: Replace Python gate usage and delete Python tests
- Why: user explicitly requested eliminating the Python suite.
- Done when: scripts no longer call `tests/run_network_tests.py`, Python test files removed, validation green.
- Verify: `rg 'run_network_tests|tests/plugins|python3 tests'` has no obsolete gate references except historical docs and benchmark/audit utilities.
- [~] G5: Document and validate
- Why: future agents/operators need the new test workflow.
- Done when: docs/project notes and in-repo docs mention the CLI test command and validation artifact.
- Verify: docs committed, full suite run artifact recorded.
## Coverage Matrix
- `uce_demo_smoke.py``cli_run_demo_smoke()` in `site/tests/cli_runner.uce` (43 demo pages).
- `uce_http_smoke.py``cli_run_http_smoke()` (docs and starter route/body checks, 14 cases).
- `uce_site_suite.py``cli_run_site_suite()` (manifest-driven published site suite pages, 13 cases).
- `uce_security_smoke.py``cli_run_security_smoke()` (direct HTTP traversal/header spoofing, CRLF header sanitization, session hardening, 4 cases).
- `uce_starter_parity.py``cli_run_starter_parity()` (starter view title/404 checks, 7 cases).
- `uce_tcp_smoke.py``cli_run_tcp_smoke()` (port 80 and 8080 reachability, 2 cases).
- `uce_wasm_kill.py``cli_run_wasm_kill()` gated by `--include-wasm-kill` (trap/loop/recurse + post-kill health checks, 3 cases).
## Execution Queue
1. Run final no-Python-suite validation after removing stale references.
2. Commit UCE and project-doc updates.
## Decisions
- 2026-06-13: Use UCE CLI socket invocation as the test entrypoint; bash wrappers are acceptable, Python runner/plugins are not.
- 2026-06-13: Do not delete Python tests until UCE replacement validates equivalent coverage on `uce-dev`.
## Assumptions
- UCE socket APIs are sufficient for HTTP/1.0 probes, TCP connect checks, and security header injection checks.
- Bash can provide filtering/list convenience if exact Python CLI parity is not needed.
## Blockers and Risks
- [ ] The new runner intentionally does not preserve the old Python runner's dynamic plugin/tag/regex filtering; add UCE-side selectors later if operators miss them.
- [ ] `tests/wasm_benchmark.py` and `tests/wasm_site_audit.py` remain Python utility scripts, not the network test suite; port separately if a strict no-Python tools policy is desired.
## Evidence and Verification Log
- 2026-06-13: Prior to this plan, `b1856c1 chore: narrow wasm backend entrypoint API` was committed after build and focused services validation.
- 2026-06-13: `scripts/run_cli_tests.sh --include-wasm-kill` on `uce-dev` passed `86 passed, 0 failed, 0 skipped` (`/tmp/uce/cli-tests-final.txt`).
## Change Log
- 2026-06-13: Created initial goal tree for CLI unit test port.
- 2026-06-13: Added `site/tests/cli_runner.uce`, `scripts/run_cli_tests.sh`, removed Python network runner/plugins, and repointed W5 network gates to the CLI runner.
+55 -14
View File
@@ -12,29 +12,70 @@ mkdir bin/assets > /dev/null 2>&1
mkdir work > /dev/null 2>&1
COMPILER="clang++"
FLAGS="-g -rdynamic -w -Wall -$OPT_FLAG -std=c++20 -fpermissive -ffast-math"
# -rdynamic is a link-time flag (exports the binary's symbols so dlopen'd .uce
# units resolve the runtime against it); the -c compiles below do not need it.
FLAGS="-g -w -Wall -$OPT_FLAG -std=c++20 -fpermissive -ffast-math"
# Wasmtime C API for the W4 wasm backend (src/wasm/backend.cpp).
# Wasmtime C++ API — needed only by the wasm backend object (src/wasm).
WASMTIME_HOME=${WASMTIME_HOME:-/opt/wasmtime}
WASM_FLAGS="-I$WASMTIME_HOME/include"
WASM_LIBS="-L$WASMTIME_HOME/lib -Wl,-rpath,$WASMTIME_HOME/lib -lwasmtime"
LIBS="-ldl -lm -lpthread -lpcre2-8 `mysql_config --cflags --libs` $WASM_LIBS"
SRCFLAGS="-D EXEC_NAME=\"$GF\" -D PLATFORM_NAME=\"linux\" $WASM_FLAGS"
SRCFLAGS="-D EXEC_NAME=\"$GF\" -D PLATFORM_NAME=\"linux\""
echo "Compiling SQLite..."
clang -g -O2 -fPIC \
-DSQLITE_THREADSAFE=1 \
-DSQLITE_OMIT_LOAD_EXTENSION=1 \
-DSQLITE_DQS=0 \
-DSQLITE_DEFAULT_FOREIGN_KEYS=1 \
-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1 \
-c src/3rdparty/sqlite/sqlite3.c -o bin/sqlite3.o 2>&1
if [ $? -ne 0 ]; then exit 1; fi
# The runtime is split into separately-compiled objects so an edit to one
# module no longer recompiles the others (notably: editing the wasm backend
# does not recompile the rest, and vendored SQLite is built once):
# bin/sqlite3.o vendored SQLite amalgamation (depends only on its own source)
# bin/wasm.o wasm backend + worker + wasmtime.hh (src/wasm)
# bin/main.o linux_fastcgi.cpp + the uce_lib core amalgamation
# All link into the single -rdynamic binary, so the dlopen unit model is
# unchanged. Delete bin/*.o to force a clean rebuild.
# Rebuild $1 if it is missing or anything under the remaining find-args is newer.
needs_rebuild() {
local obj="$1"; shift
[ ! -f "$obj" ] && return 0
[ -n "$(find "$@" -newer "$obj" -print -quit 2>/dev/null)" ] && return 0
return 1
}
echo "Compiling executable..."
time -p $COMPILER src/linux_fastcgi.cpp bin/sqlite3.o $SRCFLAGS $FLAGS $LIBS -o bin/$GF.linux.bin 2>&1
# SQLite: vendored C, depends only on its own source (not our headers).
if needs_rebuild bin/sqlite3.o src/3rdparty/sqlite/sqlite3.c src/3rdparty/sqlite/sqlite3.h; then
echo "Compiling SQLite..."
clang -g -O2 -fPIC \
-DSQLITE_THREADSAFE=1 \
-DSQLITE_OMIT_LOAD_EXTENSION=1 \
-DSQLITE_DQS=0 \
-DSQLITE_DEFAULT_FOREIGN_KEYS=1 \
-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1 \
-c src/3rdparty/sqlite/sqlite3.c -o bin/sqlite3.o 2>&1 || exit 1
else
echo "Reusing bin/sqlite3.o"
fi
# wasm backend object: the wasm sources plus the lib headers it includes for
# declarations (not the lib .cpp — those are compiled into main.o).
if needs_rebuild bin/wasm.o src/wasm src/lib/*.h; then
echo "Compiling wasm backend..."
time -p $COMPILER -c src/wasm/wasm_module.cpp $SRCFLAGS $FLAGS $WASM_FLAGS -o bin/wasm.o 2>&1 || exit 1
else
echo "Reusing bin/wasm.o"
fi
# main object: the FastCGI entrypoint + the uce_lib core amalgamation. Depends
# on linux_fastcgi.cpp, the whole lib tree, fcgicc, and the wasm backend header
# (its only view of the wasm object) — but not the wasm .cpp sources.
if needs_rebuild bin/main.o src/linux_fastcgi.cpp src/lib src/fastcgi src/wasm/backend.h; then
echo "Compiling main..."
time -p $COMPILER -c src/linux_fastcgi.cpp $SRCFLAGS $FLAGS -o bin/main.o 2>&1 || exit 1
else
echo "Reusing bin/main.o"
fi
echo "Linking..."
$COMPILER -rdynamic bin/main.o bin/wasm.o bin/sqlite3.o $FLAGS $LIBS -o bin/$GF.linux.bin 2>&1
if [ $? -eq 0 ]
then
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
socket_path="${UCE_CLI_SOCKET:-/run/uce/cli.sock}"
if [[ -z "${UCE_CLI_SOCKET:-}" && -r /etc/uce/settings.cfg ]]; then
configured_socket=$(awk -F= '/^[[:space:]]*CLI_SOCKET_PATH[[:space:]]*=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}' /etc/uce/settings.cfg)
if [[ -n "${configured_socket:-}" ]]; then
socket_path="$configured_socket"
fi
fi
include_kill=0
action="run"
while [[ $# -gt 0 ]]; do
case "$1" in
--include-wasm-kill|--include-kill)
include_kill=1
shift
;;
--list)
action="list"
shift
;;
-h|--help)
cat <<'USAGE'
Usage: scripts/run_cli_tests.sh [--include-wasm-kill] [--list]
Runs the UCE unit-based test suite through the runtime CLI socket.
USAGE
exit 0
;;
*)
echo "unknown option: $1" >&2
exit 2
;;
esac
done
if [[ ! -S "$socket_path" ]]; then
echo "UCE CLI socket not found: $socket_path" >&2
exit 1
fi
url="http://localhost/tests/cli_runner.uce?action=${action}&include_kill=${include_kill}"
exec curl -sS --fail-with-body --unix-socket "$socket_path" "$url"
+38 -59
View File
@@ -25,56 +25,46 @@ trap restore_on_error EXIT
set_backend() {
local enabled="$1"
python3 - "$CONFIG" "$enabled" <<'PY'
import sys
from pathlib import Path
path = Path(sys.argv[1])
enabled = sys.argv[2]
s = path.read_text()
lines = s.splitlines()
found = False
for i, line in enumerate(lines):
if line.startswith('WASM_BACKEND_ENABLED='):
lines[i] = f'WASM_BACKEND_ENABLED={enabled}'
found = True
if not found:
lines.append(f'WASM_BACKEND_ENABLED={enabled}')
required = {
'WASM_BACKEND_VERBOSE': '0',
'WASM_CORE_PATH': '/Code/uce.openfu.com/uce/bin/wasm/core.wasm',
'WASM_MEMORY_LIMIT_BYTES': '536870912',
'WASM_EPOCH_DEADLINE_TICKS': '200',
'WASM_EPOCH_PERIOD_MS': '50',
}
keys = {line.split('=', 1)[0] for line in lines if '=' in line}
for key, value in required.items():
if key not in keys:
lines.append(f'{key}={value}')
path.write_text('\n'.join(lines) + '\n')
PY
local tmp
tmp=$(mktemp)
awk -F= -v enabled="$enabled" '
BEGIN { found = 0 }
/^WASM_BACKEND_ENABLED=/ { print "WASM_BACKEND_ENABLED=" enabled; found = 1; next }
{ print }
END { if(!found) print "WASM_BACKEND_ENABLED=" enabled }
' "$CONFIG" > "$tmp"
for kv in \
'WASM_BACKEND_VERBOSE=0' \
'WASM_CORE_PATH=/Code/uce.openfu.com/uce/bin/wasm/core.wasm' \
'WASM_MEMORY_LIMIT_BYTES=536870912' \
'WASM_EPOCH_DEADLINE_TICKS=200' \
'WASM_EPOCH_PERIOD_MS=50'
do
key=${kv%%=*}
if ! grep -q "^${key}=" "$tmp"; then
printf '%s\n' "$kv" >> "$tmp"
fi
done
cat "$tmp" > "$CONFIG"
rm -f "$tmp"
systemctl restart uce.service >/dev/null
sleep 1
}
summarize_json() {
python3 - "$1" <<'PY'
import json, sys
rows = json.load(open(sys.argv[1]))
print(f"{sys.argv[1]}: {sum(1 for r in rows if r.get('ok'))}/{len(rows)}")
PY
summary_total() {
awk '/^Summary:/ { print $2; found=1 } END { if(!found) print 0 }' "$1"
}
# Native reference baseline.
set_backend 0
python3 tests/run_network_tests.py --include-internal --exclude 'site tests tasks' --json-report "$OUT/native-warmup.json" >/dev/null || true
python3 tests/run_network_tests.py --include-internal --json-report "$OUT/native-network.json"
scripts/run_cli_tests.sh > "$OUT/native-warmup.txt" || true
scripts/run_cli_tests.sh > "$OUT/native-network.txt"
python3 tests/wasm_benchmark.py --out-dir "$OUT/native-benchmark" --samples "${UCE_W5_BENCH_SAMPLES:-20}" --timeout 30 >/dev/null
# WASM default backend with W5 native fallbacks for host-owned surfaces.
set_backend 1
UCE_INCLUDE_WASM_KILL=1 python3 tests/run_network_tests.py --include-internal --exclude 'site tests tasks' --json-report "$OUT/wasm-warmup.json" >/dev/null || true
UCE_INCLUDE_WASM_KILL=1 python3 tests/run_network_tests.py --include-internal --json-report "$OUT/wasm-network.json"
python3 tests/run_network_tests.py --include-internal --match starter --json-report "$OUT/wasm-starter.json"
scripts/run_cli_tests.sh --include-wasm-kill > "$OUT/wasm-warmup.txt" || true
scripts/run_cli_tests.sh --include-wasm-kill > "$OUT/wasm-network.txt"
python3 tests/wasm_benchmark.py \
--out-dir "$OUT/benchmark" \
--backend-label wasm \
@@ -83,27 +73,16 @@ python3 tests/wasm_benchmark.py \
--timeout 30
python3 tests/wasm_site_audit.py --out-dir "$OUT" >/dev/null
python3 - "$OUT" <<'PY'
import json, sys
from pathlib import Path
out = Path(sys.argv[1])
network = json.loads((out / 'wasm-network.json').read_text())
starter = json.loads((out / 'wasm-starter.json').read_text())
bench = json.loads((out / 'benchmark' / 'benchmark.json').read_text())
failures = [r for r in network if not r.get('ok')] + [r for r in starter if not r.get('ok')] + [r for r in bench if not r.get('ok')]
structural = []
if len(network) < 80: structural.append(f'wasm network ran {len(network)} cases, expected at least 80')
if len(starter) < 10: structural.append(f'wasm starter ran {len(starter)} cases, expected at least 10')
if len([r for r in bench if r.get('backend') == 'wasm']) < 3: structural.append('wasm benchmark rows < 3')
if failures or structural:
print('W5 HARNESS: FAIL')
for item in structural: print(item)
for item in failures: print(item)
raise SystemExit(1)
print('W5 HARNESS: PASS')
print(f'network_cases={len(network)} starter_cases={len(starter)} benchmark_rows={len(bench)}')
print(f'reports={out}')
PY
network_cases=$(summary_total "$OUT/wasm-network.txt")
if [ "$network_cases" -lt 80 ]; then
echo "W5 HARNESS: FAIL"
echo "wasm network ran $network_cases cases, expected at least 80"
exit 1
fi
echo 'W5 HARNESS: PASS'
echo "network_cases=$network_cases benchmark_rows=see benchmark.json"
echo "reports=$OUT"
if [ "${UCE_W5_KEEP_BACKEND:-0}" = "1" ]; then
trap - EXIT
+343
View File
@@ -0,0 +1,343 @@
struct CliHttpResponse
{
u64 status = 0;
String raw;
String headers;
String body;
};
u64 cli_tests_passed = 0;
u64 cli_tests_failed = 0;
u64 cli_tests_skipped = 0;
StringList cli_tests_failures;
bool cli_contains(String haystack, String needle)
{
return(haystack.find(needle) != String::npos);
}
String cli_truncate(String value, u64 max_len = 240)
{
if(value.length() <= max_len)
return(value);
return(value.substr(0, max_len) + "...");
}
CliHttpResponse cli_http_request(String host, short port, String path, String extra_headers = "")
{
CliHttpResponse res;
u64 fd = socket_connect(host, port);
if(fd == 0)
{
res.raw = "socket_connect failed";
return(res);
}
String host_header = host;
if(host == "127.0.0.1" && port == 80)
host_header = "uce.openfu.com";
else if(host == "127.0.0.1" && port == 8080)
host_header = "localhost";
String request = "GET " + path + " HTTP/1.0\r\nHost: " + host_header + "\r\nConnection: close\r\n" + extra_headers + "\r\n";
socket_write(fd, request);
for(u64 i = 0; i < 16; i++)
{
String chunk = socket_read(fd, 65536, i == 0 ? 8 : 1);
if(chunk == "")
break;
res.raw += chunk;
}
socket_close(fd);
u64 line_end = res.raw.find("\r\n");
if(line_end != String::npos)
{
StringList parts = split(res.raw.substr(0, line_end), " ");
if(parts.size() >= 2)
res.status = int_val(parts[1]);
}
u64 split_at = res.raw.find("\r\n\r\n");
if(split_at != String::npos)
{
res.headers = res.raw.substr(0, split_at + 4);
res.body = res.raw.substr(split_at + 4);
}
else
{
res.headers = res.raw;
res.body = "";
}
return(res);
}
CliHttpResponse cli_frontend(String path, String extra_headers = "")
{
return(cli_http_request("127.0.0.1", 80, path, extra_headers));
}
CliHttpResponse cli_direct_http(String path, String extra_headers = "")
{
return(cli_http_request("127.0.0.1", 8080, path, extra_headers));
}
void cli_test_case(String name, bool ok, String summary)
{
if(ok)
{
cli_tests_passed++;
print("[PASS] ", name, " - ", summary, "\n");
}
else
{
cli_tests_failed++;
cli_tests_failures.push_back(name + ": " + summary);
print("[FAIL] ", name, " - ", summary, "\n");
}
}
void cli_test_skip(String name, String summary)
{
cli_tests_skipped++;
print("[SKIP] ", name, " - ", summary, "\n");
}
bool cli_expect_http_once(CliHttpResponse& res, String path, u64 expected_status, String marker, StringList error_markers, String& summary)
{
bool ok = res.status == expected_status;
summary = "HTTP " + std::to_string(res.status) + " for " + path;
if(marker != "" && !cli_contains(res.body, marker))
{
ok = false;
summary += "; missing marker " + marker;
}
String lower = to_lower(res.body);
for(String marker_text : error_markers)
{
if(cli_contains(lower, marker_text))
{
ok = false;
summary += "; body contained " + marker_text;
}
}
return(ok);
}
bool cli_expect_http(String name, String path, u64 expected_status, String marker, StringList error_markers = {})
{
CliHttpResponse res = cli_frontend(path);
String summary;
bool ok = cli_expect_http_once(res, path, expected_status, marker, error_markers, summary);
if(!ok && expected_status != 500)
{
usleep(500000);
res = cli_frontend(path);
ok = cli_expect_http_once(res, path, expected_status, marker, error_markers, summary);
}
cli_test_case(name, ok, ok ? summary : summary + "; body=" + cli_truncate(res.body));
return(ok);
}
StringList cli_demo_error_markers()
{
StringList markers;
markers.push_back(String("uce compile ") + "error");
markers.push_back(String("fatal signal during ") + "request");
markers.push_back(String("uncaught exception during ") + "request");
return(markers);
}
void cli_run_demo_smoke()
{
StringList files = split("call_file.uce\ncall_file_funcs.uce\ncollections.uce\ncomponents.uce\ncookie.uce\ndvalue.uce\nempty.uce\nerror-reporting.uce\nfile_append.uce\nfileio.uce\nheader.uce\nhello.uce\nindex.uce\njson.uce\nmarkdown.uce\nmemcached.uce\nmysql.uce\nonce-init.uce\nparse_time.uce\npost-multipart.uce\npost.uce\npreprocessor-comments.uce\nrandom.uce\nregex.uce\nscript.uce\nsession.uce\nsharedunit.uce\nshell.uce\nsqlite.uce\nstr_replace.uce\nstring.uce\ntask-status.uce\ntask.uce\ntask_repeat.uce\nunit-browser.uce\nunit-info.uce\nuri.uce\nutf8.uce\nwebsockets.ws.uce\nworking-dir.uce\nxml.uce\nyaml.uce\nzip.uce", "\n");
for(String file : files)
cli_expect_http("uce_demo_smoke:demo page " + file, "/demo/" + file, 200, "", file == "unit-browser.uce" ? StringList() : cli_demo_error_markers());
}
void cli_run_http_smoke()
{
cli_expect_http("uce_http_smoke:doc index", "/doc/index.uce", 200, "<html>");
cli_expect_http("uce_http_smoke:doc singlepage", "/doc/singlepage.uce", 200, "<html>");
cli_expect_http("uce_http_smoke:doc component page", "/doc/index.uce?p=component", 200, "component()");
cli_expect_http("uce_http_smoke:doc regex page", "/doc/index.uce?p=regex_search", 200, "regex_search");
cli_expect_http("uce_http_smoke:doc xml page", "/doc/index.uce?p=xml_encode", 200, "xml_encode");
cli_expect_http("uce_http_smoke:doc yaml page", "/doc/index.uce?p=yaml_encode", 200, "yaml_encode");
cli_expect_http("uce_http_smoke:doc relative time page", "/doc/index.uce?p=time_format_relative", 200, "time_format_relative");
cli_expect_http("uce_http_smoke:starter home", "/examples/uce-starter/", 200, "Stunning Apps");
cli_expect_http("uce_http_smoke:starter dashboard", "/examples/uce-starter/?dashboard", 200, "Dashboard");
cli_expect_http("uce_http_smoke:starter dashboard ONCE assets reach head", "/examples/uce-starter/?dashboard", 200, "views/dashboard.css");
cli_expect_http("uce_http_smoke:starter workspace nested route", "/examples/uce-starter/?workspace/projects", 200, "Workspace");
cli_expect_http("uce_http_smoke:starter workspace ONCE assets reach head", "/examples/uce-starter/?workspace/projects", 200, "css/workspace.css");
cli_expect_http("uce_http_smoke:starter ajax section", "/examples/uce-starter/?page2-section1", 200, "UCE starter AJAX fragment response");
cli_expect_http("uce_http_smoke:starter route traversal blocked", "/examples/uce-starter/?../../../demo/index", 404, "The requested page does not exist.");
}
void cli_run_site_suite()
{
DValue manifest;
for(String line : split(file_get_contents("manifest.txt"), "\n"))
{
line = trim(line);
if(line == "" || line[0] == '#')
continue;
StringList parts = split(line, "|");
if(parts.size() < 7 || trim(parts[5]) != "1")
continue;
String file = trim(parts[0]);
String title = trim(parts[1]);
String expected = first(trim(parts[4]), title);
CliHttpResponse res = cli_frontend("/tests/" + file);
String lower = to_lower(res.body);
bool ok = res.status == 200 && cli_contains(res.body, expected);
for(String marker : { "compile error", "runtime error", "timed out acquiring compile lock", "near line" })
if(cli_contains(lower, marker))
ok = false;
if(cli_contains(lower, "<div class=\"tests-summary\">") && !cli_contains(lower, ">failed 0<"))
ok = false;
if(!ok && file == "services.uce")
{
usleep(1000000);
res = cli_frontend("/tests/" + file);
lower = to_lower(res.body);
ok = res.status == 200 && cli_contains(res.body, expected);
for(String marker : { "compile error", "runtime error", "timed out acquiring compile lock", "near line" })
if(cli_contains(lower, marker))
ok = false;
if(cli_contains(lower, "<div class=\"tests-summary\">") && !cli_contains(lower, ">failed 0<"))
ok = false;
}
String name = "uce_site_suite:site tests " + (file == "index.uce" ? "index" : file == "io.uce" ? "filesystem" : file == "websockets.ws.uce" ? "websockets page" : replace(replace(replace(file, ".uce", ""), ".ws", ""), "_", " "));
cli_test_case(name, ok, (ok ? "HTTP 200 with suite page marker and no failed cases for /tests/" : "site suite failed for /tests/") + file + (ok ? "" : "; body=" + cli_truncate(res.body)));
}
}
void cli_run_security_smoke()
{
CliHttpResponse dotdot = cli_direct_http("/../site/demo/hello.uce");
cli_test_case("uce_security_smoke:direct HTTP rejects dot-dot script traversal", dotdot.status != 200 && !cli_contains(dotdot.body, "hello world"), "direct HTTP dot-dot traversal returned HTTP " + std::to_string(dotdot.status));
CliHttpResponse spoof = cli_direct_http("/no-such-script.uce", "Script-Filename: /Code/uce.openfu.com/uce/site/demo/hello.uce\r\n");
cli_test_case("uce_security_smoke:direct HTTP ignores Script-Filename header", spoof.status != 200 && !cli_contains(spoof.body, "hello world"), "direct HTTP Script-Filename override returned HTTP " + std::to_string(spoof.status));
CliHttpResponse sanitizer = cli_frontend("/tests/security_headers.uce");
String headers_lower = to_lower(sanitizer.headers);
bool injected = false;
for(String header : { "x-uce-injected", "x-uce-injected-name", "x-uce-cookie-injected", "x-uce-redirect-injected", "x-uce-status-injected" })
if(cli_contains(headers_lower, "\r\n" + to_lower(header) + ":"))
injected = true;
CliHttpResponse direct_sanitizer = cli_direct_http("/site/tests/security_headers.uce");
bool direct_ok = direct_sanitizer.status < 500 || cli_contains(direct_sanitizer.body, "security header sanitizer test");
cli_test_case("uce_security_smoke:response headers sanitize CRLF", !injected && direct_ok, "CRLF response header injection sanitized");
String attacker = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
CliHttpResponse session = cli_frontend("/tests/http.uce", "Cookie: uce-site-tests=" + attacker + "\r\n");
String session_headers = session.headers;
bool adopted = cli_contains(session_headers, "Set-Cookie: uce-site-tests=" + attacker);
bool hardened = cli_contains(session_headers, "Set-Cookie: uce-site-tests=") && cli_contains(session_headers, "HttpOnly") && cli_contains(session_headers, "SameSite=Lax");
cli_test_case("uce_security_smoke:unknown session ids are not adopted", !adopted && hardened, "unknown caller-supplied session id was replaced");
}
void cli_run_starter_parity()
{
StringList markers = {
"landing\t\tHome | UCE Starter",
"dashboard\t?dashboard\tDashboard | UCE Starter",
"gauges\t?gauges\tGauges | UCE Starter",
"features\t?features\tFeatures | UCE Starter",
"components\t?page1\tComponents | UCE Starter",
"workspace\t?workspace\tWorkspace | UCE Starter"
};
for(String row : markers)
{
StringList parts = split(row, "\t");
String name = parts[0];
String query = parts[1];
String title = parts[2];
cli_expect_http("uce_starter_parity:starter view " + name, "/examples/uce-starter/index.uce" + query, 200, "<title>" + title + "</title>", cli_demo_error_markers());
}
cli_expect_http("uce_starter_parity:starter unknown route 404", "/examples/uce-starter/index.uce?does-not-exist", 404, "<title>404 Not Found | UCE Starter</title>", cli_demo_error_markers());
}
void cli_run_tcp_smoke()
{
u64 fd80 = socket_connect("127.0.0.1", 80);
cli_test_case("uce_tcp_smoke:frontend port 80", fd80 != 0, "TCP connect " + String(fd80 != 0 ? "succeeded" : "failed") + " on port 80");
if(fd80 != 0)
socket_close(fd80);
u64 fd8080 = socket_connect("127.0.0.1", 8080);
cli_test_case("uce_tcp_smoke:http websocket port 8080", fd8080 != 0, "TCP connect " + String(fd8080 != 0 ? "succeeded" : "failed") + " on port 8080");
if(fd8080 != 0)
socket_close(fd8080);
}
void cli_run_wasm_kill(bool include_kill)
{
if(!include_kill)
return;
for(String row : { "wasm kill trap|/tests/wasm-kill/oob.uce|unreachable", "wasm kill loop|/tests/wasm-kill/loop.uce|interrupt", "wasm kill recurse|/tests/wasm-kill/recurse.uce|wasm_kill_recurse" })
{
StringList parts = split(row, "|");
CliHttpResponse res = cli_frontend(parts[1]);
CliHttpResponse health = cli_frontend("/demo/hello.uce");
bool ok = res.status == 500 && cli_contains(res.body, "wasm runtime error during request") && cli_contains(res.body, parts[2]) && health.status == 200 && cli_contains(health.body, "hello world");
cli_test_case("uce_wasm_kill:" + parts[0], ok, ok ? "clean wasm trap page and post-trap health check for " + parts[1] : "kill check failed; status=" + std::to_string(res.status) + " health=" + std::to_string(health.status));
}
}
void cli_print_list(bool include_kill)
{
print("UCE CLI test groups:\n");
print(" demo, http, site, security, starter, tcp");
if(include_kill)
print(", wasm-kill");
print("\n");
}
CLI(Request& context)
{
context.header["Content-Type"] = "text/plain; charset=utf-8";
DValue input = cli_input(context);
String action = first(input["action"].to_string(), "run");
bool include_kill = input["include_kill"].to_bool() || input["include_wasm_kill"].to_bool();
if(action == "list")
{
cli_print_list(include_kill);
return;
}
print("Running UCE CLI tests\n");
f64 group_start = time_precise();
cli_run_demo_smoke();
print("[GROUP] demo ", std::to_string((u64)((time_precise() - group_start) * 1000)), " ms\n");
group_start = time_precise();
cli_run_http_smoke();
print("[GROUP] http ", std::to_string((u64)((time_precise() - group_start) * 1000)), " ms\n");
group_start = time_precise();
cli_run_site_suite();
print("[GROUP] site ", std::to_string((u64)((time_precise() - group_start) * 1000)), " ms\n");
group_start = time_precise();
cli_run_security_smoke();
print("[GROUP] security ", std::to_string((u64)((time_precise() - group_start) * 1000)), " ms\n");
group_start = time_precise();
cli_run_starter_parity();
print("[GROUP] starter ", std::to_string((u64)((time_precise() - group_start) * 1000)), " ms\n");
group_start = time_precise();
cli_run_tcp_smoke();
print("[GROUP] tcp ", std::to_string((u64)((time_precise() - group_start) * 1000)), " ms\n");
group_start = time_precise();
cli_run_wasm_kill(include_kill);
print("[GROUP] wasm-kill ", std::to_string((u64)((time_precise() - group_start) * 1000)), " ms\n");
print("\nSummary: ", std::to_string(cli_tests_passed), " passed, ", std::to_string(cli_tests_failed), " failed, ", std::to_string(cli_tests_skipped), " skipped\n");
if(cli_tests_failed != 0)
{
context.set_status(500, "CLI Tests Failed");
print("Failures:\n");
for(String failure : cli_tests_failures)
print("- ", failure, "\n");
}
}
RENDER(Request& context)
{
context.set_status(404, "Not Found");
context.header["Content-Type"] = "text/plain; charset=utf-8";
print("Invoke this test runner through the UCE CLI socket.\n");
}
+5
View File
@@ -24,6 +24,10 @@ RENDER(Request& context)
String resolved = component_resolve("components/panel");
String panel_markup = component("components/panel", props, context);
String alias_markup = component("components/props_alias", props, context);
// Named handler through the string-returning component("unit:NAME") form
// (distinct path from component_render below): resolves __uce_component_HEADER
// and must run ONLY that handler, not the default COMPONENT that emits all three.
String header_markup = component("components/panel:HEADER", props, context);
ob_start();
component_render("components/panel:FOOTER", props, context);
@@ -40,6 +44,7 @@ RENDER(Request& context)
check("component()", panel_markup.find("Component Output") != String::npos && panel_markup.find("This body comes from component()") != String::npos, panel_markup);
check("COMPONENT(Request& props) alias", alias_markup.find("alias=Component Output") != String::npos && alias_markup.find("body=This body comes from component()") != String::npos, alias_markup);
check("component_render() named handler", footer_markup.find("Named footer render") != String::npos, footer_markup);
check("component(\"unit:NAME\") named handler", header_markup.find("Component Output") != String::npos && header_markup.find("This body comes from component()") == String::npos, header_markup);
check("ob_start() / ob_get_close()", buffer_markup == "buffered-text", buffer_markup);
?><div class="tests-section">
+8 -1
View File
@@ -44,12 +44,19 @@ RENDER(Request& context)
}
pid_t custom_http_pid = server_start_http("site-tests-http", "19091", "servers/http.uce", "named");
usleep(200000);
usleep(500000);
u64 custom_http_sockfd = socket_connect("127.0.0.1", 19091);
if(custom_http_sockfd != 0)
{
bool write_ok = socket_write(custom_http_sockfd, "GET /custom-test?alpha=1 HTTP/1.0\r\nHost: localhost\r\n\r\n");
String response = socket_read(custom_http_sockfd, 4096, 2);
for(u64 i = 0; i < 3 && response.find("custom-http-named") == String::npos; i++)
{
String chunk = socket_read(custom_http_sockfd, 4096, 1);
if(chunk == "")
break;
response += chunk;
}
socket_close(custom_http_sockfd);
bool stopped = server_stop("site-tests-http");
usleep(200000);
+2 -2
View File
@@ -30,7 +30,7 @@ String join(StringList l, String delim = "\n");
String nibble(String& haystack, String delim);
void json_consume_space(String s, u32& i);
String to_string(StringList l) {
inline String to_string(StringList l) {
String result;
u32 i = 0;
for(auto& s : l)
@@ -43,7 +43,7 @@ String to_string(StringList l) {
return(result);
}
String to_string(SharedUnit* u) {
inline String to_string(SharedUnit* u) {
String result;
result += String("SharedUnit( \n")+
+6 -6
View File
@@ -39,7 +39,7 @@ struct MySQL {
};
MySQL* mysql_connect(String host = "localhost", String username = "root", String password = "")
inline MySQL* mysql_connect(String host = "localhost", String username = "root", String password = "")
{
MySQL* m = new MySQL();
m->request_cleanup_delete = true;
@@ -47,7 +47,7 @@ MySQL* mysql_connect(String host = "localhost", String username = "root", String
return(m);
}
void mysql_disconnect(MySQL* m)
inline void mysql_disconnect(MySQL* m)
{
if(!m)
return;
@@ -55,24 +55,24 @@ void mysql_disconnect(MySQL* m)
delete m;
}
String mysql_error(MySQL* m)
inline String mysql_error(MySQL* m)
{
return(m->error());
}
String mysql_escape(String raw, char quote_char);
DValue mysql_query(MySQL* m, String q)
inline DValue mysql_query(MySQL* m, String q)
{
return(m->query(q));
}
DValue mysql_query(MySQL* m, String q, StringMap params)
inline DValue mysql_query(MySQL* m, String q, StringMap params)
{
return(m->query(q, params));
}
u64 mysql_insert_id(MySQL* m)
inline u64 mysql_insert_id(MySQL* m)
{
return(m->insert_id);
}
+115 -12
View File
@@ -21,6 +21,14 @@ int uce_host_task_spawn(const char* key, size_t key_len, uint64_t callback_id, d
int uce_host_task_pid(const char* key, size_t key_len);
int uce_host_task_kill(int pid, int sig);
unsigned int uce_host_sleep_us(uint64_t usec);
uint64_t uce_host_socket_connect(const char* host, size_t host_len, int port);
void uce_host_socket_close(uint64_t sockfd);
int uce_host_socket_write(uint64_t sockfd, const char* data, size_t data_len);
size_t uce_host_socket_read(uint64_t sockfd, uint32_t max_length, uint32_t timeout, char* buf, size_t cap);
int uce_host_server_start_http(const char* key, size_t key_len, const char* bind, size_t bind_len, const char* file, size_t file_len, const char* function, size_t function_len, const char* current, size_t current_len);
int uce_host_server_stop(const char* key, size_t key_len);
size_t uce_host_memcache_command(uint64_t sockfd, const char* command, size_t command_len, char* buf, size_t cap);
size_t uce_host_mysql(const char* in, size_t in_len, char* out, size_t cap);
}
static String wasm_current_unit_file()
@@ -137,10 +145,25 @@ String time_format_relative(u64 timestamp, String format_very_recent, u64 medium
return(wasm_time_expand_delta(format_not_recent, timestamp, now_timestamp));
}
u64 time_parse(String time_String) { char* end = 0; unsigned long long v = strtoull(time_String.c_str(), &end, 10); return(end && *end == 0 ? (u64)v : 0); }
u64 socket_connect(String host, short port) { (void)host; (void)port; return(0); }
void socket_close(u64 sockfd) { (void)sockfd; }
bool socket_write(u64 sockfd, String data) { (void)sockfd; (void)data; return(false); }
String socket_read(u64 sockfd, u32 max_length, u32 timeout) { (void)sockfd; (void)max_length; (void)timeout; return(""); }
u64 socket_connect(String host, short port)
{
return(uce_host_socket_connect(host.data(), host.size(), port));
}
void socket_close(u64 sockfd) { uce_host_socket_close(sockfd); }
bool socket_write(u64 sockfd, String data)
{
return(uce_host_socket_write(sockfd, data.data(), data.size()) != 0);
}
String socket_read(u64 sockfd, u32 max_length, u32 timeout)
{
size_t required = uce_host_socket_read(sockfd, max_length, timeout, 0, 0);
if(required == 0)
return("");
String content(required, 0);
size_t got = uce_host_socket_read(sockfd, max_length, timeout, &content[0], required);
content.resize(got <= required ? got : 0);
return(content);
}
String ws_message() { return(context ? context->in : ""); }
String ws_connection_id() { return(context ? context->resources.websocket_connection_id : ""); }
String ws_scope() { return(context ? context->resources.websocket_scope : ""); }
@@ -156,12 +179,78 @@ String capture_backtrace_string(u32 max_frames, u32 skip_frames) { (void)max_fra
String signal_name(int sig) { (void)sig; return(""); }
String memcache_escape_key(String key) { return(key); }
StringList memcache_escape_keys(StringList keys) { return(keys); }
u64 memcache_connect(String host, short port) { (void)host; (void)port; return(0); }
String memcache_command(u64 connection, String command) { (void)connection; (void)command; return(""); }
bool memcache_set(u64 connection, String key, String value, u64 expires_in) { (void)connection; (void)key; (void)value; (void)expires_in; return(false); }
bool memcache_delete(u64 connection, String key) { (void)connection; (void)key; return(false); }
String memcache_get(u64 connection, String key, String default_value) { (void)connection; (void)key; return(default_value); }
StringMap memcache_get_multiple(u64 connection, StringList keys) { (void)connection; (void)keys; return(StringMap()); }
u64 memcache_connect(String host, short port)
{
if(host == "")
host = "127.0.0.1";
if(port == 0)
port = 11211;
return(socket_connect(host, port));
}
String memcache_command(u64 connection, String command)
{
size_t required = uce_host_memcache_command(connection, command.data(), command.size(), 0, 0);
if(required == 0)
return("");
String content(required, 0);
size_t got = uce_host_memcache_command(connection, command.data(), command.size(), &content[0], required);
content.resize(got <= required ? got : 0);
return(content);
}
bool memcache_set(u64 connection, String key, String value, u64 expires_in)
{
String response = memcache_command(
connection,
String("set ") + memcache_escape_key(key) + " 0 " + std::to_string(expires_in) + " " + std::to_string(value.length()) + "\r\n" + value
);
return("STORED" == trim(response));
}
bool memcache_delete(u64 connection, String key)
{
String response = memcache_command(connection, String("delete ") + memcache_escape_key(key));
return("DELETED" == trim(response));
}
String memcache_get(u64 connection, String key, String default_value)
{
String res = memcache_command(connection, String("get ") + memcache_escape_key(key));
String header_end = "\r\n";
size_t header_pos = res.find(header_end);
if(res.rfind("VALUE ", 0) != 0 || header_pos == String::npos)
return(default_value);
String header = res.substr(0, header_pos);
StringList parts = split(header, " ");
if(parts.size() < 4)
return(default_value);
u64 length = int_val(parts[3]);
size_t data_pos = header_pos + header_end.size();
if(res.size() < data_pos + length)
return(default_value);
return(res.substr(data_pos, length));
}
StringMap memcache_get_multiple(u64 connection, StringList keys)
{
StringMap result;
String res = memcache_command(connection, String("get ") + join(memcache_escape_keys(keys), " "));
while(res.rfind("VALUE ", 0) == 0)
{
size_t header_pos = res.find("\r\n");
if(header_pos == String::npos)
break;
StringList parts = split(res.substr(0, header_pos), " ");
if(parts.size() < 4)
break;
String key = parts[1];
u64 length = int_val(parts[3]);
size_t data_pos = header_pos + 2;
if(res.size() < data_pos + length)
break;
result[key] = res.substr(data_pos, length);
res = res.substr(data_pos + length);
if(res.rfind("\r\n", 0) == 0)
res = res.substr(2);
}
return(result);
}
void on_segfault(int sig) { (void)sig; }
static u64 wasm_next_task_callback_id = 1;
@@ -195,8 +284,18 @@ pid_t task_repeat(String key, f64 interval, std::function<void()> exec_after_spa
pid_t task_pid(String key) { return((pid_t)uce_host_task_pid(key.data(), key.size())); }
extern "C" unsigned int sleep(unsigned int seconds) { return(uce_host_sleep_us((uint64_t)seconds * 1000000ull)); }
extern "C" int usleep(unsigned int usec) { uce_host_sleep_us(usec); return(0); }
pid_t server_start_http(String key, String socket_fn_or_port, String call_uce_filename, String call_function) { (void)key; (void)socket_fn_or_port; (void)call_uce_filename; (void)call_function; return(0); }
bool server_stop(String key) { (void)key; return(false); }
pid_t server_start_http(String key, String socket_fn_or_port, String call_uce_filename, String call_function)
{
String current = wasm_current_unit_file();
return((pid_t)uce_host_server_start_http(
key.data(), key.size(),
socket_fn_or_port.data(), socket_fn_or_port.size(),
call_uce_filename.data(), call_uce_filename.size(),
call_function.data(), call_function.size(),
current.data(), current.size()
));
}
bool server_stop(String key) { return(uce_host_server_stop(key.data(), key.size()) != 0); }
StringMap default_config()
{
StringMap cfg;
@@ -219,6 +318,10 @@ StringMap default_config()
#include "sys.h"
#include "hash.h"
// Single definitions for the native split build (declared extern in sys.h).
pid_t parent_pid = 0;
pid_t my_pid = 0;
namespace {
constexpr f64 FILE_LOCK_WAIT_TIMEOUT_SECONDS = 3.0;
+7
View File
@@ -100,8 +100,15 @@ bool memcache_delete(u64 connection, String key);
String memcache_get(u64 connection, String key, String default_value = "");
StringMap memcache_get_multiple(u64 connection, StringList keys);
// Defined once in sys.cpp for the native split build (core/wasm/main); the wasm
// core/unit builds keep the in-place definition (single TU / loader-resolved).
#if defined(__UCE_WASM_CORE__) || defined(__UCE_WASM_UNIT__)
pid_t parent_pid = 0;
pid_t my_pid = 0;
#else
extern pid_t parent_pid;
extern pid_t my_pid;
#endif
void on_segfault(int sig);
int task_kill(pid_t pid, int sig = 0);
+26
View File
@@ -16,6 +16,32 @@
#include "types.h"
// Single definition of context for the native split build (the wasm core/unit
// builds keep the in-place definition from types.h — see the guard there).
#if !defined(__UCE_WASM_CORE__) && !defined(__UCE_WASM_UNIT__)
Request* context = 0;
#endif
#ifndef __UCE_WASM_UNIT__
void * operator new(decltype(sizeof(0)) n) noexcept(false)
{
void* ptr = malloc(n);
if(context)
{
context->stats.mem_alloc += n;
if(context->stats.mem_alloc > context->stats.mem_high)
context->stats.mem_high = context->stats.mem_alloc;
}
return(ptr);
}
void operator delete(void * p) throw()
{
//TO DO: track deallocations
free(p);
}
#endif
namespace {
String http_status_reason(s32 code)
+20 -26
View File
@@ -22,27 +22,29 @@ typedef long long s64;
typedef std::string String;
String operator+(String lhs, u64 rhs) {
// inline: this header is included by every runtime object (core/wasm/main); a
// non-inline definition here would be a duplicate symbol across them.
inline String operator+(String lhs, u64 rhs) {
return(lhs + std::to_string(rhs));
}
String operator+(String lhs, u32 rhs) {
inline String operator+(String lhs, u32 rhs) {
return(lhs + std::to_string(rhs));
}
String operator+(String lhs, s64 rhs) {
inline String operator+(String lhs, s64 rhs) {
return(lhs + std::to_string(rhs));
}
String operator+(String lhs, s32 rhs) {
inline String operator+(String lhs, s32 rhs) {
return(lhs + std::to_string(rhs));
}
String operator+(String lhs, f64 rhs) {
inline String operator+(String lhs, f64 rhs) {
return(lhs + std::to_string(rhs));
}
String operator+(String lhs, f32 rhs) {
inline String operator+(String lhs, f32 rhs) {
return(lhs + std::to_string(rhs));
}
@@ -59,7 +61,7 @@ typedef void (*request_ref_handler)(Request& request);
typedef DValue* (*dv_call_handler)(DValue* call_param);
typedef void (*request_handler)(Request* request);
String to_string(s64 v) { return(std::to_string(v)); }
inline String to_string(s64 v) { return(std::to_string(v)); }
struct SharedUnit {
@@ -242,7 +244,15 @@ struct Request {
typedef Request FastCGIRequest;
// Native runtime is split across objects (core/wasm/main), so context is
// declared here and defined once in types.cpp. The wasm core is a single TU and
// wasm units resolve context through the loader's GOT, so both keep the
// in-place definition (unchanged ABI).
#if defined(__UCE_WASM_CORE__) || defined(__UCE_WASM_UNIT__)
Request* context;
#else
extern Request* context;
#endif
#include <iostream>
@@ -268,22 +278,6 @@ inline String concat(Ts... args)
return(out.str());
}
#ifndef __UCE_WASM_UNIT__
void * operator new(decltype(sizeof(0)) n) noexcept(false)
{
void* ptr = malloc(n);
if(context)
{
context->stats.mem_alloc += n;
if(context->stats.mem_alloc > context->stats.mem_high)
context->stats.mem_high = context->stats.mem_alloc;
}
return(ptr);
}
void operator delete(void * p) throw()
{
//TO DO: track deallocations
free(p);
}
#endif
// The global allocator override (request memory accounting) is defined once in
// types.cpp so it lives in a single runtime object; a header definition would
// duplicate it across core/wasm/main. Units (__UCE_WASM_UNIT__) import it.
+43 -13
View File
@@ -1,5 +1,8 @@
#include "lib/uce_lib.cpp"
#include "wasm/backend.cpp"
// The wasm backend is its own object (src/wasm/wasm_module.cpp → wasm.o); the
// main object only needs its declarations, so editing the wasm runtime no
// longer recompiles the whole native TU.
#include "wasm/backend.h"
#include <csetjmp>
#include <deque>
#include <errno.h>
@@ -853,7 +856,20 @@ int handle_cli_complete(FastCGIRequest& request)
request.params["SCRIPT_FILENAME"] = script_filename;
prepare_request_body_maps(request);
request.props = DValue();
compiler_invoke_cli(&request, script_filename);
// The CLI socket path installs no native fault handler, so the
// wasm worker (whose traps are signal-based) can run directly.
String cli_unit = compiler_normalize_unit_path(&request, script_filename);
if(wasm_backend_should_handle(request, cli_unit))
{
String wasm_error = wasm_backend_serve(request, cli_unit, wasm_kind::CLI);
if(wasm_error != "")
{
request.set_status(500, "Internal Server Error");
print("UCE CLI wasm error: ", wasm_error, "\n");
}
}
else
compiler_invoke_cli(&request, script_filename);
}
}
}
@@ -937,18 +953,15 @@ int handle_complete(FastCGIRequest& request) {
{
prepare_request_body_maps(request);
request.props = DValue();
if(request.resources.is_cli)
compiler_invoke_cli(&request, request.params["SCRIPT_FILENAME"]);
else if(request.params["UCE_SERVE_HTTP"] == "1")
compiler_invoke_serve_http(&request, request.params["SCRIPT_FILENAME"], request.params["UCE_SERVE_HTTP_FUNCTION"]);
else if(wasm_backend_should_handle(request, compiler_normalize_unit_path(&request, request.params["SCRIPT_FILENAME"])))
{
// W4/W5: Wasmtime uses host signals internally to implement guest
// traps. The native SIGSEGV/SIGILL request recovery handler must not
// intercept those, or clean guest traps become native fatal signals.
// Suspend the native SIGSEGV/SIGILL request recovery handler around a
// wasm invocation: Wasmtime uses host signals internally to implement
// guest traps, and the native handler would otherwise turn a clean
// guest trap into a native fatal signal. Sets failure_* on error.
auto serve_via_wasm = [&](const String& entry_unit, int32_t kind) {
request_fault_active = 0;
restore_request_fault_handlers();
String wasm_error = wasm_backend_serve(request, compiler_normalize_unit_path(&request, request.params["SCRIPT_FILENAME"]));
String wasm_error = wasm_backend_serve(request, entry_unit, kind);
install_request_fault_handlers();
request_fault_active = 1;
if(wasm_error != "")
@@ -957,7 +970,25 @@ int handle_complete(FastCGIRequest& request) {
failure_details = "";
failure_trace = wasm_error;
}
};
String entry_unit = compiler_normalize_unit_path(&request, request.params["SCRIPT_FILENAME"]);
if(request.resources.is_cli)
{
if(wasm_backend_should_handle(request, entry_unit))
serve_via_wasm(entry_unit, wasm_kind::CLI);
else
compiler_invoke_cli(&request, request.params["SCRIPT_FILENAME"]);
}
else if(request.params["UCE_SERVE_HTTP"] == "1")
// W7c pending: the custom-server dispatcher is forked from a worker
// that already holds a live Wasmtime engine, so re-creating an engine
// in that child currently hangs under the in-process dispatcher path.
// The fix is to have the broker forward to the worker pool rather than
// render in the fork; until then serve_http renders natively.
compiler_invoke_serve_http(&request, request.params["SCRIPT_FILENAME"], request.params["UCE_SERVE_HTTP_FUNCTION"]);
else if(wasm_backend_should_handle(request, entry_unit))
serve_via_wasm(entry_unit, wasm_kind::RENDER);
else
compiler_invoke(&request, request.params["SCRIPT_FILENAME"]);
}
@@ -1220,7 +1251,6 @@ void custom_server_http_dispatcher_loop(String key)
custom_server_dispatcher_key = key;
close_inherited_server_sockets();
install_process_fault_handlers();
StringMap cfg = custom_server_read_config(key);
if(cfg["type"] != "http" || cfg["bind"] == "")
{
+35 -17
View File
@@ -22,7 +22,7 @@
// per forked worker process: one engine + compiled-core cache, one epoch ticker
static WasmWorker* g_wasm_worker = 0;
static std::thread g_wasm_epoch_ticker;
static std::thread* g_wasm_epoch_ticker = 0;
static std::atomic<bool> g_wasm_epoch_running(false);
static String g_wasm_init_error;
static bool g_wasm_init_attempted = false;
@@ -70,7 +70,7 @@ static String wasm_backend_ensure_started(Request* context)
g_wasm_epoch_running.store(true);
WasmWorker* worker = g_wasm_worker;
u64 period_ms = config_u64("WASM_EPOCH_PERIOD_MS", 50);
g_wasm_epoch_ticker = std::thread([worker, period_ms] {
g_wasm_epoch_ticker = new std::thread([worker, period_ms] {
while(g_wasm_epoch_running.load())
{
std::this_thread::sleep_for(std::chrono::milliseconds(period_ms));
@@ -117,14 +117,13 @@ static bool wasm_backend_native_fallback_uncached(Request* context, const String
// - regex_* (host PCRE2 hostcall), xml_*/yaml_*/markdown_* (compiled in),
// - unit_render()/component() (host resolver).
// What remains is genuinely host-owned / native-only for now:
// - zip, sockets/custom servers, memcache, mysql;
// - unit_call + compiler/unit introspection (need the native toolchain).
// Background tasks and sleep/usleep are host-owned but now have membrane
// hostcalls, so they intentionally do not fallback here.
// - zip pages that use C++ try/catch around zip_* error cases;
// - direct compiler_load_shared_unit() access to native SharedUnit internals.
// Background tasks, sleep/usleep, sockets/custom servers, memcache, mysql,
// and unit_call/unit_compile/unit_info/units_list are host-owned but now have
// membrane hostcalls, so they intentionally do not fallback here.
StringList native_only_tokens = {
"zip_", "socket_", "server_start_http(", "server_stop(",
"memcache_", "mysql_", "unit_call(",
"unit_compile(", "unit_info(", "units_list(", "compiler_load_shared_unit("
"zip_", "compiler_load_shared_unit("
};
for(auto& token : native_only_tokens)
if(source.find(token) != String::npos)
@@ -152,13 +151,13 @@ static bool wasm_backend_native_fallback_needed(Request* context, const String&
// True if this request should be served by the wasm backend. Falls through to
// native when disabled, when init failed, or when the unit has no wasm artifact
// (e.g. units skip-listed for try/catch — automatic, graceful fallback).
// (e.g. units skip-listed for try/catch — automatic, graceful fallback). Mode-
// agnostic: the caller (page render / cli / serve_http) decides which entry to
// route here; this only gates on config, fallback tokens, artifact, and worker.
bool wasm_backend_should_handle(Request& request, const String& entry_unit)
{
if(!wasm_backend_configured(&request))
return(false);
if(request.resources.is_cli)
return(false);
if(wasm_backend_native_fallback_needed(&request, entry_unit))
return(false);
if(!wasm_artifact_exists(&request, entry_unit))
@@ -168,11 +167,12 @@ bool wasm_backend_should_handle(Request& request, const String& entry_unit)
return(true);
}
// Serve the page render through a wasm workspace. Populates the native Request
// Serve a request through a wasm workspace using the unit handler selected by
// `kind` (page render or cli). Populates the native Request
// (status/headers/cookies/session/body) so the existing transport writes the
// response unchanged. Returns "" on success, or a collapsed error/trace string
// for the caller to route into the configured error page.
String wasm_backend_serve(Request& request, const String& entry_unit)
String wasm_backend_serve(Request& request, const String& entry_unit, int32_t kind = WasmWorkspace::RESOLVE_RENDER)
{
DValue ctx;
auto copy_map = [&](const StringMap& source, const char* key) {
@@ -185,11 +185,23 @@ String wasm_backend_serve(Request& request, const String& entry_unit)
copy_map(request.cookies, "cookies");
copy_map(request.session, "session");
ctx["entry_unit"] = entry_unit;
// Raw request body: cli_input() parses a JSON CLI payload from context.in,
// carried into the workspace, not just the form-decoded post map.
ctx["in"] = request.in;
WasmResponse response = wasm_worker_serve(*g_wasm_worker, ctx, entry_unit);
WasmResponse response = wasm_worker_serve(*g_wasm_worker, ctx, entry_unit, kind);
if(!response.ok)
return(response.error == "" ? String("wasm workspace failed") : response.error);
// A cli unit that does not export __uce_cli is a 404, matching native
// compiler_invoke_cli ("CLI Entry Point Not Found"). For page render a
// missing handler is simply an empty body (native parity).
if(!response.handler_present && kind == WasmWorkspace::RESOLVE_CLI)
{
request.set_status(404, "CLI Entry Point Not Found");
return("");
}
// Diagnostic timing headers are opt-in: they leak workspace internals and
// belong to the W5 benchmark harness, not public responses.
if(config_bool("WASM_BACKEND_VERBOSE", false))
@@ -235,6 +247,12 @@ String wasm_backend_serve(Request& request, const String& entry_unit)
// are usually killed, but a clean ager-out path should join the thread).
void wasm_backend_shutdown()
{
if(g_wasm_epoch_running.exchange(false) && g_wasm_epoch_ticker.joinable())
g_wasm_epoch_ticker.join();
if(g_wasm_epoch_ticker)
{
g_wasm_epoch_running.store(false);
if(g_wasm_epoch_ticker->joinable())
g_wasm_epoch_ticker->join();
delete g_wasm_epoch_ticker;
g_wasm_epoch_ticker = 0;
}
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
// Public surface of the wasm backend object (src/wasm/backend.cpp → wasm.o).
//
// The native main object (linux_fastcgi.cpp) includes this header — not
// backend.cpp — so it does not have to compile worker.cpp + wasmtime.hh on
// every build. Include only after uce_lib.h (needs Request / String).
#include <cstdint>
// Resolve-kind selector for wasm_backend_serve, mirrored from
// WasmWorkspace::ResolveKind in worker.cpp. Only the entry kinds actually wired
// to the wasm backend live here; WebSocket/serve_http re-enter when W7b/W7c land
// (via the broker→worker-pool forwarding model, not in-fork rendering).
namespace wasm_kind {
enum {
RENDER = 1,
CLI = 4,
};
}
// True if this request should be served by the wasm backend (config + artifact
// + fallback-token gate); mode-agnostic, the caller picks the kind.
bool wasm_backend_should_handle(Request& request, const String& entry_unit);
// Serve a request through a wasm workspace using the unit handler for `kind`,
// populating the native Request. Returns "" on success or a collapsed error.
String wasm_backend_serve(Request& request, const String& entry_unit,
int32_t kind = wasm_kind::RENDER);
// Join the per-process epoch ticker before the worker process exits.
void wasm_backend_shutdown();
+330 -22
View File
@@ -10,32 +10,323 @@
#include "../lib/mysql-connector.h"
#include "../lib/sqlite-connector.h"
// ---- W3 connector membrane stubs -------------------------------------------
// Generated units reference the connector class surface through uce_lib.h, so
// the core must define it. Until the real hostcall connectors land (W5 parity
// scope: the starter uses no database), every operation fails cleanly with an
// explanatory error instead of trapping.
// ---- W3 connector membranes -----------------------------------------------
// sqlite/mysql run host-side (the host links the native connectors and owns the
// connections in per-workspace handle tables). UCEB1-marshalled hostcalls carry
// operation requests/responses; `connection` holds the host handle (>0).
static const char* WASM_DB_UNAVAILABLE =
"database connectors are not yet available in the wasm workspace";
"database connector is not available in the wasm workspace";
extern "C" size_t uce_host_mysql(const char* in, size_t in_len, char* out, size_t cap);
extern "C" size_t uce_host_zip(const char* in, size_t in_len, char* out, size_t cap);
extern "C" size_t uce_host_units(const char* in, size_t in_len, char* out, size_t cap);
static DValue wasm_sized_hostcall(DValue request, size_t (*hostcall)(const char*, size_t, char*, size_t))
{
String encoded = ucb_encode(request);
size_t need = hostcall(encoded.data(), encoded.size(), 0, 0);
if(need == 0)
return(DValue());
String buffer(need, 0);
size_t got = hostcall(encoded.data(), encoded.size(), &buffer[0], need);
if(got == 0 || got > need)
return(DValue());
DValue response;
String error;
ucb_decode(String(buffer.data(), got), response, &error);
return(response);
}
static DValue wasm_zip_call(DValue request)
{
DValue response = wasm_sized_hostcall(request, uce_host_zip);
return(response);
}
DValue zip_list(String zip_file_name)
{
DValue request;
request["op"] = "list";
request["path"] = zip_file_name;
DValue response = wasm_zip_call(request);
DValue* result = response.key("result");
return(result ? *result : DValue());
}
String zip_read(String zip_file_name, String entry_name)
{
DValue request;
request["op"] = "read";
request["path"] = zip_file_name;
request["entry"] = entry_name;
DValue response = wasm_zip_call(request);
return(response["result"].to_string());
}
bool zip_create(String zip_file_name, DValue entries)
{
DValue request;
request["op"] = "create";
request["path"] = zip_file_name;
request["entries"] = entries;
DValue response = wasm_zip_call(request);
return(response["ok"].to_bool());
}
bool zip_extract(String zip_file_name, String destination_directory)
{
DValue request;
request["op"] = "extract";
request["path"] = zip_file_name;
request["destination"] = destination_directory;
DValue response = wasm_zip_call(request);
return(response["ok"].to_bool());
}
String gz_compress(String src)
{
DValue request;
request["op"] = "gz_compress";
request["src"] = src;
DValue response = wasm_zip_call(request);
return(response["result"].to_string());
}
String gz_uncompress(String compressed)
{
DValue request;
request["op"] = "gz_uncompress";
request["src"] = compressed;
DValue response = wasm_zip_call(request);
return(response["result"].to_string());
}
static DValue wasm_units_call(DValue request)
{
return(wasm_sized_hostcall(request, uce_host_units));
}
DValue unit_info(String path)
{
DValue request;
request["op"] = "info";
request["path"] = path;
DValue response = wasm_units_call(request);
DValue* result = response.key("result");
return(result ? *result : DValue());
}
StringList units_list()
{
DValue request;
request["op"] = "list";
DValue response = wasm_units_call(request);
StringList result;
DValue* items = response.key("result");
if(items)
items->each([&](const DValue& value, String) { result.push_back(value.to_string()); });
return(result);
}
bool unit_compile(String path)
{
DValue request;
request["op"] = "compile";
request["path"] = path;
DValue response = wasm_units_call(request);
return(response["ok"].to_bool());
}
static DValue wasm_unit_call_result;
DValue* unit_call(String file_name, String function_name, DValue* call_param)
{
DValue request;
request["op"] = "call";
request["file"] = file_name;
request["function"] = function_name;
if(call_param)
request["param"] = *call_param;
DValue response = wasm_units_call(request);
String output = response["output"].to_string();
if(output != "")
print(output);
DValue* result = response.key("result");
if(result)
{
wasm_unit_call_result = *result;
return(&wasm_unit_call_result);
}
return(0);
}
SharedUnit* compiler_load_shared_unit(Request* context, String file_name, String current_path, bool opt_so_optional)
{
(void)context; (void)file_name; (void)current_path; (void)opt_so_optional;
return(0);
}
static DValue wasm_mysql_call(DValue request)
{
return(wasm_sized_hostcall(request, uce_host_mysql));
}
bool MySQL::connect(String host, String username, String password)
{
(void)host; (void)username; (void)password;
connection = 0;
statement_info = WASM_DB_UNAVAILABLE;
DValue request;
request["op"] = "connect";
request["host"] = host;
request["username"] = username;
request["password"] = password;
DValue response = wasm_mysql_call(request);
u64 handle = response["handle"].to_u64();
connection = (void*)(uintptr_t)handle;
_preload_next_error_code = (u32)response["error_code"].to_u64();
statement_info = response["statement_info"].to_string();
return(handle != 0 && _preload_next_error_code == 0);
}
void MySQL::disconnect()
{
if(connection)
{
DValue request;
request["op"] = "disconnect";
request["handle"] = (f64)(uintptr_t)connection;
wasm_mysql_call(request);
connection = 0;
}
}
String MySQL::error()
{
String result = statement_info;
statement_info = "";
_preload_next_error_code = 0;
return(result);
}
String MySQL::escape(String raw, char quote_char) { return(mysql_escape(raw, quote_char)); }
String mysql_escape(String raw, char quote_char)
{
DValue request;
request["op"] = "escape";
request["raw"] = raw;
request["quote_char"] = String(quote_char > 0 ? 1 : 0, quote_char);
DValue response = wasm_mysql_call(request);
DValue* result = response.key("result");
return(result ? result->to_string() : raw);
}
String MySQL::parse_query_parameters(String query, StringMap map)
{
String result;
query.append(1, ' ');
u8 mode = 0;
char quote = 0;
String identifier;
for(u32 i = 0; i < query.length(); i++)
{
char c = query[i];
if(mode == 0)
{
if(c == ':')
{
mode = 1;
identifier = "";
}
else if(c == '"' || c == '\'')
{
result.append(1, c);
mode = 2;
quote = c;
}
else
result.append(1, c);
}
else if(mode == 1)
{
if(isalnum(c))
identifier.append(1, c);
else
{
result.append(escape(map[identifier]));
result.append(1, c);
mode = 0;
}
}
else if(mode == 2)
{
if(c == quote)
mode = 0;
result.append(1, c);
}
}
return(result);
}
static bool wasm_mysql_has_unquoted_positional_placeholder(String query)
{
bool quoted = false;
char quote = 0;
bool escaped = false;
for(u32 i = 0; i < query.length(); i++)
{
char c = query[i];
if(quoted)
{
if(escaped)
{
escaped = false;
continue;
}
if(c == '\\')
{
escaped = true;
continue;
}
if(c == quote)
quoted = false;
continue;
}
if(c == '\'' || c == '"')
{
quoted = true;
quote = c;
continue;
}
if(c == '?')
return(true);
}
return(false);
}
void MySQL::disconnect() { connection = 0; }
String MySQL::error() { return(WASM_DB_UNAVAILABLE); }
String MySQL::escape(String raw, char quote_char) { (void)quote_char; return(raw); }
String MySQL::parse_query_parameters(String query, StringMap m) { (void)m; return(query); }
DValue MySQL::query(String q) { (void)q; statement_info = WASM_DB_UNAVAILABLE; return(DValue()); }
DValue MySQL::query(String q, StringMap params) { (void)q; (void)params; statement_info = WASM_DB_UNAVAILABLE; return(DValue()); }
DValue MySQL::get_pending_result() { return(DValue()); }
DValue MySQL::query(String q)
{
if(wasm_mysql_has_unquoted_positional_placeholder(q))
{
_preload_next_error_code = 2000;
statement_info = "mysql positional ? placeholders are not supported; use named :name placeholders";
return(DValue());
}
DValue request;
request["op"] = "query";
request["handle"] = (f64)(uintptr_t)connection;
request["query"] = q;
DValue response = wasm_mysql_call(request);
insert_id = response["insert_id"].to_u64();
affected_rows = (u32)response["affected"].to_u64();
_preload_next_error_code = (u32)response["error_code"].to_u64();
statement_info = response["statement_info"].to_string();
DValue* result = response.key("result");
return(result ? *result : DValue());
}
String mysql_escape(String raw, char quote_char) { (void)quote_char; return(raw); }
DValue MySQL::query(String q, StringMap params) { return(query(parse_query_parameters(q, params))); }
DValue MySQL::get_pending_result() { return(DValue()); }
// sqlite runs host-side (the host links libsqlite and owns the connections in
// a per-workspace handle table). One UCEB1-marshalled hostcall carries
@@ -129,6 +420,7 @@ DValue sqlite_query(SQLite* db, String q, const StringMap& params) { return(db ?
u64 sqlite_insert_id(SQLite* db) { return(db ? db->insert_id : 0); }
u32 sqlite_affected_rows(SQLite* db) { return(db ? db->affected_rows : 0); }
void cleanup_sqlite_connections() { }
void cleanup_mysql_connections() { }
static ServerState wasm_server;
static Request wasm_request;
@@ -248,6 +540,7 @@ enum WasmResolveKind {
WASM_RESOLVE_RENDER = 1,
WASM_RESOLVE_EXISTS = 2,
WASM_RESOLVE_ONCE = 3,
WASM_RESOLVE_CLI = 4,
};
static s32 wasm_resolve_target(String target, s32 kind, String* resolved_out = 0)
@@ -360,13 +653,26 @@ void unit_render(String file_name) { unit_render(file_name, *context); }
extern "C" {
// Host calls this to render the request's entry unit. Routing through
// unit_render gives the entry the same ONCE() + dispatch semantics as
// components (the host pre-loaded it, so resolution is a cache hit).
void uce_wasm_render_entry(const char* path, size_t len)
// Host calls this to invoke the request's entry unit through one of its
// directive handlers (render/cli/websocket/serve_http). Routing through the
// resolver gives the entry the same ONCE() + dispatch semantics as components
// (the host pre-loaded it, so resolution is a cache hit). The host pre-checks
// that the handler export exists, so a missing slot here is a silent no-op.
void uce_wasm_invoke_entry(const char* path, size_t len, int32_t kind)
{
// the host always runs uce_wasm_core_init + apply_context before this
unit_render(String(path, len), *context);
String file_name(path, len);
String resolved;
s32 slot = wasm_resolve_target(trim(file_name), kind, &resolved);
if(!slot)
return;
wasm_run_once(resolved, *context);
String previous_unit = context->resources.current_unit_file;
if(resolved != "")
context->resources.current_unit_file = resolved;
request_ref_handler handler = (request_ref_handler)(uintptr_t)slot;
handler(*context);
context->resources.current_unit_file = previous_unit;
}
void* uce_alloc(size_t len)
@@ -453,6 +759,8 @@ int uce_wasm_apply_context(const char* buf, size_t len)
DValue* entry = decoded.key("entry_unit");
if(entry)
wasm_request.resources.current_unit_file = entry->to_string();
DValue* raw_in = decoded.key("in");
wasm_request.in = raw_in ? raw_in->to_string() : "";
return(0);
}
+10
View File
@@ -7,6 +7,16 @@ uce_host_task_spawn
uce_host_task_pid
uce_host_task_kill
uce_host_sleep_us
uce_host_socket_connect
uce_host_socket_close
uce_host_socket_write
uce_host_socket_read
uce_host_server_start_http
uce_host_server_stop
uce_host_memcache_command
uce_host_mysql
uce_host_zip
uce_host_units
uce_host_component_resolve
uce_host_file_exists
uce_host_file_read
+9
View File
@@ -0,0 +1,9 @@
// Translation unit for the wasm backend object (wasm.o).
//
// Pulls in the runtime *declarations* (uce_lib.h) and then the backend
// definitions, so the heavy wasmtime.hh + worker.cpp only recompile when the
// wasm sources change — not on every native build. The symbols it references
// (String/DValue/config/connectors/unit_*/socket_*) are defined in the core
// (main) object and resolved at link.
#include "../lib/uce_lib.h"
#include "backend.cpp"
+349 -10
View File
@@ -35,6 +35,9 @@
#include <memory>
#include <optional>
#include <string>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
#include <sys/time.h>
@@ -81,6 +84,7 @@ struct WasmWorkerConfig
struct WasmResponse
{
bool ok = false;
bool handler_present = true; // false → unit has no handler for the requested kind (404)
String body;
DValue meta; // status / headers / cookies / session
String error; // collapsed trace or loader error when !ok
@@ -345,11 +349,15 @@ public:
// Host-owned resource handle table (§3.1): connections opened by the guest
// live here and are closed when the workspace drops at request end.
std::vector<SQLite*> sqlite_handles;
std::vector<MySQL*> mysql_handles;
~WasmWorkspace()
{
for(auto* db : sqlite_handles)
if(db)
delete db; // ~SQLite disconnects
for(auto* db : mysql_handles)
if(db)
delete db; // ~MySQL disconnects
}
#endif
@@ -359,6 +367,10 @@ public:
// input bytes) and replayed on the second without re-running the op.
String staged_hostcall_input;
String staged_hostcall_result;
String staged_socket_read_key;
String staged_socket_read_result;
String staged_memcache_key;
String staged_memcache_result;
bool hostcall_staged(const String& input, String& out)
{
if(!staged_hostcall_input.empty() && input == staged_hostcall_input)
@@ -376,7 +388,8 @@ public:
// resolve-kind values shared with the guest core (src/wasm/core.cpp)
// must match WasmResolveKind in src/wasm/core.cpp
enum ResolveKind { RESOLVE_COMPONENT = 0, RESOLVE_RENDER = 1, RESOLVE_EXISTS = 2, RESOLVE_ONCE = 3 };
enum ResolveKind { RESOLVE_COMPONENT = 0, RESOLVE_RENDER = 1, RESOLVE_EXISTS = 2, RESOLVE_ONCE = 3,
RESOLVE_CLI = 4 };
String birth()
{
@@ -469,21 +482,29 @@ public:
return("");
}
String render_entry(const String& entry_source_path)
// Invoke the entry unit through one of its directive handlers
// (render/cli/websocket/serve_http, selected by kind). handler_present, when
// provided, reports whether the unit actually exports that handler — the
// caller maps a missing render to an empty body (native parity) and a
// missing cli/serve handler to a 404.
String invoke_entry(const String& entry_source_path, int32_t kind, bool* handler_present = 0)
{
entry_dir = dir_of(entry_source_path);
size_t unit_index = 0;
String error = load_unit(entry_source_path, unit_index);
if(error != "")
return(error);
// a page with no RENDER block renders empty (native parity), not an error
if(!unit_func(unit_index, "__uce_render"))
String symbol = kind == RESOLVE_CLI ? "__uce_cli" : "__uce_render";
bool present = (bool)unit_func(unit_index, symbol);
if(handler_present)
*handler_present = present;
if(!present)
return("");
// Render through the core so the entry gets the same ONCE() + dispatch
// Invoke through the core so the entry gets the same ONCE() + dispatch
// path as components (unit is pre-loaded above; resolution is cached).
auto entry = core_func("uce_wasm_render_entry");
auto entry = core_func("uce_wasm_invoke_entry");
if(!entry)
return("core does not export uce_wasm_render_entry");
return("core does not export uce_wasm_invoke_entry");
int32_t guest_ptr = 0;
error = call_core("uce_alloc", { (int32_t)entry_source_path.size() }, &guest_ptr);
if(error != "" || guest_ptr == 0)
@@ -491,13 +512,18 @@ public:
error = guest_write((u32)guest_ptr, entry_source_path);
if(error != "")
return(error);
auto result = entry->call(ctx(), { wasmtime::Val(guest_ptr), wasmtime::Val((int32_t)entry_source_path.size()) });
auto result = entry->call(ctx(), { wasmtime::Val(guest_ptr), wasmtime::Val((int32_t)entry_source_path.size()), wasmtime::Val(kind) });
call_core("uce_free", { guest_ptr }, 0);
if(!result)
return(trap_text(result.err()));
return("");
}
String render_entry(const String& entry_source_path)
{
return(invoke_entry(entry_source_path, RESOLVE_RENDER));
}
String collect(WasmResponse& response)
{
String error = call_core("uce_wasm_finish_output", {}, 0);
@@ -1044,6 +1070,7 @@ private:
}
String symbol = kind == RESOLVE_RENDER ? "__uce_render"
: kind == RESOLVE_ONCE ? "__uce_once"
: kind == RESOLVE_CLI ? "__uce_cli"
: "__uce_component";
if(render_name != "")
symbol += "_" + sanitize_symbol_suffix(render_name);
@@ -1193,6 +1220,120 @@ private:
results[0] = Val(ok ? (int32_t)1 : (int32_t)0);
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_zip")
return(add([self](Caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
String encoded;
self->hostcall_read(args[0].i32(), args[1].i32(), encoded);
String out;
if(!self->hostcall_staged(encoded, out))
{
DValue request, response;
String decode_error;
try
{
if(ucb_decode(encoded, request, &decode_error))
{
String op = request["op"].to_string();
if(op == "list")
{
String path = self->resolve_guest_file(request["path"].to_string());
if(path == "") throw std::runtime_error("zip_list: path is outside wasm file policy");
response["result"] = zip_list(path);
}
else if(op == "read")
{
String path = self->resolve_guest_file(request["path"].to_string());
if(path == "") throw std::runtime_error("zip_read: path is outside wasm file policy");
response["result"] = zip_read(path, request["entry"].to_string());
}
else if(op == "create")
{
String path = self->resolve_guest_write(request["path"].to_string(), "");
if(path == "") throw std::runtime_error("zip_create: path is outside wasm file policy");
DValue* entries = request.key("entries");
response["ok"].set_bool(zip_create(path, entries ? *entries : DValue()));
}
else if(op == "extract")
{
String path = self->resolve_guest_file(request["path"].to_string());
String destination = self->resolve_guest_write(request["destination"].to_string(), "");
if(path == "" || destination == "") throw std::runtime_error("zip_extract: path is outside wasm file policy");
response["ok"].set_bool(zip_extract(path, destination));
}
else if(op == "gz_compress")
response["result"] = gz_compress(request["src"].to_string());
else if(op == "gz_uncompress")
response["result"] = gz_uncompress(request["src"].to_string());
}
}
catch(const std::exception& e)
{
response["error"] = e.what();
}
out = ucb_encode(response);
self->hostcall_stage(encoded, out);
}
u32 cap = (u32)args[3].i32();
int32_t buf = args[2].i32();
if(buf != 0 && cap >= out.size())
self->hostcall_write(buf, out);
results[0] = Val((int32_t)out.size());
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_units")
return(add([self](Caller caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
String encoded;
self->hostcall_read(args[0].i32(), args[1].i32(), encoded);
String out;
if(!self->hostcall_staged(encoded, out))
{
DValue request, response;
String decode_error;
try
{
if(ucb_decode(encoded, request, &decode_error))
{
String op = request["op"].to_string();
if(op == "info")
response["result"] = unit_info(request["path"].to_string());
else if(op == "list")
{
StringList paths = units_list();
for(auto& path : paths)
{
DValue item;
item = path;
response["result"].push(item);
}
}
else if(op == "compile")
response["ok"].set_bool(unit_compile(request["path"].to_string()));
else if(op == "call")
{
DValue* param = request.key("param");
ob_start();
DValue* result = unit_call(request["file"].to_string(), request["function"].to_string(), param);
response["output"] = ob_get_close();
if(result)
response["result"] = *result;
}
}
}
catch(const std::exception& e)
{
response["error"] = e.what();
}
out = ucb_encode(response);
self->hostcall_stage(encoded, out);
}
u32 cap = (u32)args[3].i32();
int32_t buf = args[2].i32();
if(buf != 0 && cap >= out.size())
self->hostcall_write(buf, out);
caller.context().set_epoch_deadline(self->worker.cfg.epoch_deadline_ticks);
results[0] = Val((int32_t)out.size());
return(std::monostate());
}));
#ifdef UCE_WASM_HOST_CONNECTORS
if(mod == "env" && name == "uce_host_sqlite")
return(add([self](Caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
@@ -1260,6 +1401,99 @@ private:
results[0] = Val((int32_t)out.size());
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_memcache_command")
return(add([self](Caller caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
String command;
self->hostcall_read(args[1].i32(), args[2].i32(), command);
String key = "memcache:" + std::to_string((u64)args[0].i64()) + ":" + command;
u32 cap = (u32)args[4].i32();
int32_t buf = args[3].i32();
String out;
if(buf != 0 && self->staged_memcache_key == key)
{
out = self->staged_memcache_result;
self->staged_memcache_key = "";
self->staged_memcache_result = "";
}
else
{
::socket_write((u64)args[0].i64(), command + "\r\n");
out = ::socket_read((u64)args[0].i64());
if(buf == 0)
{
self->staged_memcache_key = key;
self->staged_memcache_result = out;
}
}
if(buf != 0 && cap >= out.size())
self->hostcall_write(buf, out);
caller.context().set_epoch_deadline(self->worker.cfg.epoch_deadline_ticks);
results[0] = Val((int32_t)out.size());
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_mysql")
return(add([self](Caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
String encoded;
self->hostcall_read(args[0].i32(), args[1].i32(), encoded);
String out;
if(!self->hostcall_staged(encoded, out))
{
DValue request, response;
String decode_error;
if(ucb_decode(encoded, request, &decode_error))
{
String op = request["op"].to_string();
if(op == "connect")
{
MySQL* db = new MySQL();
bool ok = db->connect(request["host"].to_string(), request["username"].to_string(), request["password"].to_string());
u64 handle = 0;
if(ok && db->connection)
{
self->mysql_handles.push_back(db);
handle = self->mysql_handles.size();
}
response["handle"] = (f64)handle;
response["error_code"] = (f64)db->_preload_next_error_code;
response["statement_info"] = db->error();
if(handle == 0)
delete db;
}
else if(op == "escape")
{
String quote = request["quote_char"].to_string();
response["result"] = mysql_escape(request["raw"].to_string(), quote.size() ? quote[0] : 0);
}
else
{
u64 handle = request["handle"].to_u64();
MySQL* db = (handle >= 1 && handle <= self->mysql_handles.size())
? self->mysql_handles[(size_t)handle - 1] : 0;
if(op == "query" && db)
{
response["result"] = db->query(request["query"].to_string());
response["insert_id"] = (f64)db->insert_id;
response["affected"] = (f64)db->affected_rows;
response["error_code"] = (f64)db->_preload_next_error_code;
response["statement_info"] = db->error();
}
else if(op == "disconnect" && db)
{
delete db;
self->mysql_handles[(size_t)handle - 1] = 0;
}
}
}
out = ucb_encode(response);
self->hostcall_stage(encoded, out);
}
u32 cap = (u32)args[3].i32();
int32_t buf = args[2].i32();
if(buf != 0 && cap >= out.size())
self->hostcall_write(buf, out);
results[0] = Val((int32_t)out.size());
return(std::monostate());
}));
#endif
if(mod == "env" && name == "uce_host_file_unlink")
return(add([self](Caller, Span<const Val> args, Span<Val>) -> Result<std::monostate, Trap> {
@@ -1271,6 +1505,110 @@ private:
::unlink(resolved.c_str());
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_socket_connect")
return(add([self](Caller caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
String host;
self->hostcall_read(args[0].i32(), args[1].i32(), host);
int fd = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if(fd >= 0)
{
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons((short)args[2].i32());
addr.sin_addr.s_addr = inet_addr(host.c_str());
if(::connect(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0)
{
::close(fd);
fd = -1;
}
else if(fd == 0)
{
int moved = ::dup(fd);
::close(fd);
fd = moved;
}
if(fd > 0 && context)
context->resources.sockets.push_back(fd);
}
results[0] = Val((int64_t)(fd > 0 ? fd : 0));
caller.context().set_epoch_deadline(self->worker.cfg.epoch_deadline_ticks);
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_socket_close")
return(add([](Caller, Span<const Val> args, Span<Val>) -> Result<std::monostate, Trap> {
::socket_close((u64)args[0].i64());
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_socket_write")
return(add([self](Caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
String data;
self->hostcall_read(args[1].i32(), args[2].i32(), data);
results[0] = Val(::socket_write((u64)args[0].i64(), data) ? (int32_t)1 : (int32_t)0);
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_socket_read")
return(add([self](Caller caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
u64 sockfd = (u64)args[0].i64();
u32 max_length = (u32)args[1].i32();
u32 timeout = (u32)args[2].i32();
int32_t buf = args[3].i32();
u32 cap = (u32)args[4].i32();
String key = std::to_string(sockfd) + ":" + std::to_string(max_length) + ":" + std::to_string(timeout);
String out;
if(buf != 0 && self->staged_socket_read_key == key)
{
out = self->staged_socket_read_result;
self->staged_socket_read_key = "";
self->staged_socket_read_result = "";
}
else
{
out = ::socket_read(sockfd, max_length, timeout);
if(buf == 0)
{
self->staged_socket_read_key = key;
self->staged_socket_read_result = out;
}
}
if(buf != 0 && cap >= out.size())
self->hostcall_write(buf, out);
caller.context().set_epoch_deadline(self->worker.cfg.epoch_deadline_ticks);
results[0] = Val((int32_t)out.size());
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_server_start_http")
return(add([self](Caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
String key, bind, file, function, current;
self->hostcall_read(args[0].i32(), args[1].i32(), key);
self->hostcall_read(args[2].i32(), args[3].i32(), bind);
self->hostcall_read(args[4].i32(), args[5].i32(), file);
self->hostcall_read(args[6].i32(), args[7].i32(), function);
self->hostcall_read(args[8].i32(), args[9].i32(), current);
String resolved = self->resolve_guest_file(file, current);
pid_t pid = 0;
try
{
if(resolved != "")
pid = ::server_start_http(key, bind, resolved, function);
}
catch(const std::exception& e)
{
fprintf(stderr, "[wasm server] start failed for key '%s': %s\n", key.c_str(), e.what());
}
catch(...)
{
fprintf(stderr, "[wasm server] start failed for key '%s'\n", key.c_str());
}
results[0] = Val((int32_t)pid);
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_server_stop")
return(add([self](Caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
String key;
self->hostcall_read(args[0].i32(), args[1].i32(), key);
results[0] = Val(::server_stop(key) ? (int32_t)1 : (int32_t)0);
return(std::monostate());
}));
if(mod == "env" && name == "uce_host_task_spawn")
return(add([self](Caller, Span<const Val> args, Span<Val> results) -> Result<std::monostate, Trap> {
String key;
@@ -1413,7 +1751,8 @@ private:
// ---- public entry: one request through one workspace -----------------------
inline WasmResponse wasm_worker_serve(WasmWorker& worker, const DValue& context_tree, const String& entry_source_path)
inline WasmResponse wasm_worker_serve(WasmWorker& worker, const DValue& context_tree, const String& entry_source_path,
int32_t kind = WasmWorkspace::RESOLVE_RENDER)
{
WasmResponse response;
WasmWorkspace workspace(worker);
@@ -1424,7 +1763,7 @@ inline WasmResponse wasm_worker_serve(WasmWorker& worker, const DValue& context_
if(error == "")
error = workspace.apply_context(context_tree);
if(error == "")
error = workspace.render_entry(entry_source_path);
error = workspace.invoke_entry(entry_source_path, kind, &response.handler_present);
if(error == "")
error = workspace.collect(response);
response.workspace_birth_us = workspace.workspace_birth_us;
+20 -69
View File
@@ -1,81 +1,32 @@
# Network Tests
# UCE CLI Tests
This directory contains a dependency-less vanilla Python 3 network test runner and plugin-style test cases. These are AI-generated so they might be garbage.
The network/runtime smoke suite is implemented as a UCE unit and invoked through the runtime CLI socket. The old Python plugin runner was retired so the tests exercise the same runtime path as local operational CLI commands.
## Runner
Run the default local UCE battery:
## Run
```bash
./tests/run_network_tests.py
/bin/python3 tests/run_network_tests.py
scripts/run_cli_tests.sh
scripts/run_cli_tests.sh --include-wasm-kill
scripts/run_cli_tests.sh --list
```
The default battery is aimed at public or cross-host LAN checks and currently focuses on stable published doc endpoints, the published `site/tests` public coverage pages, plus a basic TCP reachability check.
Internal-only cases are tagged `internal` and are not part of the default smoke path unless you ask for them explicitly.
`--include-wasm-kill` adds the wasm trap/loop/recurse kill cases and should be used when `WASM_BACKEND_ENABLED=1`.
For the local runtime, the target is localhost rather than `http://k-uce`.
The script reads `CLI_SOCKET_PATH` from `/etc/uce/settings.cfg` unless `UCE_CLI_SOCKET` is set.
Run against another machine on the local network:
## Implementation
```bash
/bin/python3 tests/run_network_tests.py --base-url http://k-uce --host-header uce.openfu.com
```
- Runner unit: `site/tests/cli_runner.uce`
- Bash wrapper: `scripts/run_cli_tests.sh`
Explicit localhost example:
The runner covers the former Python plugin behaviors:
```bash
/bin/python3 tests/run_network_tests.py --base-url http://localhost --host-header uce.openfu.com
```
- demo page smoke checks
- docs and starter HTTP smoke checks
- published `site/tests` suite pages
- security/header/session hardening checks
- starter parity checks
- TCP listener checks
- optional wasm kill checks
List discovered cases without running them:
```bash
/bin/python3 tests/run_network_tests.py --list
```
Run only one plugin or a subset of tagged tests:
```bash
/bin/python3 tests/run_network_tests.py --plugin uce_http_smoke
/bin/python3 tests/run_network_tests.py --tag tcp
/bin/python3 tests/run_network_tests.py --match session
/bin/python3 tests/run_network_tests.py --tag internal --include-internal
```
Override the network target:
```bash
/bin/python3 tests/run_network_tests.py --host 10.8.0.12 --port 80 --host-header uce.openfu.com
/bin/python3 tests/run_network_tests.py --base-url http://10.8.0.12:8080
```
Write a JSON report:
```bash
/bin/python3 tests/run_network_tests.py --json-report tests/last-report.json
```
## Plugin Contract
Each plugin is a plain Python file under `tests/plugins/` that exports:
```python
def register(registry):
...
```
Use `registry.case(name, callback, tags=[...])` to register cases.
Each callback receives a `TestContext` and may use helpers such as:
- `context.request(...)`
- `context.expect_status(...)`
- `context.expect_body_contains(...)`
- `context.tcp_connect(...)`
## Included Plugins
- `uce_http_smoke.py`: HTTP 200/body-marker checks for stable published doc pages
- `uce_site_suite.py`: HTTP 200/body-marker checks for the published `site/tests` coverage pages; local-only pages such as filesystem, ZIP, services, and tasks are tagged `internal`
- `uce_tcp_smoke.py`: basic TCP listener checks, with public smoke checks on port 80 and an internal-only HTTP/WebSocket listener probe on port `8080` tagged `internal`
The CLI unit sets HTTP status `500 CLI Tests Failed` when any case fails, so the bash wrapper exits nonzero via `curl --fail-with-body`.
-37
View File
@@ -1,37 +0,0 @@
from pathlib import Path
# The demo pages are not regression suites, but every one of them must at
# least compile and render: a preprocessor or runtime change that breaks a
# demo page (e.g. literal text that looks like an entry point) should fail CI.
ERROR_MARKERS = [
"uce compile error",
"fatal signal during request",
"uncaught exception during request",
]
def _repo_root():
return Path(__file__).resolve().parents[2]
def _make_demo_case(page_path):
def run(context):
response = context.expect_status(page_path, 200)
body_lower = response.text.lower()
for marker in ERROR_MARKERS:
if marker in body_lower:
raise Exception("response body contained error marker %r for %s" % (marker, page_path))
return "HTTP 200 without error markers for %s" % page_path
return run
def register(registry):
demo_dir = _repo_root() / "site" / "demo"
for path in sorted(demo_dir.glob("*.uce")):
registry.case(
"demo page " + path.name,
_make_demo_case("/demo/" + path.name),
tags=["http", "smoke", "uce", "demo", "public"],
)
-42
View File
@@ -1,42 +0,0 @@
def register(registry):
pages = [
("doc index", "/doc/index.uce", "<html>"),
("doc singlepage", "/doc/singlepage.uce", "<html>"),
("doc component page", "/doc/index.uce?p=component", "component()"),
("doc regex page", "/doc/index.uce?p=regex_search", "regex_search"),
("doc xml page", "/doc/index.uce?p=xml_encode", "xml_encode"),
("doc yaml page", "/doc/index.uce?p=yaml_encode", "yaml_encode"),
("doc relative time page", "/doc/index.uce?p=time_format_relative", "time_format_relative"),
]
starter_pages = [
("starter home", "/examples/uce-starter/", 200, "Stunning Apps"),
("starter dashboard", "/examples/uce-starter/?dashboard", 200, "Dashboard"),
("starter dashboard ONCE assets reach head", "/examples/uce-starter/?dashboard", 200, "views/dashboard.css"),
("starter workspace nested route", "/examples/uce-starter/?workspace/projects", 200, "Workspace"),
("starter workspace ONCE assets reach head", "/examples/uce-starter/?workspace/projects", 200, "css/workspace.css"),
("starter ajax section", "/examples/uce-starter/?page2-section1", 200, "UCE starter AJAX fragment response"),
("starter route traversal blocked", "/examples/uce-starter/?../../../demo/index", 404, "The requested page does not exist."),
]
for name, path, needle in pages:
def make_case(page_path=path, expected_text=needle):
def run(context):
response = context.expect_status(page_path, 200)
context.expect_body_contains(response, expected_text)
return "HTTP 200 with expected body marker for %s" % page_path
return run
registry.case(name, make_case(), tags=["http", "smoke", "uce", "public"])
for name, path, status, needle in starter_pages:
def make_starter_case(page_path=path, expected_status=status, expected_text=needle):
def run(context):
response = context.expect_status(page_path, expected_status)
context.expect_body_contains(response, expected_text)
return "starter route %s returned HTTP %s with expected marker" % (page_path, expected_status)
return run
registry.case(name, make_starter_case(), tags=["http", "smoke", "uce", "public", "starter"])
-80
View File
@@ -1,80 +0,0 @@
import http.client
from run_network_tests import TestFailure
def _direct_http_request(path, headers=None):
connection = http.client.HTTPConnection("127.0.0.1", 8080, timeout=5.0)
try:
connection.request("GET", path, headers=headers or {})
response = connection.getresponse()
body = response.read().decode("utf-8", errors="replace")
return response.status, dict(response.getheaders()), body
finally:
connection.close()
def _frontend_http_request(path, headers=None):
request_headers = {"Host": "uce.openfu.com"}
request_headers.update(headers or {})
connection = http.client.HTTPConnection("127.0.0.1", 80, timeout=5.0)
try:
connection.request("GET", path, headers=request_headers)
response = connection.getresponse()
body = response.read().decode("utf-8", errors="replace")
return response.status, response.getheaders(), body
finally:
connection.close()
def register(registry):
def direct_http_rejects_dotdot(context):
status, headers, body = _direct_http_request("/../site/demo/hello.uce")
if status == 200 or "hello world" in body:
raise TestFailure("direct HTTP accepted dot-dot script traversal")
return "direct HTTP dot-dot traversal rejected with HTTP %s" % status
def direct_http_ignores_script_filename_header(context):
status, headers, body = _direct_http_request(
"/no-such-script.uce",
headers={"Script-Filename": "/Code/uce.openfu.com/uce/site/demo/hello.uce"},
)
if status == 200 or "hello world" in body:
raise TestFailure("direct HTTP trusted client Script-Filename header")
return "direct HTTP Script-Filename override rejected with HTTP %s" % status
def response_headers_are_sanitized(context):
response = context.request("/tests/security_headers.uce")
injected_headers = [
"X-UCE-Injected",
"X-UCE-Injected-Name",
"X-UCE-Cookie-Injected",
"X-UCE-Redirect-Injected",
"X-UCE-Status-Injected",
]
for header in injected_headers:
if header in response.headers:
raise TestFailure("CRLF header injection produced extra response header %s" % header)
location = response.headers.get("Location", "")
if "\r" in location or "\n" in location:
raise TestFailure("Location header contains raw CR/LF")
status, direct_headers, direct_body = _direct_http_request("/site/tests/security_headers.uce")
if status >= 500 and "security header sanitizer test" not in direct_body:
raise TestFailure("direct HTTP sanitizer test did not render successfully; status=%s" % status)
return "CRLF response header injection was sanitized"
def unknown_session_id_is_not_adopted(context):
attacker_id = "a" * 64
status, headers, body = _frontend_http_request("/tests/http.uce", headers={"Cookie": "uce-site-tests=" + attacker_id})
set_cookies = [value for name, value in headers if name.lower() == "set-cookie"]
session_cookies = [value for value in set_cookies if value.startswith("uce-site-tests=")]
if any(("uce-site-tests=" + attacker_id) in value for value in session_cookies):
raise TestFailure("session_start adopted caller supplied unknown session id")
if not session_cookies or not any("HttpOnly" in value and "SameSite=Lax" in value for value in session_cookies):
raise TestFailure("session_start did not issue a hardened replacement session cookie")
return "unknown caller-supplied session id was replaced"
registry.case("direct HTTP rejects dot-dot script traversal", direct_http_rejects_dotdot, tags=["security", "http", "internal"])
registry.case("direct HTTP ignores Script-Filename header", direct_http_ignores_script_filename_header, tags=["security", "http", "internal"])
registry.case("response headers sanitize CRLF", response_headers_are_sanitized, tags=["security", "http", "internal"])
registry.case("unknown session ids are not adopted", unknown_session_id_is_not_adopted, tags=["security", "http", "internal"])
-104
View File
@@ -1,104 +0,0 @@
from pathlib import Path
from run_network_tests import TestFailure
ERROR_MARKERS = [
"compile error",
"runtime error",
"timed out acquiring compile lock",
"near line",
]
def _repo_root():
return Path(__file__).resolve().parents[2]
def _parse_manifest(manifest_path):
manifest = {}
if not manifest_path.exists():
return manifest
for raw_line in manifest_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
parts = [part.strip() for part in line.split("|")]
if len(parts) < 7:
continue
file_name, title, description, tags, expected, suite, index = parts[:7]
manifest[file_name] = {
"file": file_name,
"title": title,
"description": description,
"tags": tags.split(),
"expected": expected,
"suite": suite == "1",
"index": index == "1",
}
return manifest
def _case_name(file_name):
if file_name == "index.uce":
return "site tests index"
if file_name == "io.uce":
return "site tests filesystem"
if file_name == "websockets.ws.uce":
return "site tests websockets page"
stem = file_name
if stem.endswith(".uce"):
stem = stem[:-4]
if stem.endswith(".ws"):
stem = stem[:-3]
return "site tests " + stem.replace("_", " ")
def _make_missing_metadata_case(file_name):
def run(context):
raise TestFailure("site/tests/%s is missing from site/tests/manifest.txt" % file_name)
return run
def _make_suite_case(page_path, expected_title):
def run(context):
response = context.expect_status(page_path, 200)
context.expect_body_contains(response, expected_title)
body_lower = response.text.lower()
for marker in ERROR_MARKERS:
if marker in body_lower:
raise Exception("response body contained error marker %r for %s" % (marker, page_path))
if '<div class="tests-summary">' in body_lower and ('>fail</span>' in body_lower or '>failed 0<' not in body_lower):
raise Exception("site suite reported failed cases for %s" % page_path)
return "HTTP 200 with suite page marker and no failed cases for %s" % page_path
return run
def register(registry):
repo = _repo_root()
tests_dir = repo / "site" / "tests"
manifest = _parse_manifest(tests_dir / "manifest.txt")
for path in sorted(tests_dir.glob("*.uce")):
file_name = path.name
meta = manifest.get(file_name)
if not meta:
registry.case(
_case_name(file_name),
_make_missing_metadata_case(file_name),
tags=["http", "suite", "uce", "metadata", "internal"],
)
continue
if not meta["suite"]:
continue
tags = list(meta["tags"])
for tag in ["http", "suite", "uce"]:
if tag not in tags:
tags.append(tag)
registry.case(
_case_name(file_name),
_make_suite_case("/tests/" + file_name, meta["expected"] or meta["title"]),
tags=tags,
)
-66
View File
@@ -1,66 +0,0 @@
# Parity harness for the uce-starter example app.
#
# WASM-PROPOSAL Phase 3/5 exit criterion: `run_network_tests.py --match starter`
# must pass against the wasm worker backend. These cases are written against
# the native backend first so the bar exists — and is green — before the wasm
# worker does. Keep assertions backend-agnostic (rendered content only), so
# the same cases gate both backends unchanged.
ERROR_MARKERS = [
"uce compile error",
"fatal signal during request",
"uncaught exception during request",
]
BASE = "/examples/uce-starter/index.uce"
# (case name suffix, query-string route, expected <title>)
VIEWS = [
("landing", "", "Home | UCE Starter"),
("dashboard", "?dashboard", "Dashboard | UCE Starter"),
("gauges", "?gauges", "Gauges | UCE Starter"),
("features", "?features", "Features | UCE Starter"),
("components", "?page1", "Components | UCE Starter"),
("workspace", "?workspace", "Workspace | UCE Starter"),
]
def _check_body(body, title):
body_lower = body.lower()
for marker in ERROR_MARKERS:
if marker in body_lower:
raise Exception("response body contained error marker %r" % marker)
needle = "<title>%s</title>" % title
if needle not in body:
raise Exception("expected %r in response body" % needle)
def _make_view_case(name, query, title):
def run(context):
response = context.expect_status(BASE + query, 200)
_check_body(response.text, title)
return "rendered %s with expected title and no error markers" % name
return run
def _unknown_route_case(context):
# the starter app must render its own 404 view through the app shell,
# not fall through to a bare server error
response = context.expect_status(BASE + "?does-not-exist", 404)
_check_body(response.text, "404 Not Found | UCE Starter")
return "starter app rendered its own 404 page"
def register(registry):
for name, query, title in VIEWS:
registry.case(
"starter view " + name,
_make_view_case(name, query, title),
tags=["http", "uce", "starter", "parity"],
)
registry.case(
"starter unknown route 404",
_unknown_route_case,
tags=["http", "uce", "starter", "parity"],
)
-12
View File
@@ -1,12 +0,0 @@
def register(registry):
def port_80(context):
context.tcp_connect(port=80)
return "TCP connect succeeded on port 80"
registry.case("frontend port 80", port_80, tags=["tcp", "smoke", "uce", "public"])
def port_8080(context):
context.tcp_connect(port=8080)
return "TCP connect succeeded on port 8080"
registry.case("http websocket port 8080", port_8080, tags=["tcp", "uce", "internal"])
-30
View File
@@ -1,30 +0,0 @@
import os
def register(registry):
# W4/W5 kill pages are only meaningful with the wasm backend enabled. Native
# requests may terminate the worker by design, so keep them out of the normal
# native suite unless the W5 harness opts in explicitly.
if os.environ.get("UCE_INCLUDE_WASM_KILL") != "1":
return
pages = [
# oob is __builtin_trap() → wasm `unreachable`, a signal-delivering trap.
# It crashed the worker until signals_based_traps(false) (see make_engine
# in src/wasm/worker.cpp); keeping it in the gate guards that fix.
("wasm kill trap", "/tests/wasm-kill/oob.uce", "unreachable"),
("wasm kill loop", "/tests/wasm-kill/loop.uce", "interrupt"),
("wasm kill recurse", "/tests/wasm-kill/recurse.uce", "wasm_kill_recurse"),
]
for name, path, marker in pages:
def make_case(page_path=path, expected_marker=marker):
def run(context):
response = context.expect_status(page_path, 500)
context.expect_body_contains(response, "wasm runtime error during request")
context.expect_body_contains(response, expected_marker)
# The worker should remain healthy after the trap.
health = context.expect_status("/demo/hello.uce", 200)
context.expect_body_contains(health, "hello world")
return "clean wasm trap page and post-trap health check for %s" % page_path
return run
registry.case(name, make_case(), tags=["http", "uce", "wasm", "kill", "internal"])
-360
View File
@@ -1,360 +0,0 @@
#!/bin/python3
import argparse
import http.client
import importlib.util
import json
import re
import socket
import sys
import time
from urllib.parse import urlparse
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional
# When executed as a script this module is "__main__"; register it under its
# real name too, so plugins doing `from run_network_tests import TestFailure`
# get this module instance (and the same TestFailure class) instead of a
# re-imported copy whose exceptions the runner's except clause cannot catch.
sys.modules.setdefault("run_network_tests", sys.modules[__name__])
@dataclass
class Target:
scheme: str = "http"
host: str = "localhost"
port: int = 80
host_header: Optional[str] = None
timeout: float = 5.0
@property
def label(self) -> str:
return "%s://%s:%s" % (self.scheme, self.host, self.port)
@dataclass
class HttpResponse:
status: int
reason: str
headers: Dict[str, str]
body: bytes
duration_ms: float
@property
def text(self) -> str:
return self.body.decode("utf-8", errors="replace")
@dataclass
class TestResult:
name: str
ok: bool
summary: str
plugin: str
duration_ms: float = 0.0
details: str = ""
tags: List[str] = field(default_factory=list)
class TestFailure(Exception):
pass
class TestContext:
def __init__(self, target: Target, args: argparse.Namespace):
self.target = target
self.args = args
def request(
self,
path: str,
method: str = "GET",
headers: Optional[Dict[str, str]] = None,
body: Optional[bytes] = None,
timeout: Optional[float] = None,
) -> HttpResponse:
request_headers = dict(headers or {})
if self.target.host_header and "Host" not in request_headers:
request_headers["Host"] = self.target.host_header
connection_class = http.client.HTTPConnection
if self.target.scheme == "https":
connection_class = http.client.HTTPSConnection
started_at = time.perf_counter()
connection = connection_class(self.target.host, self.target.port, timeout=timeout or self.target.timeout)
try:
connection.request(method, path, body=body, headers=request_headers)
response = connection.getresponse()
content = response.read()
return HttpResponse(
status=response.status,
reason=response.reason,
headers=dict(response.getheaders()),
body=content,
duration_ms=(time.perf_counter() - started_at) * 1000.0,
)
finally:
connection.close()
def expect_status(self, path: str, expected_status: int = 200, method: str = "GET") -> HttpResponse:
response = self.request(path, method=method)
if response.status != expected_status:
raise TestFailure(
"expected HTTP %s for %s, got %s %s" % (
expected_status,
path,
response.status,
response.reason,
)
)
return response
def expect_body_contains(self, response: HttpResponse, needle: str) -> None:
if needle not in response.text:
raise TestFailure("response body did not contain expected text: %r" % needle)
def tcp_connect(self, host: Optional[str] = None, port: Optional[int] = None, timeout: Optional[float] = None) -> None:
sock = socket.create_connection(
(host or self.target.host, port or self.target.port),
timeout or self.target.timeout,
)
sock.close()
@dataclass
class NetworkTestCase:
name: str
callback: Callable[[TestContext], Optional[str]]
plugin: str
tags: List[str] = field(default_factory=list)
def run(self, context: TestContext) -> TestResult:
started_at = time.perf_counter()
try:
summary = self.callback(context) or "ok"
return TestResult(
name=self.name,
ok=True,
summary=summary,
plugin=self.plugin,
duration_ms=(time.perf_counter() - started_at) * 1000.0,
tags=list(self.tags),
)
except TestFailure as exc:
return TestResult(
name=self.name,
ok=False,
summary=str(exc),
plugin=self.plugin,
duration_ms=(time.perf_counter() - started_at) * 1000.0,
tags=list(self.tags),
)
except Exception as exc:
return TestResult(
name=self.name,
ok=False,
summary="unexpected error: %s" % exc,
plugin=self.plugin,
duration_ms=(time.perf_counter() - started_at) * 1000.0,
details=repr(exc),
tags=list(self.tags),
)
class TestRegistry:
def __init__(self):
self._cases = []
self._active_plugin = ""
def set_plugin(self, plugin_name: str) -> None:
self._active_plugin = plugin_name
def case(self, name: str, callback: Callable[[TestContext], Optional[str]], tags: Optional[Iterable[str]] = None) -> None:
if not self._active_plugin:
raise RuntimeError("plugin name was not set before registering tests")
self._cases.append(
NetworkTestCase(
name=name,
callback=callback,
plugin=self._active_plugin,
tags=list(tags or []),
)
)
@property
def cases(self) -> List[NetworkTestCase]:
return list(self._cases)
def discover_plugin_files(plugin_dir: Path) -> List[Path]:
if not plugin_dir.exists():
return []
return sorted(
path for path in plugin_dir.iterdir()
if path.is_file() and path.suffix == ".py" and path.name != "__init__.py"
)
def load_plugin_module(module_path: Path):
module_name = "network_test_plugin_%s" % module_path.stem
spec = importlib.util.spec_from_file_location(module_name, str(module_path))
if spec is None or spec.loader is None:
raise RuntimeError("could not load plugin %s" % module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def register_plugin_cases(registry: TestRegistry, module_path: Path) -> None:
module = load_plugin_module(module_path)
if not hasattr(module, "register"):
raise RuntimeError("plugin %s does not define register(registry)" % module_path.name)
registry.set_plugin(module_path.stem)
module.register(registry)
def filter_cases(
cases: List[NetworkTestCase],
plugins: List[str],
name_pattern: Optional[str],
tags: List[str],
include_internal: bool,
excluded_names: List[str],
) -> List[NetworkTestCase]:
filtered = list(cases)
if plugins:
filtered = [case for case in filtered if case.plugin in plugins]
if not include_internal:
filtered = [case for case in filtered if "internal" not in case.tags]
if name_pattern:
matcher = re.compile(name_pattern)
filtered = [case for case in filtered if matcher.search(case.name)]
if tags:
filtered = [case for case in filtered if any(tag in case.tags for tag in tags)]
if excluded_names:
excluded = set(excluded_names)
filtered = [case for case in filtered if case.name not in excluded]
return filtered
def print_case_list(cases: List[NetworkTestCase]) -> None:
for case in cases:
tag_suffix = ""
if case.tags:
tag_suffix = " tags=%s" % ",".join(case.tags)
print("%s:%s%s" % (case.plugin, case.name, tag_suffix))
def print_result(result: TestResult) -> None:
status = "PASS" if result.ok else "FAIL"
print("[%s] %s:%s (%.1f ms) - %s" % (
status,
result.plugin,
result.name,
result.duration_ms,
result.summary,
))
if result.details:
print(" %s" % result.details)
def write_json_report(results: List[TestResult], report_path: Path) -> None:
report_path.write_text(json.dumps([asdict(result) for result in results], indent=2) + "\n", encoding="utf-8")
def build_parser() -> argparse.ArgumentParser:
base_dir = Path(__file__).resolve().parent
parser = argparse.ArgumentParser(description="Dependency-less plugin-style network test runner")
parser.add_argument("--base-url", help="full base URL, for example http://k-uce or http://127.0.0.1:8080")
parser.add_argument("--host", default="localhost", help="target host or IP")
parser.add_argument("--port", type=int, default=80, help="target port")
parser.add_argument("--scheme", default="http", choices=["http", "https"], help="target scheme")
parser.add_argument("--host-header", default="uce.openfu.com", help="optional Host header override")
parser.add_argument("--timeout", type=float, default=5.0, help="per-request timeout in seconds")
parser.add_argument("--plugin-dir", default=str(base_dir / "plugins"), help="directory containing plugin files")
parser.add_argument("--plugin", action="append", default=[], help="limit to one or more plugin basenames")
parser.add_argument("--match", help="regex filter for case names")
parser.add_argument("--exclude", action="append", default=[], help="exclude an exact test case name after other filters")
parser.add_argument("--tag", action="append", default=[], help="only run cases with at least one matching tag")
parser.add_argument("--include-internal", action="store_true", help="include tests tagged internal in the run")
parser.add_argument("--list", action="store_true", help="list discovered tests without running them")
parser.add_argument("--fail-fast", action="store_true", help="stop on first failing test")
parser.add_argument("--json-report", help="write full results to a JSON file")
return parser
def build_target(args: argparse.Namespace) -> Target:
if args.base_url:
parsed = urlparse(args.base_url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise SystemExit("--base-url must include an http or https scheme and hostname")
default_port = 443 if parsed.scheme == "https" else 80
return Target(
scheme=parsed.scheme,
host=parsed.hostname,
port=parsed.port or default_port,
host_header=args.host_header or parsed.hostname,
timeout=args.timeout,
)
return Target(
scheme=args.scheme,
host=args.host,
port=args.port,
host_header=args.host_header or None,
timeout=args.timeout,
)
def main(argv: Optional[List[str]] = None) -> int:
args = build_parser().parse_args(argv)
plugin_dir = Path(args.plugin_dir).resolve()
plugin_files = discover_plugin_files(plugin_dir)
if not plugin_files:
print("No plugin files found in %s" % plugin_dir, file=sys.stderr)
return 2
registry = TestRegistry()
for plugin_path in plugin_files:
register_plugin_cases(registry, plugin_path)
cases = filter_cases(registry.cases, args.plugin, args.match, args.tag, args.include_internal, args.exclude)
if not cases:
print("No tests matched the selected filters", file=sys.stderr)
return 2
if args.list:
print_case_list(cases)
return 0
context = TestContext(
target=build_target(args),
args=args,
)
print("Running %s test(s) against %s" % (len(cases), context.target.label))
if context.target.host_header:
print("Host header: %s" % context.target.host_header)
results = []
for case in cases:
result = case.run(context)
results.append(result)
print_result(result)
if args.fail_fast and not result.ok:
break
if args.json_report:
write_json_report(results, Path(args.json_report).resolve())
failures = [result for result in results if not result.ok]
print("")
print("Summary: %s passed, %s failed" % (len(results) - len(failures), len(failures)))
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())