Compare commits
6
Commits
c84fc86e6c
...
15e8d092bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15e8d092bc | ||
|
|
6bb4f7f0ad | ||
|
|
b1856c1725 | ||
|
|
af6500d134 | ||
|
|
d6421cb8f3 | ||
|
|
fe83c52411 |
+42
-20
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+46
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")+
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user