v0.34.0_8
This commit is contained in:
1 parent
b6a3e12225
commit
480c3ec769
92 files changed
+298
-10035
No files matched your search
@@ -1,354 +0,0 @@
|
||||
# Live performance and regression harness
|
||||
|
||||
## DEV OPT3 / 1 KiB response: uncompiled source
|
||||
|
||||
`docs/PROXY_1KB_OPT3_DEV_0.34.0.md` describes 7 optimized hot-path sites
|
||||
and how to benchmark their **newly rebuilt** executable. Baseline
|
||||
`rust-proxy(2)` passed 89 real proxy/policy/cache checks. This runtime result
|
||||
is not evidence that uncompiled OPT3 changes work, nor a measured OPT3 RPS gain.
|
||||
|
||||
These scripts exercise actual RustProxy executables through loopback sockets.
|
||||
They do not simulate Rust request handling. The Python server in `peer.py` is a
|
||||
controllable test **upstream**, used to verify streaming, non-replayable writes,
|
||||
headers, cache policy and WebSocket frames. Performance measurements use nginx
|
||||
as the shared upstream instead of Python.
|
||||
|
||||
## DEV 1 KiB histogram writes (source candidate)
|
||||
|
||||
`VhostMetrics::complete` now writes one exclusive histogram bin for each
|
||||
completed exchange instead of up to 11 cumulative atomics. Both Prometheus
|
||||
exporters reconstruct `le` and `+Inf` at scrape time. Unit tests cover boundary
|
||||
semantics. This change has not been compiled or benchmarked: see
|
||||
`docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md`.
|
||||
|
||||
## DEV 1 KiB cache counter follow-up
|
||||
|
||||
See `docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md`. Cache domain metrics no longer
|
||||
lock the HOT store on BYPASS; analytics cloning is deferred until the queue
|
||||
has capacity. The new **source** remains uncompiled in the editing environment.
|
||||
For quick 1,040-byte loopback diagnostics with ApacheBench (not a production
|
||||
acceptance benchmark), use `python3 scripts/performance/ab_1kb_dev.py --help`.
|
||||
The authoritative long-run comparison remains `benchmark.py` with pinned
|
||||
CPU sets, an explicitly equal upstream idle pool and multiple `wrk` repeats.
|
||||
|
||||
## DEV 1 KiB follow-up on 0.34.0 (newer source)
|
||||
|
||||
See `docs/PROXY_1KB_HOTPATH_DEV_0.34.0.md`. The previous executable's 4-vs-64
|
||||
idle-connection tuning experiment is real loopback evidence, but the *edited*
|
||||
source has not been compiled. Its new default is 64 for new vhosts, whereas
|
||||
explicitly saved `4` remains `4`. Keep the idle limit equal between before/after
|
||||
*code* benchmarks; measure configuration tuning in a separate labelled series.
|
||||
The source release is not eligible for a claim of 100k RPS or 80% nginx yet.
|
||||
|
||||
## Reverse-proxy optimization revision 0.34.0
|
||||
|
||||
After compiling the refreshed candidate, measure the same unmodified 0.33.8
|
||||
binary and nginx reference as before, without turning off logging, request
|
||||
IDs, ACL, cache-policy enforcement or LB health. Profile request-size 1,040 B
|
||||
first. Before RPS runs execute full proxy, LB, transport and concurrency
|
||||
regressions (including single-member capacity, recovery, sticky cookies,
|
||||
replays on safe methods, rejected non-replayable writes and H2 connection
|
||||
shutdown). Treat `--max-idle` tuning and recycles as separately labelled
|
||||
experiments rather than mixing them into the primary comparison. There are no
|
||||
new measured RPS figures in the source candidate.
|
||||
|
||||
## Follow-up RPS candidate on 0.34.0
|
||||
|
||||
`docs/PROXY_RPS_0.34.0.md` tracks the code changes and their regressions.
|
||||
Compile before/after binaries from the two 0.34.0 source revisions and use
|
||||
the paired benchmark below; a live run against the older binary cannot
|
||||
establish a measured RPS improvement for the uncompiled candidate. Keep
|
||||
`max_idle_per_host`, recycling, HTTP1/H2 settings and all application security
|
||||
and logging features equal between runs. Treat any configuration tuning as a
|
||||
separate experiment. Profile the resulting binary with `perf` only after
|
||||
correctness checks pass.
|
||||
|
||||
## Offline cache-retention model for 0.34.0
|
||||
|
||||
`python3 scripts/performance/simulate_cache_034.py` compares cache invalidation
|
||||
policies under a deterministic synthetic sequence of vhost saves and uniform
|
||||
cacheable GETs. The model contains **no RustProxy server**, no timing and no
|
||||
network traffic; its percentages are **not measured RPS gains**. See
|
||||
`docs/TEST_REPORT_0.34.0.md` for assumptions and the live acceptance gap.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux with readable `/proc`, Python 3.10+, executable RustProxy binaries.
|
||||
- nginx and wrk for performance comparisons. Both paths are explicit; no
|
||||
system-wide installation is required.
|
||||
- `taskset` from util-linux for the benchmark's explicit CPU affinity and for
|
||||
optional affinity settings in the other scripts.
|
||||
- Optional Python `brotli` and `zstandard` decoders increase compression
|
||||
acceptance coverage. A missing optional decoder is reported as **skipped**.
|
||||
- Permission to bind unprivileged loopback sockets and start local processes.
|
||||
In a sandbox without sockets or `/proc`, run in the approved local execution
|
||||
environment. Do not replace real runtime results with a peer self-test.
|
||||
|
||||
Each run creates a new private temporary data directory and initializes its own
|
||||
administrator with a random password supplied only over stdin. Configurations
|
||||
are applied through RustProxy's existing authenticated CLI/API and listener
|
||||
changes receive a restart. No historical SQLite database is copied or edited.
|
||||
TLS keys, databases and passwords are never written to the evidence directory.
|
||||
Temporary state is outside the evidence tree and is removed on normal exit.
|
||||
`fixture-cleanup.json` records the cleanup check. Interrupted runs can leave an
|
||||
unrelated `/tmp/rustproxy-perf-*` directory; do not include it in a release.
|
||||
|
||||
Before timed benchmark, soak or syscall-profile work, `wait_quiescent()` waits
|
||||
for the existing authenticated ACME runtime API to report `ready` or
|
||||
`unavailable`. A fresh RustProxy data directory can start a Python/Certbot
|
||||
environment bootstrap even when no certificates have an issuer. The gate
|
||||
requires no configured issuers, jobs or renewal observations, the same live Rust
|
||||
PID and start time, and no child processes for five continuous seconds. It polls
|
||||
every 0.5 seconds and fails after 180 seconds instead of measuring installation
|
||||
work. Selected status, process count and timing evidence is saved in
|
||||
`<label>-quiescence.json`. No PATH, pip settings or application features are
|
||||
changed. An `unavailable` result permits the HTTP fixture to proceed; these runs
|
||||
do not test ACME issuance or certificate-provider availability.
|
||||
|
||||
## Regression tests
|
||||
|
||||
Run from the repository root, providing absolute binary and output paths:
|
||||
|
||||
```bash
|
||||
python3 scripts/performance/regression.py \
|
||||
--binary before=/absolute/path/rust-proxy-before \
|
||||
--binary after=/absolute/path/rust-proxy-after \
|
||||
--out /absolute/path/results/regression
|
||||
```
|
||||
|
||||
`--groups static,proxy,policy,cache,transform` selects groups; all are enabled by
|
||||
default. `--workers 4` sets the same `TOKIO_WORKER_THREADS` for each binary.
|
||||
The report contains individual pass/fail/skip observations and a comparison
|
||||
matrix. An existing failure in the supplied binary remains a failure in its
|
||||
report; it is not silently converted to a successful baseline expectation.
|
||||
|
||||
Coverage includes:
|
||||
|
||||
- Static GET/HEAD and byte checksums at 1,040 B, 64 KiB and 1 MiB; empty files;
|
||||
ETag/Last-Modified conditional requests; prefix/suffix/open/multipart ranges,
|
||||
If-Range and 416; directory redirect/index/listing; decoded path rejection;
|
||||
deny/inside-root symlinks; precompressed gzip; MIME/download headers;
|
||||
`try_files`, redirects and the compiled `.htaccess` header subset.
|
||||
- Proxy POST/PUT, 1 MiB upload checksums, chunked upload delivery **before the
|
||||
last chunk is sent**, early response delivery, client disconnect, original
|
||||
status/redirect preservation, failed-POST no replay, request/response header
|
||||
scopes, hop-by-hop stripping, request-ID shape/uniqueness and WebSocket echo.
|
||||
- Global host/vhost/profile/location ACL composition, upstream suppression on
|
||||
denial, trusted versus spoofed forwarding, untrusted country hints, method
|
||||
and rate policies, hot changes and rejection of invalid configuration.
|
||||
- Private administration routes, login, session/CSRF and Origin enforcement,
|
||||
Basic Auth, ACL-before-auth, private-origin rejection, retained exchange
|
||||
logging fields and byte accounting.
|
||||
- Cache global/vhost/route OFF and ON, MISS/HIT, Vary isolation, cookie,
|
||||
Authorization/private/no-store bypass, PURGE, concurrent fills, validator
|
||||
revalidation, static cached ranges/conditionals; transforms OFF/ON with cache
|
||||
OFF/ON; compression negotiation, exclusions and scope/global codec ceilings.
|
||||
|
||||
The generic suite does not establish TLS/H2/H3, Tinyauth, real PHP-FPM, real DNS
|
||||
rebinding, concurrent filesystem rename-race safety or long-soak acceptance.
|
||||
Use the dedicated transport tests and existing Rust integration suites for
|
||||
their covered cases. The sequential symlink-target test is explicitly not a
|
||||
concurrent rename-race proof.
|
||||
|
||||
## TLS, HTTP/2, HTTP/3 and WebSocket interoperability
|
||||
|
||||
```bash
|
||||
python3 scripts/performance/transport_regression.py \
|
||||
--binary before=/absolute/path/rust-proxy-before \
|
||||
--binary after=/absolute/path/rust-proxy-after \
|
||||
--out /absolute/path/results/transport
|
||||
```
|
||||
|
||||
This suite requires `aioquic`, `httpx[http2]` and `websockets`; use
|
||||
`--python-deps /absolute/path/pydeps` for a separate dependency directory.
|
||||
It verifies the temporary server certificate, negotiated protocol, response
|
||||
status and bytes for TLS H2 and QUIC H3, including HEAD, validators, ranges,
|
||||
cache ON/OFF, 1 MiB bodies and concurrent streams. Dedicated WebSocket checks
|
||||
include bidirectional echo and a normal close handshake. Client protocol
|
||||
self-checks are reported separately from RustProxy acceptance cases.
|
||||
This establishes covered interoperability, not TLS/H2/H3 throughput or
|
||||
production-certificate compatibility.
|
||||
|
||||
## Two-origin load-balancer regression
|
||||
|
||||
```bash
|
||||
python3 scripts/performance/lb_regression.py \
|
||||
--binary before=/absolute/path/rust-proxy-before \
|
||||
--binary after=/absolute/path/rust-proxy-after \
|
||||
--out /absolute/path/results/lb
|
||||
```
|
||||
|
||||
This separate, small suite sends real requests through RustProxy to two local
|
||||
TCP origins. It verifies equal round-robin allocation, GET failover after a 503
|
||||
and after a stopped origin refuses connections, and the absence of POST replay.
|
||||
A controlled response pauses after its first body fragment while configuration
|
||||
changes drain or disable its member. New requests must use the other member,
|
||||
the admitted response must finish intact, and its active lease must remain
|
||||
counted until completion and then return to zero. The main HTTP case matrix is
|
||||
unchanged. This suite does not cover sticky sessions, active health probes,
|
||||
DNS rotation or TLS upstream failures.
|
||||
|
||||
## Finite concurrency correctness stress
|
||||
|
||||
```bash
|
||||
python3 scripts/performance/concurrency_regression.py \
|
||||
--binary before=/absolute/path/rust-proxy-before \
|
||||
--binary after=/absolute/path/rust-proxy-after \
|
||||
--nginx /absolute/path/nginx --out /absolute/path/results/concurrency
|
||||
```
|
||||
|
||||
This separate functional check uses 1, 16 and 128 concurrent client tasks for
|
||||
proxy and static responses at 1,040 B, 64 KiB and 1 MiB. Each finite batch sends
|
||||
`max(32, 2 * clients)` requests and verifies every status, length and body SHA-256.
|
||||
After the clients finish, backend active-exchange counters must settle to zero.
|
||||
RSS and file descriptors are recorded before the batch and after its drain.
|
||||
These are not peak resource measurements or throughput results. Client tasks
|
||||
start behind a barrier; configured concurrency does not establish that the same
|
||||
number of server exchanges were active simultaneously.
|
||||
|
||||
## Before/after/nginx benchmark
|
||||
|
||||
First verify starts, loopback routing and exact payload hashes without invoking
|
||||
the load generator:
|
||||
|
||||
```bash
|
||||
python3 scripts/performance/benchmark.py \
|
||||
--binary before=/absolute/path/rust-proxy-before \
|
||||
--binary after=/absolute/path/rust-proxy-after \
|
||||
--nginx /absolute/path/nginx --wrk /absolute/path/wrk \
|
||||
--server-cpus 0-3 --origin-cpus 4-5 --client-cpus 6-8 \
|
||||
--out /absolute/path/results/preflight --prepare-only
|
||||
```
|
||||
|
||||
For a host with CPUs 0–8 available, a reproducible primary run is:
|
||||
|
||||
```bash
|
||||
python3 scripts/performance/benchmark.py \
|
||||
--binary before=/absolute/path/rust-proxy-before \
|
||||
--binary after=/absolute/path/rust-proxy-after \
|
||||
--nginx /absolute/path/nginx --wrk /absolute/path/wrk \
|
||||
--out /absolute/path/results/primary \
|
||||
--workers 4 --origin-workers 2 --client-threads 4 \
|
||||
--server-cpus 0-3 --origin-cpus 4-5 --client-cpus 6-8 \
|
||||
--concurrency 64 --sizes small,large,huge --modes proxy,static \
|
||||
--duration 15 --warmup 3 --repeats 5
|
||||
```
|
||||
|
||||
Choose explicit, nonempty and disjoint CPU sets allowed in the run environment;
|
||||
adapt all three sets on a smaller host. Frontends use the same CPU set and worker
|
||||
count, the shared origin uses another set, and the generator uses a third. The
|
||||
harness checks each process/thread's actual affinity and refuses unavailable
|
||||
resource counters. Four wrk threads share the three client CPUs in this example.
|
||||
The structured result records thread count and effective connection count; at
|
||||
64 connections, four threads keep exactly 64 connections, avoiding wrk's integer
|
||||
division of connections between threads. Only one frontend is loaded at a time.
|
||||
Stop builds, other tests and profilers during throughput measurement.
|
||||
|
||||
The primary run takes approximately 27 minutes: six scenarios, three variants,
|
||||
five samples, and 18 seconds per sample including excluded warm-up. To explore
|
||||
concurrency, repeat in a fresh output directory with
|
||||
`--concurrency 1,16,64,128`. More scenarios intentionally take more time.
|
||||
Short sanity checks require the explicit `--exploratory` option and cannot
|
||||
satisfy the >=5 samples / >=15-second measurement gate.
|
||||
|
||||
Cache is explicitly disabled for the primary benchmark. Transforms retain their
|
||||
disabled defaults. Identity encoding is requested. Logging history, analytics,
|
||||
request IDs, origin policy, body accounting, health/lease plumbing and normal
|
||||
application functionality remain active. nginx uses `sendfile on`, HTTP/1.1,
|
||||
keep-alive and streaming proxy delivery. The generated exact configurations are
|
||||
saved, so this intentional difference in nginx/application features is visible.
|
||||
No cache-disabled claim is inferred from a cache-on speedup.
|
||||
|
||||
Optional keep-alive tuning uses `--max-idle label=64`. Keep this as a separately
|
||||
labelled experiment. The harness does not turn off bounded pool recycling.
|
||||
|
||||
## Recorded evidence and interpretation
|
||||
|
||||
- `schedule.json`: complete deterministic crossover order, rotating and
|
||||
reversing variants between repetitions. No best-run selection.
|
||||
- `preflight.json`, `fixture/payloads.json`, binary/version/config metadata:
|
||||
exact status, body SHA-256, payload sizes and inputs.
|
||||
- Per-sample exact status/body checks before and after the measured interval,
|
||||
plus a wire-bytes-per-request sanity check, detect wrong content and gross
|
||||
transfer-size anomalies. These do not hash every response inside wrk.
|
||||
- `raw/*.txt` and `raw/*.json`: unmodified wrk output and structured requests,
|
||||
duration, bytes, RPS, p50/p95/p99, effective concurrency, and
|
||||
connect/read/write/timeout/status errors.
|
||||
- `raw/*-resources.json`: timestamped `/proc` samples for each server process
|
||||
and its children, plus the separate origin process group.
|
||||
- `raw/*-affinity.json`: observed process/thread CPU masks for the front end,
|
||||
origin and generator.
|
||||
- `runs.csv/json`, `summary.csv/json`: all samples, medians, RPS range and
|
||||
coefficient of variation, latency medians, server CPU time per completed
|
||||
request, peak/end RSS and file descriptors.
|
||||
- `status/*.json`: numeric drop and pool-generation/rotation counters read
|
||||
outside each measured interval. Warm-up output is kept separately.
|
||||
- `completion.json` and `SHA256SUMS`: completion state and hashes of evidence.
|
||||
Any generator/network/status error stops the run with `failure.json`; partial
|
||||
data remains available and is not presented as completed acceptance.
|
||||
|
||||
Use `(after_median / before_median - 1) * 100` for throughput improvement and
|
||||
`after_median / nginx_median * 100` for relative nginx throughput. Rebuilding
|
||||
both revisions with one compiler, target and resolved lockfile is required for
|
||||
source-change attribution. Compare the supplied executable separately: build
|
||||
flags and dependency versions can change its performance.
|
||||
|
||||
These are same-host, closed-loop HTTP/1.1 diagnostics. They do not establish
|
||||
production capacity, TLS/H2/H3 performance, an open-loop latency SLA, allocator
|
||||
rates, CPU cycles or 24-hour resource stability. wrk latency follows wrk's own
|
||||
histogram/correction behavior. RSS and descriptor peaks are sampled observations,
|
||||
so shorter spikes may be missed. Summed nginx RSS can double-count shared pages.
|
||||
Origin or generator CPU saturation can limit observed throughput; inspect their
|
||||
resource data and move the generator/origin to separate machines for release
|
||||
measurements. Run profilers separately so profiling overhead does not enter the
|
||||
headline A/B throughput numbers.
|
||||
|
||||
## Mixed-traffic resource soak
|
||||
|
||||
Run this in its own phase, after correctness and throughput checks:
|
||||
|
||||
```bash
|
||||
python3 scripts/performance/soak.py \
|
||||
--binary /absolute/path/rust-proxy-after --label after \
|
||||
--nginx /absolute/path/nginx --out /absolute/path/results/soak \
|
||||
--duration 1800 --rate 100 --clients 8 \
|
||||
--workers 4 --origin-workers 2 --server-cpus 0-3 --origin-cpus 4-5 \
|
||||
--toggle-seconds 120 --sample-seconds 5 --abort-every 101
|
||||
```
|
||||
|
||||
The bounded client executor alternates proxy/static and 1,040 B/64 KiB/1 MiB
|
||||
payloads, verifies full bytes and occasional HEAD/range semantics, closes some
|
||||
clients after an initial body prefix, and applies live cache OFF/ON changes.
|
||||
Every workload request opens a new frontend HTTP/1.1 connection; upstream
|
||||
keepalive retains its configured defaults. This exercises connection churn.
|
||||
The primary wrk benchmark separately uses persistent frontend connections.
|
||||
The nominal request rate is paced; scheduler delays and actual completion rate
|
||||
are reported instead of hiding an under-driven client. Pending jobs are bounded
|
||||
to twice the client count. The script restores base configuration, drains and
|
||||
rechecks both listeners before shutdown. After each cache apply, two separate
|
||||
GETs per source verify exact contents and require a HIT on the second GET when
|
||||
enabled; globally disabled cache must expose no X-Cache header. These probe
|
||||
requests are recorded separately from paced traffic. Backend active-exchange
|
||||
counters must settle to zero after the final drain and cooldown.
|
||||
|
||||
Five-second resource/status records, body/status failures, observed cache-status
|
||||
counts, cache transitions/probes, RSS/FD trend and completed traffic totals are
|
||||
retained. Controlled client closes mean that the client closes after reading a
|
||||
prefix. The server may already have sent the complete response, so those counts
|
||||
are not presented as proven server-side cancellations.
|
||||
Memory trend is descriptive: switching caching and allocator retention affect
|
||||
RSS. This is a 30-minute resource gate, not a 24-hour leak proof. A short
|
||||
`--exploratory --duration ...` sanity run stays explicitly outside that gate.
|
||||
|
||||
## 0.34.0 hypothesis-only sendfile/nginx gap simulator
|
||||
|
||||
```bash
|
||||
python3 scripts/performance/simulate_nginx_gap_034.py --output /tmp/nginx-gap.json
|
||||
```
|
||||
|
||||
This is **not** a benchmark of RustProxy 0.34.0. It reproduces measured
|
||||
0.33.8/nginx medians with cache OFF and applies explicitly hypothetical
|
||||
Amdahl service-time reductions (WWW and reverse proxy separately). A real
|
||||
RustProxy binary is required to validate the code path, detect regressions
|
||||
and measure latency/RPS against nginx under identical conditions.
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Short, same-host ab diagnostic for 1,040-byte HTTP/1 proxy responses.
|
||||
|
||||
Requires an existing executable and nginx; keeps the same explicitly supplied
|
||||
idle-pool limit across variants. Not a substitute for wrk/CPU-isolated acceptance.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from common import Fixture, http, write_json
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', action='append', required=True, metavar='LABEL=/abs/path')
|
||||
parser.add_argument('--nginx', default='/usr/sbin/nginx', type=Path)
|
||||
parser.add_argument('--ab', default='/usr/bin/ab', type=Path)
|
||||
parser.add_argument('--out', required=True, type=Path)
|
||||
parser.add_argument('--concurrency', default='16,64')
|
||||
parser.add_argument('--duration', type=int, default=4)
|
||||
parser.add_argument('--repeats', type=int, default=2)
|
||||
parser.add_argument('--workers', type=int, default=4)
|
||||
parser.add_argument('--max-idle', type=int, default=64)
|
||||
args = parser.parse_args()
|
||||
binaries = dict(item.split('=', 1) for item in args.binary)
|
||||
if len(binaries) != len(args.binary) or not all(Path(x).is_absolute() for x in binaries.values()):
|
||||
parser.error('binary labels must be distinct and paths must be absolute')
|
||||
if args.duration < 2 or args.repeats < 1:
|
||||
parser.error('duration >=2 seconds and repeats >=1 required')
|
||||
concurrency = [int(v) for v in args.concurrency.split(',')]
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
records = []
|
||||
write_json(args.out / 'metadata.json', {
|
||||
'simulation': False, 'binary_labels': list(binaries), 'nginx': str(args.nginx),
|
||||
'max_idle_for_every_rust_variant': args.max_idle, 'duration_s': args.duration,
|
||||
'repeats': args.repeats, 'concurrency': concurrency, 'workers': args.workers,
|
||||
'limitations': 'same-host shared CPU and closed-loop ab; not build-level before/after of uncompiled source'
|
||||
})
|
||||
with Fixture(args.out, workers=args.workers) as fixture:
|
||||
nginx = fixture.nginx(args.nginx, frontend=True)
|
||||
instances = {name: fixture.rust(Path(binary), name, max_idle=args.max_idle)
|
||||
for name, binary in binaries.items()}
|
||||
for name, instance in instances.items():
|
||||
instance.login()
|
||||
print('Waiting for quiescence:', name, flush=True)
|
||||
instance.wait_quiescent(timeout=150, quiet_seconds=3)
|
||||
sources = {**{name: r.reverse_port for name, r in instances.items()}, 'nginx': nginx['reverse_port']}
|
||||
payload = (fixture.site / 'small.html').read_bytes()
|
||||
for name, port in sources.items():
|
||||
code, _, body = http(port, '/small.html', headers={'Accept-Encoding': 'identity'})
|
||||
if code != 200 or body != payload:
|
||||
raise AssertionError(f'{name} precheck failed: {code}, {len(body)} bytes')
|
||||
write_json(args.out / 'payload-check.json', {
|
||||
'status': 200, 'bytes': len(payload), 'sha256': hashlib.sha256(payload).hexdigest(),
|
||||
'all_variants_verified': True
|
||||
})
|
||||
labels = list(sources)
|
||||
for c in concurrency:
|
||||
for repeat in range(args.repeats):
|
||||
order = labels[repeat % len(labels):] + labels[:repeat % len(labels)]
|
||||
if repeat % 2:
|
||||
order.reverse()
|
||||
for name in order:
|
||||
port = sources[name]
|
||||
cmd = [str(args.ab), '-k', '-t', str(args.duration), '-n', '1000000', '-c', str(c),
|
||||
'-H', 'Accept-Encoding: identity', f'http://127.0.0.1:{port}/small.html']
|
||||
prefix = f'c{c}-rep{repeat+1}-{name}'
|
||||
subprocess.run([str(args.ab), '-k', '-n', '256', '-c', str(c),
|
||||
f'http://127.0.0.1:{port}/small.html'],
|
||||
capture_output=True, timeout=25, check=True)
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=args.duration + 45)
|
||||
raw = p.stdout + '\n' + p.stderr
|
||||
(args.out / f'{prefix}.txt').write_text(raw)
|
||||
if p.returncode:
|
||||
raise RuntimeError(f'{prefix} ab failed: {p.returncode} {p.stderr}')
|
||||
def field(label):
|
||||
m = re.search(r'^' + re.escape(label) + r':\s*(\d+(?:\.\d+)?)', raw, re.M)
|
||||
return float(m.group(1)) if m else None
|
||||
# ApacheBench tracks both network failures and bad body lengths.
|
||||
if not re.search(r'^Failed requests:\s+0\s*$', raw, re.M):
|
||||
raise RuntimeError(f'{prefix} failed requests: {raw[-1600:]}')
|
||||
if not re.search(r'^Non-2xx responses:\s*0\s*$', raw, re.M) and 'Non-2xx responses:' in raw:
|
||||
raise RuntimeError(f'{prefix} received non-2xx')
|
||||
code, _, body = http(port, '/small.html', headers={'Accept-Encoding': 'identity'})
|
||||
if code != 200 or body != payload:
|
||||
raise AssertionError(f'{prefix} postcheck failed')
|
||||
rps = field('Requests per second')
|
||||
if rps is None:
|
||||
raise RuntimeError(f'{prefix} missing RPS')
|
||||
sample = {'variant': name, 'concurrency': c, 'repeat': repeat+1,
|
||||
'rps': rps, 'completed': field('Complete requests'), 'failed': 0,
|
||||
'expected_sha256': hashlib.sha256(payload).hexdigest(), 'status': 200}
|
||||
records.append(sample)
|
||||
write_json(args.out / 'samples.json', records)
|
||||
print(sample, flush=True)
|
||||
write_json(args.out / 'complete.json', {'complete': True, 'samples': len(records)})
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,479 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Interleaved before/after/nginx process benchmarks with immutable raw evidence.
|
||||
|
||||
This is a same-host diagnostic experiment, not a simulation or release sign-off.
|
||||
Runs >=5 measured samples per variant/scenario by default, each >=15 seconds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import resource
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from common import Fixture, http, seal_results, sha256, write_json
|
||||
from resources import (Monitor, affinity_snapshot, cgroup_cpu_delta, cgroup_cpu_stat,
|
||||
parse_cpu_set, validate_cpu_sets, wait_for_affinity)
|
||||
from telemetry import traffic_analytics_delta, traffic_analytics_snapshot
|
||||
|
||||
|
||||
SIZES = {"small": "small.html", "large": "large.bin", "huge": "huge.bin"}
|
||||
DROP_COUNTER_FIELDS = {
|
||||
"logs_dropped": "history_dropped_delta",
|
||||
"logging.sinks.queue_dropped": "logging_sinks_queue_dropped_delta",
|
||||
"logging.error_file.queue_dropped": "logging_error_file_queue_dropped_delta",
|
||||
"cache.cold_queue_dropped": "cache_cold_queue_dropped_delta",
|
||||
}
|
||||
DROP_INTERVAL_NOTE = ("Delta brackets measured wrk plus one postflight request and possible in-flight completions; "
|
||||
"percentage uses wrk completed requests as an approximate denominator. "
|
||||
"History rejection does not imply rejection by live broadcast or external sinks.")
|
||||
|
||||
|
||||
def usage_snapshot():
|
||||
usage = resource.getrusage(resource.RUSAGE_CHILDREN)
|
||||
return usage.ru_utime + usage.ru_stime
|
||||
|
||||
|
||||
def effective_threads(requested, concurrency):
|
||||
if requested < 1 or concurrency < 1:
|
||||
raise ValueError("client threads and concurrency must be positive")
|
||||
# wrk floor-divides the connection count between threads. Select a divisor
|
||||
# so its advertised and effective connection counts remain identical.
|
||||
return next(value for value in range(min(requested, concurrency), 0, -1)
|
||||
if concurrency % value == 0)
|
||||
|
||||
|
||||
def wrk_run(args, port, path, duration, concurrency, prefix, roots=None, expected_processes=None):
|
||||
threads = effective_threads(args.client_threads, concurrency)
|
||||
command = [str(args.wrk), "--latency", "-t", str(threads),
|
||||
"-c", str(concurrency), "-d", f"{duration}s", "--timeout", "5s",
|
||||
"-H", "Accept-Encoding: identity", "-s", str(Path(__file__).with_name("wrk_report.lua")),
|
||||
f"http://127.0.0.1:{port}/{path}"]
|
||||
if args.client_cpus:
|
||||
command = ["taskset", "-c", args.client_cpus, *command]
|
||||
affinities = {}
|
||||
expected_processes = expected_processes or {}
|
||||
if roots:
|
||||
for label, pid in roots.items():
|
||||
expected = parse_cpu_set(args.server_cpus if label == "server" else args.origin_cpus)
|
||||
affinities[label] = affinity_snapshot(pid, expected, expected_processes.get(label))
|
||||
monitor = Monitor(roots, args.resource_interval, expected_processes) if roots else None
|
||||
if monitor:
|
||||
monitor.start()
|
||||
cpu_start = usage_snapshot()
|
||||
started = time.monotonic()
|
||||
process = None
|
||||
stdout, stderr = "", ""
|
||||
try:
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
affinities["generator"] = wait_for_affinity(process, parse_cpu_set(args.client_cpus))
|
||||
stdout, stderr = process.communicate(timeout=duration + 30)
|
||||
finally:
|
||||
if process is not None and process.poll() is None:
|
||||
process.kill()
|
||||
stdout, stderr = process.communicate(timeout=5)
|
||||
if monitor:
|
||||
monitor.stop()
|
||||
prefix.parent.mkdir(parents=True, exist_ok=True)
|
||||
prefix.with_suffix(".txt").write_text(stdout + "\n" + stderr)
|
||||
write_json(prefix.with_name(prefix.name + "-affinity.json"), affinities)
|
||||
if monitor:
|
||||
write_json(prefix.with_name(prefix.name + "-resources.json"), monitor.samples)
|
||||
if monitor.errors:
|
||||
write_json(prefix.with_name(prefix.name + "-resource-errors.json"), monitor.errors)
|
||||
cpu_seconds = usage_snapshot() - cpu_start
|
||||
wall_seconds = time.monotonic() - started
|
||||
raw = stdout + "\n" + stderr
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"wrk exited {process.returncode}: {raw[-2000:]}")
|
||||
line = next((line for line in stdout.splitlines() if line.startswith("RUSTPROXY_BENCH_JSON ")), None)
|
||||
if line is None:
|
||||
raise RuntimeError("wrk produced no structured report")
|
||||
result = json.loads(line.removeprefix("RUSTPROXY_BENCH_JSON "))
|
||||
if result["requests"] <= 0 or result["duration_us"] <= 0:
|
||||
write_json(prefix.with_suffix(".json"), result)
|
||||
raise RuntimeError("wrk completed no requests or reported an invalid duration; raw evidence retained")
|
||||
result.update({"command": command, "wall_seconds": wall_seconds,
|
||||
"client_threads": threads, "requested_connections": concurrency,
|
||||
"client_threads_requested": args.client_threads,
|
||||
"effective_connections": threads * (concurrency // threads),
|
||||
"generator_cpu_seconds": cpu_seconds,
|
||||
"rps": result["requests"] * 1_000_000 / result["duration_us"],
|
||||
"error_total": sum(value for key, value in result.items() if key.startswith("errors_"))})
|
||||
if monitor:
|
||||
result["resources"] = monitor.summary(result["requests"])
|
||||
write_json(prefix.with_suffix(".json"), result)
|
||||
return result
|
||||
|
||||
|
||||
def payload_check(port, path, expected):
|
||||
"""A full exact-body check outside the wrk measurement interval."""
|
||||
try:
|
||||
status, headers, body = http(port, "/" + path, headers={"Accept-Encoding": "identity"})
|
||||
return {"status": status, "bytes": len(body), "sha256": hashlib.sha256(body).hexdigest(),
|
||||
"expected_bytes": len(expected), "expected_sha256": hashlib.sha256(expected).hexdigest(),
|
||||
"content_length": headers.get("content-length"), "content_encoding": headers.get("content-encoding"),
|
||||
"approximate_header_bytes": 19 + sum(len(key) + len(value) + 4 for key, value in headers.items()),
|
||||
"passed": status == 200 and body == expected}
|
||||
except Exception as error:
|
||||
return {"passed": False, "error": str(error), "expected_bytes": len(expected),
|
||||
"expected_sha256": hashlib.sha256(expected).hexdigest()}
|
||||
|
||||
|
||||
def transfer_sanity(result, payload_bytes, header_bytes, concurrency):
|
||||
"""Check gross truncation/size anomalies; this is not per-response hashing."""
|
||||
requests, wire_bytes = result["requests"], result["bytes"]
|
||||
minimum = requests * payload_bytes
|
||||
# wrk counts headers and partially received final responses in its wire bytes.
|
||||
header_allowance = max(1024, 2 * header_bytes)
|
||||
maximum = (requests + concurrency) * (payload_bytes + header_allowance)
|
||||
return {"passed": requests > 0 and minimum <= wire_bytes <= maximum,
|
||||
"wire_bytes_per_request": wire_bytes / requests if requests else None,
|
||||
"expected_payload_bytes_per_request": payload_bytes,
|
||||
"minimum_wire_bytes": minimum, "maximum_wire_bytes": maximum,
|
||||
"header_allowance_per_response": header_allowance,
|
||||
"inflight_response_allowance": concurrency}
|
||||
|
||||
|
||||
def counter_values(value, prefix=""):
|
||||
"""Retain numeric drop and rotation counters without session/config data."""
|
||||
found = {}
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
path = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(child, (int, float)) and any(part in key for part in ["dropped", "rotations", "generation", "requests_in_generation"]):
|
||||
found[path] = child
|
||||
found.update(counter_values(child, path))
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
found.update(counter_values(child, f"{prefix}[{index}]"))
|
||||
return found
|
||||
|
||||
|
||||
def validate_drop_counters(values):
|
||||
for name in DROP_COUNTER_FIELDS:
|
||||
if type(values.get(name)) is not int or values[name] < 0:
|
||||
raise RuntimeError(f"missing or invalid cumulative status counter: {name}")
|
||||
return values
|
||||
|
||||
|
||||
def status_counter_snapshot(rust):
|
||||
if rust is None:
|
||||
return None
|
||||
status, _, body = rust.admin("/api/status")
|
||||
if status != 200:
|
||||
raise RuntimeError(f"{rust.label} status capture failed: HTTP {status}")
|
||||
return counter_values(json.loads(body))
|
||||
|
||||
|
||||
def drop_counter_report(before, after, requests):
|
||||
if requests <= 0:
|
||||
raise ValueError("drop-rate denominator must contain completed requests")
|
||||
if (before is None) != (after is None):
|
||||
raise RuntimeError("both status snapshots are required for a drop delta")
|
||||
if before is not None:
|
||||
validate_drop_counters(before)
|
||||
validate_drop_counters(after)
|
||||
deltas = {name: after[name] - before[name] if before is not None else None
|
||||
for name in DROP_COUNTER_FIELDS}
|
||||
if any(value is not None and value < 0 for value in deltas.values()):
|
||||
raise RuntimeError("cumulative drop counter decreased during measured interval")
|
||||
fields = {field: deltas[name] for name, field in DROP_COUNTER_FIELDS.items()}
|
||||
fields["history_drop_percent_of_completed_requests"] = (
|
||||
100 * deltas["logs_dropped"] / requests if before is not None else None)
|
||||
return {"applicable": before is not None, "counter_deltas": deltas, "row_fields": fields,
|
||||
"completed_requests_denominator": requests, "denominator_is_approximate": True,
|
||||
"interval_note": DROP_INTERVAL_NOTE}
|
||||
|
||||
|
||||
def analytics_counter_report(before, after, requests):
|
||||
if requests <= 0 or (before is None) != (after is None):
|
||||
raise ValueError("analytics delta requires paired snapshots and completed requests")
|
||||
delta = traffic_analytics_delta(before, after) if before is not None else {
|
||||
"queue_dropped_delta": None, "recorded_requests_delta": None, "interval_seconds": None}
|
||||
fields = {"analytics_queue_dropped_delta": delta["queue_dropped_delta"],
|
||||
"analytics_recorded_requests_delta": delta["recorded_requests_delta"],
|
||||
"analytics_queue_drop_percent_of_completed_requests": (
|
||||
100 * delta["queue_dropped_delta"] / requests if before is not None else None)}
|
||||
return {"applicable": before is not None, **delta, "row_fields": fields,
|
||||
"completed_requests_denominator": requests, "denominator_is_approximate": True,
|
||||
"interval_note": DROP_INTERVAL_NOTE,
|
||||
"recorded_requests_note": "Asynchronous analytics records; not HTTP success or completion counts."}
|
||||
|
||||
|
||||
def persist_rows(out, rows):
|
||||
write_json(out / "runs.json", rows)
|
||||
if not rows:
|
||||
return
|
||||
with (out / "runs.csv").open("w", newline="") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
groups = {}
|
||||
for row in rows:
|
||||
groups.setdefault((row["mode"], row["size"], row["concurrency"], row["variant"]), []).append(row)
|
||||
summary = []
|
||||
for (mode, size, concurrency, variant), group in sorted(groups.items()):
|
||||
rps = [row["rps"] for row in group]
|
||||
summary.append({"mode": mode, "size": size, "concurrency": concurrency, "variant": variant,
|
||||
"samples": len(group), "rps_median": statistics.median(rps),
|
||||
"rps_min": min(rps), "rps_max": max(rps),
|
||||
"rps_cv_percent": statistics.stdev(rps) / statistics.mean(rps) * 100 if len(rps) > 1 else None,
|
||||
"p50_us_median": statistics.median(row["p50_us"] for row in group),
|
||||
"p95_us_median": statistics.median(row["p95_us"] for row in group),
|
||||
"p99_us_median": statistics.median(row["p99_us"] for row in group),
|
||||
"server_cpu_us_per_request_median": statistics.median(row["server_cpu_us_per_request"] for row in group),
|
||||
"server_rss_peak_bytes": max(row["server_rss_peak_bytes"] for row in group),
|
||||
"server_fd_peak": max(row["server_fd_peak"] for row in group),
|
||||
"requests": sum(row["requests"] for row in group),
|
||||
"cgroup_throttled_samples": sum(row["cgroup_nr_throttled_delta"] > 0 for row in group),
|
||||
"cgroup_nr_throttled_delta": sum(row["cgroup_nr_throttled_delta"] for row in group),
|
||||
"cgroup_throttled_usec_delta": sum(row["cgroup_throttled_usec_delta"] for row in group),
|
||||
"validation_failures": sum(not (row["payload_before_passed"] and row["payload_after_passed"]
|
||||
and row["transfer_sanity_passed"]) for row in group),
|
||||
"errors": sum(row["error_total"] for row in group)})
|
||||
write_json(out / "summary.json", summary)
|
||||
with (out / "summary.csv").open("w", newline="") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=list(summary[0]))
|
||||
writer.writeheader()
|
||||
writer.writerows(summary)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
|
||||
parser.add_argument("--max-idle", action="append", default=[], metavar="LABEL=COUNT")
|
||||
parser.add_argument("--nginx", type=Path, required=True)
|
||||
parser.add_argument("--wrk", type=Path, required=True)
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
parser.add_argument("--workers", type=int, default=4)
|
||||
parser.add_argument("--origin-workers", type=int, default=4)
|
||||
parser.add_argument("--client-threads", type=int, default=4)
|
||||
parser.add_argument("--server-cpus")
|
||||
parser.add_argument("--origin-cpus")
|
||||
parser.add_argument("--client-cpus")
|
||||
parser.add_argument("--duration", type=int, default=15)
|
||||
parser.add_argument("--warmup", type=int, default=3)
|
||||
parser.add_argument("--repeats", type=int, default=5)
|
||||
parser.add_argument("--concurrency", default="64")
|
||||
parser.add_argument("--sizes", default="small,large")
|
||||
parser.add_argument("--modes", default="proxy,static")
|
||||
parser.add_argument("--resource-interval", type=float, default=.5)
|
||||
parser.add_argument("--exploratory", action="store_true", help="explicitly allow <5 repetitions or <15s samples")
|
||||
parser.add_argument("--prepare-only", action="store_true", help="start processes and verify hashes; run no load generator")
|
||||
args = parser.parse_args()
|
||||
if (args.duration < 15 or args.repeats < 5) and not args.exploratory:
|
||||
parser.error("acceptance requires >=15-second runs and >=5 repetitions; use --exploratory for labelled short diagnostics")
|
||||
if args.out.exists() and any(args.out.iterdir()):
|
||||
parser.error("--out must be a new or empty directory so raw evidence is never overwritten")
|
||||
modes, sizes = args.modes.split(","), args.sizes.split(",")
|
||||
concurrencies = [int(value) for value in args.concurrency.split(",")]
|
||||
if any(mode not in ("proxy", "static") for mode in modes) or any(size not in SIZES for size in sizes):
|
||||
parser.error("supported modes: proxy,static; sizes: small,large,huge")
|
||||
if min(concurrencies) < 1 or max(concurrencies) > 4096:
|
||||
parser.error("concurrency must be 1..4096")
|
||||
if args.client_threads < 1:
|
||||
parser.error("--client-threads must be positive")
|
||||
if args.workers < 1 or args.origin_workers < 1:
|
||||
parser.error("--workers and --origin-workers must be positive")
|
||||
try:
|
||||
cpu_sets = validate_cpu_sets({"server": args.server_cpus, "origin": args.origin_cpus,
|
||||
"client": args.client_cpus}, set(os.sched_getaffinity(0)))
|
||||
except ValueError as error:
|
||||
parser.error(str(error))
|
||||
if args.resource_interval <= 0:
|
||||
parser.error("--resource-interval must be positive")
|
||||
args.nginx, args.wrk, args.out = args.nginx.resolve(), args.wrk.resolve(), args.out.resolve()
|
||||
variants = dict(item.split("=", 1) for item in args.binary)
|
||||
if len(variants) != len(args.binary) or "nginx" in variants:
|
||||
parser.error("variant names must be unique and must not be nginx")
|
||||
max_idle = dict(item.split("=", 1) for item in args.max_idle)
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
environment = {"kind": "same-host real HTTP/1.1 process benchmark", "simulation": False,
|
||||
"exploratory": args.exploratory, "prepare_only": args.prepare_only,
|
||||
"acceptance_sampling": args.duration >= 15 and args.repeats >= 5 and not args.prepare_only,
|
||||
"platform": platform.platform(), "python": sys.version,
|
||||
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"cpu_affinity_available": sorted(os.sched_getaffinity(0)),
|
||||
"cpu_sets": {label: sorted(cpus) for label, cpus in cpu_sets.items()},
|
||||
"resource_process_discovery": "PPid map from /proc/*/stat; root and configured nginx process counts must be readable",
|
||||
"expected_nginx_processes": {"frontend": args.workers + 1, "origin": args.origin_workers + 1},
|
||||
"generator_plan": [{"connections": concurrency, "requested_threads": args.client_threads,
|
||||
"effective_threads": effective_threads(args.client_threads, concurrency)}
|
||||
for concurrency in concurrencies],
|
||||
"settings": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()},
|
||||
"wrk_sha256": sha256(args.wrk), "nginx_sha256": sha256(args.nginx),
|
||||
"limitations": ["Generator, upstream and frontend share one host and kernel; not production capacity",
|
||||
"Closed-loop wrk latency includes its histogram correction; no open-loop arrival-rate SLA claim",
|
||||
"RSS sums processes and may double-count shared pages in nginx",
|
||||
"RSS and FD peaks are sampled, not absolute instantaneous peaks",
|
||||
"Exact HTTP 200 and payload hashes are checked before/after each measured run, not for every response during load",
|
||||
"wrk status errors count only status >399; aggregate bytes/request is a gross sanity check, not response integrity proof",
|
||||
"cgroup CPU-throttling deltas cover all processes in the shared cgroup, not only the measured server",
|
||||
DROP_INTERVAL_NOTE,
|
||||
"No TLS/H2/H3 speed inference; no CPU cycles/allocation profiler claim",
|
||||
"nginx has a smaller feature set; request logging/ACL/analytics work is not feature-matched"]}
|
||||
for path in ["/sys/fs/cgroup/cpu.max", "/sys/fs/cgroup/memory.max", "/proc/version"]:
|
||||
try:
|
||||
environment[path] = Path(path).read_text().strip()
|
||||
except OSError:
|
||||
environment[path] = None
|
||||
write_json(args.out / "environment.json", environment)
|
||||
expected_samples = len(modes) * len(sizes) * len(concurrencies) * args.repeats * (len(variants) + 1)
|
||||
write_json(args.out / "completion.json", {"completed": False, "samples": 0,
|
||||
"expected_samples": expected_samples,
|
||||
"simulation": False, "prepare_only": args.prepare_only})
|
||||
rows, error = [], None
|
||||
try:
|
||||
with Fixture(args.out / "fixture", workers=args.workers, server_cpus=args.server_cpus,
|
||||
origin_cpus=args.origin_cpus, origin_workers=args.origin_workers) as fixture:
|
||||
nginx = fixture.nginx(args.nginx)
|
||||
servers = {"nginx": {"process": nginx["process"], "proxy": nginx["reverse_port"],
|
||||
"static": nginx["static_port"], "rust": None}}
|
||||
for label, binary in variants.items():
|
||||
rust = fixture.rust(Path(binary), label, max_idle=int(max_idle[label]) if label in max_idle else None)
|
||||
rust.login()
|
||||
servers[label] = {"process": rust.process, "proxy": rust.reverse_port, "static": rust.static_port, "rust": rust}
|
||||
quiescence = {label: server["rust"].wait_quiescent()
|
||||
for label, server in servers.items() if server["rust"] is not None}
|
||||
write_json(args.out / "quiescence-preflight.json", quiescence)
|
||||
initial_affinity = {"origin": affinity_snapshot(nginx["origin"].pid, cpu_sets["origin"], args.origin_workers + 1)}
|
||||
initial_affinity.update({label: affinity_snapshot(server["process"].pid, cpu_sets["server"],
|
||||
args.workers + 1 if label == "nginx" else 1)
|
||||
for label, server in servers.items()})
|
||||
write_json(args.out / "affinity-preflight.json", initial_affinity)
|
||||
# The fixture has only master + configured workers. Refuse a
|
||||
# master-only snapshot even if HTTP payload checks would succeed.
|
||||
prepare_monitor = Monitor({"server": nginx["process"].pid, "origin": nginx["origin"].pid},
|
||||
args.resource_interval,
|
||||
{"server": args.workers + 1, "origin": args.origin_workers + 1})
|
||||
prepare_monitor.sample()
|
||||
write_json(args.out / "resources-preflight.json", prepare_monitor.samples)
|
||||
telemetry_preflight = {label: {"status": status_counter_snapshot(server["rust"]),
|
||||
"traffic": traffic_analytics_snapshot(server["rust"]) if server["rust"] else None}
|
||||
for label, server in servers.items()}
|
||||
write_json(args.out / "telemetry-preflight.json", telemetry_preflight)
|
||||
for snapshot in telemetry_preflight.values():
|
||||
if snapshot["status"] is not None:
|
||||
validate_drop_counters(snapshot["status"])
|
||||
preflight = []
|
||||
for label, server in servers.items():
|
||||
for mode, size in itertools.product(modes, sizes):
|
||||
payload = (fixture.site / SIZES[size]).read_bytes()
|
||||
check = payload_check(server[mode], SIZES[size], payload)
|
||||
preflight.append({"variant": label, "mode": mode, "size": size, **check})
|
||||
write_json(args.out / "preflight.json", preflight)
|
||||
if not check["passed"]:
|
||||
raise AssertionError(f"payload preflight failed: {label} {mode} {size}: {check}")
|
||||
write_json(args.out / "preflight.json", preflight)
|
||||
labels = [*variants, "nginx"]
|
||||
schedule = []
|
||||
for mode, size, concurrency in itertools.product(modes, sizes, concurrencies):
|
||||
for repetition in range(args.repeats):
|
||||
offset = repetition % len(labels)
|
||||
order = labels[offset:] + labels[:offset]
|
||||
if repetition % 2:
|
||||
order.reverse()
|
||||
for variant in order:
|
||||
schedule.append({"sequence": len(schedule) + 1, "mode": mode, "size": size,
|
||||
"concurrency": concurrency, "repetition": repetition + 1, "variant": variant})
|
||||
write_json(args.out / "schedule.json", schedule)
|
||||
if not args.prepare_only:
|
||||
for item in schedule:
|
||||
server = servers[item["variant"]]
|
||||
expected_processes = {"origin": args.origin_workers + 1,
|
||||
"server": args.workers + 1 if item["variant"] == "nginx" else 1}
|
||||
stem = f"{item['sequence']:04}-{item['mode']}-{item['size']}-c{item['concurrency']}-{item['variant']}"
|
||||
if args.warmup:
|
||||
warmup = wrk_run(args, server[item["mode"]], SIZES[item["size"]], args.warmup,
|
||||
item["concurrency"], args.out / "warmup" / stem)
|
||||
if warmup["error_total"]:
|
||||
raise RuntimeError(f"warmup errors in {stem}; raw evidence retained")
|
||||
payload = (fixture.site / SIZES[item["size"]]).read_bytes()
|
||||
before = payload_check(server[item["mode"]], SIZES[item["size"]], payload)
|
||||
write_json(args.out / "checks" / (stem + "-before.json"), before)
|
||||
if not before["passed"]:
|
||||
raise RuntimeError(f"exact payload check failed before {stem}: {before}")
|
||||
counters_before = status_counter_snapshot(server["rust"])
|
||||
write_json(args.out / "status" / (stem + "-before.json"), counters_before)
|
||||
if counters_before is not None:
|
||||
validate_drop_counters(counters_before)
|
||||
analytics_before = traffic_analytics_snapshot(server["rust"]) if server["rust"] else None
|
||||
write_json(args.out / "status" / (stem + "-traffic-before.json"), analytics_before)
|
||||
cpu_before = cgroup_cpu_stat()
|
||||
try:
|
||||
result = wrk_run(args, server[item["mode"]], SIZES[item["size"]], args.duration,
|
||||
item["concurrency"], args.out / "raw" / stem,
|
||||
{"server": server["process"].pid, "origin": nginx["origin"].pid},
|
||||
expected_processes)
|
||||
finally:
|
||||
cpu_after = cgroup_cpu_stat()
|
||||
cpu_delta = cgroup_cpu_delta(cpu_before, cpu_after)
|
||||
throttling = {"before": cpu_before, "after": cpu_after, "delta": cpu_delta}
|
||||
write_json(args.out / "checks" / (stem + "-cgroup-cpu.json"), throttling)
|
||||
after = payload_check(server[item["mode"]], SIZES[item["size"]], payload)
|
||||
write_json(args.out / "checks" / (stem + "-after.json"), after)
|
||||
write_json(args.out / "checks" / (stem + "-affinity-after.json"), {
|
||||
"server": affinity_snapshot(server["process"].pid, cpu_sets["server"], expected_processes.get("server")),
|
||||
"origin": affinity_snapshot(nginx["origin"].pid, cpu_sets["origin"], expected_processes["origin"])})
|
||||
counters_after = status_counter_snapshot(server["rust"])
|
||||
write_json(args.out / "status" / (stem + ".json"), counters_after)
|
||||
analytics_after = traffic_analytics_snapshot(server["rust"]) if server["rust"] else None
|
||||
write_json(args.out / "status" / (stem + "-traffic-after.json"), analytics_after)
|
||||
drops = drop_counter_report(counters_before, counters_after, result["requests"])
|
||||
write_json(args.out / "status" / (stem + "-delta.json"), drops)
|
||||
analytics = analytics_counter_report(analytics_before, analytics_after, result["requests"])
|
||||
write_json(args.out / "status" / (stem + "-traffic-delta.json"), analytics)
|
||||
sanity = transfer_sanity(result, len(payload), before["approximate_header_bytes"], item["concurrency"])
|
||||
result.update({"payload_before": before, "payload_after": after, "transfer_sanity": sanity,
|
||||
"cgroup_cpu": throttling, "observability": drops, "traffic_analytics": analytics})
|
||||
write_json((args.out / "raw" / stem).with_suffix(".json"), result)
|
||||
row = {**item, **{key: result[key] for key in ["requests", "bytes", "duration_us", "rps", "p50_us", "p95_us", "p99_us",
|
||||
"client_threads_requested", "client_threads", "requested_connections", "effective_connections",
|
||||
"error_total", "errors_connect", "errors_read", "errors_write", "errors_timeout", "errors_status", "generator_cpu_seconds"]}}
|
||||
row.update({"payload_before_passed": before["passed"], "payload_after_passed": after["passed"],
|
||||
"transfer_sanity_passed": sanity["passed"], "wire_bytes_per_request": sanity["wire_bytes_per_request"],
|
||||
"cgroup_nr_throttled_delta": cpu_delta["nr_throttled"],
|
||||
"cgroup_throttled_usec_delta": cpu_delta["throttled_usec"]})
|
||||
row.update(drops["row_fields"])
|
||||
row.update(analytics["row_fields"])
|
||||
for group in ["server", "origin"]:
|
||||
row.update({f"{group}_{key}": value for key, value in result["resources"][group].items()})
|
||||
rows.append(row)
|
||||
persist_rows(args.out, rows)
|
||||
print(json.dumps({"sequence": item["sequence"], "total": len(schedule), "variant": item["variant"],
|
||||
"scenario": f"{item['mode']}_{item['size']}_c{item['concurrency']}",
|
||||
"rps": round(result["rps"], 2), "p95_ms": result["p95_us"] / 1000,
|
||||
"errors": result["error_total"]}), flush=True)
|
||||
if result["error_total"]:
|
||||
raise RuntimeError(f"benchmark errors in {stem}; raw evidence retained")
|
||||
if not after["passed"] or not sanity["passed"]:
|
||||
raise RuntimeError(f"payload/byte-count validation failed in {stem}; raw evidence retained")
|
||||
except (Exception, KeyboardInterrupt) as exc:
|
||||
error = {"error": str(exc) or type(exc).__name__, "type": type(exc).__name__,
|
||||
"interrupted": isinstance(exc, KeyboardInterrupt), "traceback": traceback.format_exc()}
|
||||
write_json(args.out / "failure.json", error)
|
||||
print(json.dumps(error), flush=True)
|
||||
finally:
|
||||
persist_rows(args.out, rows)
|
||||
completed = error is None and (args.prepare_only or len(rows) == expected_samples)
|
||||
write_json(args.out / "completion.json", {"completed": completed, "samples": len(rows),
|
||||
"expected_samples": expected_samples,
|
||||
"simulation": False, "prepare_only": args.prepare_only,
|
||||
"finished_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
|
||||
seal_results(args.out)
|
||||
return (130 if error.get("interrupted") else 1) if error else (0 if completed else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Cache and response-processing paths must survive disabled-path optimization."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import copy
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
import zlib
|
||||
|
||||
from common import http
|
||||
|
||||
|
||||
def run(check, rust, peer):
|
||||
port = rust.reverse_port
|
||||
before = peer.count("GET", "/fresh")
|
||||
results = [http(port, "/fresh") for _ in range(2)]
|
||||
check("cache.global_off", all(s == 200 and h.get("x-cache") != "HIT" for s, h, b in results)
|
||||
and peer.count("GET", "/fresh") - before == 2)
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["cache"] = {"enabled": True, "expose_status": True, "expose_hits": True,
|
||||
"expose_cache_status": True, "http_invalidation_allowed_cidrs": ["127.0.0.1/32"]}
|
||||
config["web_services"][0]["cache_control"] = "public, max-age=60"
|
||||
rust.apply(config, "cache-enabled")
|
||||
first = http(port, "/fresh")
|
||||
second = http(port, "/fresh")
|
||||
check("cache.miss_then_hit", first[0] == 200 and first[1].get("x-cache") == "MISS"
|
||||
and second[1].get("x-cache") == "HIT" and first[2] == second[2],
|
||||
first=first[1].get("x-cache"), second=second[1].get("x-cache"))
|
||||
results = {}
|
||||
for language in ["en", "pl", "en", "pl"]:
|
||||
response = http(port, "/vary", headers={"Accept-Language": language})
|
||||
previous = results.get(language)
|
||||
if previous:
|
||||
check("cache.vary_hit." + language, response[1].get("x-cache") == "HIT" and response[2] == previous[2],
|
||||
cache=response[1].get("x-cache"))
|
||||
results[language] = response
|
||||
check("cache.vary_isolation", results["en"][2] != results["pl"][2])
|
||||
for path in ["/private", "/no-store", "/cookie"]:
|
||||
before = peer.count("GET", path)
|
||||
first, second = http(port, path), http(port, path)
|
||||
check("cache.nonpublic_bypass." + path, first[0] == second[0] == 200
|
||||
and first[1].get("x-cache") != "HIT" and second[1].get("x-cache") != "HIT"
|
||||
and peer.count("GET", path) - before == 2,
|
||||
first=first[1].get("x-cache"), second=second[1].get("x-cache"))
|
||||
for name, headers in [("authorization", {"Authorization": "Bearer fixture-value"}),
|
||||
("cookie", {"Cookie": "application_session=fixture"})]:
|
||||
before = peer.count("GET", "/fresh")
|
||||
s, h, b = http(port, "/fresh", headers=headers)
|
||||
check("cache.request_bypass." + name, s == 200 and h.get("x-cache") != "HIT"
|
||||
and peer.count("GET", "/fresh") - before == 1, cache=h.get("x-cache"))
|
||||
before = peer.count("GET", "/fresh")
|
||||
s, h, b = http(port, "/fresh", "GET", {"Content-Type": "application/octet-stream"}, b"nonempty-get-body")
|
||||
check("cache.nonempty_get_bypass_and_preserve", s == 200 and h.get("x-cache") != "HIT"
|
||||
and h.get("x-received-body-bytes") == "17" and peer.count("GET", "/fresh") - before == 1,
|
||||
cache=h.get("x-cache"), origin_body_bytes=h.get("x-received-body-bytes"))
|
||||
s, h, b = http(port, "/fresh", "PURGE")
|
||||
after = http(port, "/fresh")
|
||||
check("cache.http_purge", s in (200, 202, 204) and after[1].get("x-cache") == "MISS", status=s)
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
results = list(pool.map(lambda _: http(port, "/concurrent"), range(8)))
|
||||
check("cache.concurrent_fill", all(item[0] == 200 for item in results)
|
||||
and len({item[2] for item in results}) == 1, origin_requests=peer.count("GET", "/concurrent"))
|
||||
first = http(port, "/revalidate")
|
||||
time.sleep(1.2)
|
||||
second = http(port, "/revalidate")
|
||||
check("cache.expired_validator_revalidation", first[0] == second[0] == 200 and first[2] == second[2]
|
||||
and peer.count("GET", "/revalidate") >= 2, cache=second[1].get("x-cache"))
|
||||
|
||||
first, second = http(rust.static_port), http(rust.static_port)
|
||||
check("cache.static_hit", first[0] == second[0] == 200 and second[1].get("x-cache") == "HIT"
|
||||
and first[2] == second[2], cache=second[1].get("x-cache"))
|
||||
empty_first, empty_second = http(rust.static_port, "/empty.txt"), http(rust.static_port, "/empty.txt")
|
||||
check("cache.static_empty_hit", empty_first[0] == empty_second[0] == 200
|
||||
and empty_second[1].get("x-cache") == "HIT" and empty_second[2] == b"",
|
||||
first=empty_first[1].get("x-cache"), second=empty_second[1].get("x-cache"))
|
||||
hs, hh, hb = http(rust.static_port, "/download.txt", "HEAD")
|
||||
gs, gh, gb = http(rust.static_port, "/download.txt")
|
||||
check("cache.static_head_cannot_fill_empty_get", hs == gs == 200 and hb == b""
|
||||
and gb == (rust.fixture.site / "download.txt").read_bytes(), head_status=hs, get_status=gs)
|
||||
s, h, b = http(rust.static_port, headers={"Range": "bytes=0-9"})
|
||||
check("cache.static_range_survives", s == 206 and b == first[2][:10], status=s)
|
||||
s, h, b = http(rust.static_port, headers={"If-None-Match": first[1]["etag"]})
|
||||
check("cache.static_conditional_survives", s == 304 and b == b"", status=s)
|
||||
|
||||
config["vhosts"][0]["cache"] = {"enabled": False}
|
||||
rust.apply(config, "cache-vhost-disabled")
|
||||
first, second = http(port, "/fresh"), http(port, "/fresh")
|
||||
check("cache.vhost_off", all(response[1].get("x-cache") != "HIT" for response in [first, second])
|
||||
and first[2] != second[2])
|
||||
config["vhosts"][0]["cache"] = {"enabled": True}
|
||||
config["vhosts"][0]["routes"] = [{"id": "fresh-no-cache", "path_prefix": "/fresh", "cache": False}]
|
||||
rust.apply(config, "cache-route-disabled")
|
||||
first, second = http(port, "/fresh"), http(port, "/fresh")
|
||||
check("cache.route_off", all(response[1].get("x-cache") != "HIT" for response in [first, second])
|
||||
and first[2] != second[2])
|
||||
config["cache"]["enabled"] = False
|
||||
config["vhosts"][0]["routes"] = []
|
||||
rust.apply(config, "cache-global-disabled-vhost-enabled")
|
||||
first, second = http(port, "/fresh"), http(port, "/fresh")
|
||||
check("cache.vhost_cannot_override_global_off", first[2] != second[2]
|
||||
and second[1].get("x-cache") != "HIT")
|
||||
|
||||
|
||||
def transforms(check, rust, peer):
|
||||
config = copy.deepcopy(rust.base)
|
||||
original = (rust.fixture.site / "app.json").read_bytes()
|
||||
for port, source in [(rust.reverse_port, "proxy"), (rust.static_port, "static")]:
|
||||
s, h, b = http(port, "/app.json")
|
||||
check("transform.off." + source, s == 200 and b == original, status=s)
|
||||
config["transform"] = {"enabled": True, "json": True, "html": True}
|
||||
for vhost in config["vhosts"]:
|
||||
vhost["transform"] = {"enabled": True, "json": True, "html": True}
|
||||
rust.apply(config, "transform-enabled-cache-off")
|
||||
for port, source in [(rust.reverse_port, "proxy"), (rust.static_port, "static")]:
|
||||
s, h, b = http(port, "/app.json")
|
||||
check("transform.on." + source, s == 200 and json.loads(b) == json.loads(original)
|
||||
and len(b) < len(original), original_bytes=len(original), result_bytes=len(b), status=s)
|
||||
s, h, b = http(port, "/large.bin")
|
||||
check("transform.binary_unchanged." + source, s == 200 and b == (rust.fixture.site / "large.bin").read_bytes())
|
||||
s, h, b = http(port, "/app.json", headers={"Range": "bytes=0-9"})
|
||||
check("transform.range_exclusion." + source, s == 206 and b == original[:10], status=s)
|
||||
config["cache"] = {"enabled": True, "expose_status": True}
|
||||
rust.apply(config, "transform-enabled-cache-on")
|
||||
first, second = http(rust.reverse_port, "/app.json"), http(rust.reverse_port, "/app.json")
|
||||
check("transform.cache_combination", second[0] == 200 and second[1].get("x-cache") == "HIT"
|
||||
and first[2] == second[2] and json.loads(second[2]) == json.loads(original),
|
||||
cache=second[1].get("x-cache"))
|
||||
|
||||
rust.reset()
|
||||
expected = (rust.fixture.site / "compress.txt").read_bytes()
|
||||
for port, source in [(rust.reverse_port, "proxy"), (rust.static_port, "static")]:
|
||||
s, h, b = http(port, "/compress.txt", headers={"Accept-Encoding": "gzip"})
|
||||
check("compression.gzip." + source, s == 200 and h.get("content-encoding") == "gzip"
|
||||
and gzip.decompress(b) == expected and len(b) < len(expected), bytes=len(b))
|
||||
s, h, b = http(port, "/compress.txt", headers={"Accept-Encoding": "gzip;q=0, identity"})
|
||||
check("compression.identity_qvalue." + source, s == 200 and "content-encoding" not in h and b == expected)
|
||||
s, h, b = http(port, "/compress.txt", headers={"Accept-Encoding": "gzip", "Range": "bytes=0-9"})
|
||||
check("compression.range_exclusion." + source, s == 206 and "content-encoding" not in h and b == expected[:10])
|
||||
s, h, b = http(rust.reverse_port, "/no-transform", headers={"Accept-Encoding": "gzip"})
|
||||
check("compression.no_transform_exclusion", s == 200 and "content-encoding" not in h)
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["vhosts"][0]["compression"] = {"enabled": False}
|
||||
rust.apply(config, "compression-vhost-disabled")
|
||||
s, h, b = http(rust.reverse_port, "/compress.txt", headers={"Accept-Encoding": "gzip"})
|
||||
check("compression.vhost_off", s == 200 and "content-encoding" not in h and b == expected)
|
||||
config["compression"] = {"gzip": False, "br": False, "zstd": False, "deflate": False}
|
||||
config["vhosts"][0]["compression"] = {"enabled": True, "gzip": True}
|
||||
rust.apply(config, "compression-global-disabled")
|
||||
s, h, b = http(rust.reverse_port, "/compress.txt", headers={"Accept-Encoding": "gzip"})
|
||||
check("compression.global_codec_ceiling", s == 200 and "content-encoding" not in h and b == expected)
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["compression"] = {"gzip": True, "br": True, "zstd": True, "deflate": True}
|
||||
config["cache"] = {"enabled": True, "expose_status": True}
|
||||
rust.apply(config, "compression-cache-enabled")
|
||||
for codec in ["gzip", "deflate", "br", "zstd"]:
|
||||
decoder = None
|
||||
if codec == "gzip":
|
||||
decoder = gzip.decompress
|
||||
elif codec == "deflate":
|
||||
decoder = zlib.decompress
|
||||
elif codec == "br":
|
||||
try:
|
||||
import brotli
|
||||
decoder = brotli.decompress
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
import zstandard
|
||||
decoder = lambda data: zstandard.ZstdDecompressor().stream_reader(io.BytesIO(data)).read()
|
||||
except ImportError:
|
||||
pass
|
||||
if decoder is None:
|
||||
check("compression.codec." + codec, None, skip_reason="optional Python decoder is not installed")
|
||||
continue
|
||||
first = http(rust.reverse_port, "/compress.txt", headers={"Accept-Encoding": codec})
|
||||
second = http(rust.reverse_port, "/compress.txt", headers={"Accept-Encoding": codec})
|
||||
check("compression.codec." + codec, second[0] == 200 and second[1].get("content-encoding") == codec
|
||||
and decoder(first[2]) == decoder(second[2]) == expected, cache=second[1].get("x-cache"))
|
||||
@@ -1,184 +0,0 @@
|
||||
"""Live ACL, authentication, atomic reload and logging regressions."""
|
||||
import base64
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from common import http, write_json
|
||||
|
||||
|
||||
DENY = {"default": "deny", "rules": []}
|
||||
ALLOW_LOCAL = {"default": "deny", "rules": [{"network": "127.0.0.1/32", "action": "allow"}]}
|
||||
|
||||
|
||||
def run(check, rust, peer):
|
||||
port = rust.reverse_port
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["host_policies"] = [{"hosts": ["fixture.test"], "acl": DENY}]
|
||||
rust.apply(config, "policy-global-host")
|
||||
count = peer.count("GET", "/echo")
|
||||
s, h, b = http(port, "/echo", headers={"Host": "fixture.test"})
|
||||
check("acl.global_host_without_vhost_acl", s == 403 and peer.count("GET", "/echo") == count,
|
||||
status=s, upstream_requests=peer.count("GET", "/echo") - count)
|
||||
s, h, b = http(port, "/echo", headers={"Host": "localhost"})
|
||||
check("acl.global_other_host_unchanged", s == 200, status=s)
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["vhosts"][0]["acl"] = DENY
|
||||
rust.apply(config, "policy-vhost-deny")
|
||||
s, h, b = http(port)
|
||||
check("acl.vhost_deny", s == 403, status=s)
|
||||
s, h, b = http(rust.static_port)
|
||||
check("acl.vhost_isolation", s == 200, status=s)
|
||||
config["vhosts"][0]["acl"] = ALLOW_LOCAL
|
||||
rust.apply(config, "policy-vhost-allow")
|
||||
s, h, b = http(port)
|
||||
check("acl.hot_enable_allow", s == 200, status=s)
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["acl_profiles"] = [{"id": "restricted", "name": "Restricted", "acl": DENY}]
|
||||
config["vhosts"][0]["acl_profile_id"] = "restricted"
|
||||
config["vhosts"][0]["acl"] = ALLOW_LOCAL
|
||||
rust.apply(config, "policy-profile-deny")
|
||||
s, h, b = http(port)
|
||||
check("acl.profile_and_vhost_stack", s == 403, status=s)
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["vhosts"][0]["routes"] = [{"id": "denied", "path_prefix": "/echo", "acl": DENY}]
|
||||
rust.apply(config, "policy-location-deny")
|
||||
count = peer.count("GET", "/echo")
|
||||
s, h, b = http(port, "/echo")
|
||||
check("acl.location_before_upstream", s == 403 and peer.count("GET", "/echo") == count, status=s)
|
||||
s, h, b = http(port)
|
||||
check("acl.location_other_path_unchanged", s == 200, status=s)
|
||||
config["vhosts"][0]["acl"] = DENY
|
||||
config["vhosts"][0]["routes"][0]["acl"] = ALLOW_LOCAL
|
||||
rust.apply(config, "policy-location-allow-vhost-deny")
|
||||
s, h, b = http(port, "/echo")
|
||||
check("acl.location_cannot_override_vhost_deny", s == 403, status=s)
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["vhosts"][0]["acl"] = {"default": "deny", "rules": [{"network": "203.0.113.22/32", "action": "allow"}]}
|
||||
rust.apply(config, "policy-untrusted-forwarded")
|
||||
s, h, b = http(port, headers={"X-Forwarded-For": "203.0.113.22", "X-Real-IP": "203.0.113.22"})
|
||||
check("acl.spoofed_forwarded_ip_denied", s == 403, status=s)
|
||||
config["forwarding"] = {"trusted_proxies": ["127.0.0.1/32"]}
|
||||
rust.apply(config, "policy-trusted-forwarded")
|
||||
s, h, b = http(port, headers={"X-Forwarded-For": "203.0.113.22"})
|
||||
check("acl.trusted_forwarded_ip_allowed", s == 200, status=s)
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["vhosts"][0]["rate_limit"] = {"country_access": {"mode": "allow", "countries": ["PL"]}}
|
||||
config["vhosts"][0]["cloudflare_client_ip"] = True
|
||||
rust.apply(config, "policy-country")
|
||||
s, h, b = http(port, headers={"CF-IPCountry": "PL", "CF-Connecting-IP": "203.0.113.22"})
|
||||
check("acl.untrusted_country_header_cannot_allow", s == 403, status=s)
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["vhosts"][0]["method_policy"] = {"mode": "allow", "methods": ["GET", "HEAD"]}
|
||||
rust.apply(config, "policy-method")
|
||||
s, h, b = http(port, "/echo", "POST", body=b"denied method")
|
||||
check("policy.method_allowlist", s in (403, 405), status=s)
|
||||
s, h, b = http(port)
|
||||
check("policy.allowed_method_survives", s == 200, status=s)
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["vhosts"][0]["rate_limit"] = {"enabled": True, "requests": 2, "window_seconds": 60, "burst": 0}
|
||||
rust.apply(config, "policy-rate-limit")
|
||||
statuses = [http(port)[0] for _ in range(3)]
|
||||
check("policy.rate_limit", statuses[:2] == [200, 200] and statuses[2] == 429, statuses=statuses)
|
||||
rust.reset()
|
||||
s, h, b = http(port)
|
||||
check("policy.hot_disable_restores_request", s == 200, status=s)
|
||||
|
||||
# Every route check uses the real admin socket and no bearer/backdoor token.
|
||||
for path in ["/api/config", "/api/status", "/api/logs", "/api/events", "/api/openapi.json", "/assets/app.js"]:
|
||||
s, h, b = http(rust.admin_port, path)
|
||||
check("admin.private." + path, s in (401, 403), status=s)
|
||||
check("admin.login", rust.login() == 200)
|
||||
s, h, b = rust.admin("/api/config")
|
||||
envelope = json.loads(b)
|
||||
check("admin.authenticated_config", s == 200 and "config" in envelope and "revision" in envelope, status=s)
|
||||
s, h, b = rust.admin("/api/config", "PUT", {"expected_revision": envelope["revision"], "config": rust.base}, csrf=False)
|
||||
check("admin.csrf_required", s == 403, status=s)
|
||||
s, h, b = rust.admin("/api/config", origin="http://foreign.example")
|
||||
check("admin.foreign_origin_denied", s == 403, status=s)
|
||||
|
||||
# Generate the disposable Basic Auth hash through the existing secured API.
|
||||
s, h, b = rust.admin("/api/basic-auth/hash", "POST", {"username": "fixtureuser", "password": rust.password})
|
||||
hashed = json.loads(b)
|
||||
check("basic.hash_api", s == 200 and "password_hash" in hashed, status=s)
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["vhosts"][0]["basic_auth"] = {"enabled": True, "require_tls": False,
|
||||
"users": [{"username": "fixtureuser", "password_hash": hashed["password_hash"]}]}
|
||||
rust.apply(config, "policy-basic-auth")
|
||||
s, h, b = http(port, "/echo")
|
||||
check("basic.challenge", s == 401 and "Basic" in h.get("www-authenticate", ""), status=s)
|
||||
credentials = base64.b64encode(("fixtureuser:" + rust.password).encode()).decode()
|
||||
s, h, b = http(port, "/echo", headers={"Authorization": "Basic " + credentials})
|
||||
check("basic.correct_password_and_strip", s == 200 and "authorization" not in json.loads(b)["headers"], status=s)
|
||||
s, h, b = http(port, "/echo", headers={"Authorization": "Basic Zml4dHVyZXVzZXI6d3Jvbmc="})
|
||||
check("basic.wrong_password_denied", s == 401, status=s)
|
||||
config["vhosts"][0]["acl"] = DENY
|
||||
rust.apply(config, "policy-acl-before-basic")
|
||||
s, h, b = http(port, "/echo")
|
||||
check("basic.acl_precedes_auth", s == 403, status=s)
|
||||
rust.reset()
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["vhosts"][0]["frontend"]["listener_ids"] = ["does-not-exist"]
|
||||
rust.apply(config, "invalid-config-retains-snapshot", expected_success=False)
|
||||
s, h, b = http(port)
|
||||
check("reload.invalid_document_retains_previous_snapshot", s == 200, status=s)
|
||||
s, h, b = http(rust.static_port)
|
||||
check("reload.invalid_document_keeps_other_vhost", s == 200, status=s)
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["vhosts"][0]["backends"][0]["allow_private"] = False
|
||||
# Explicit private-address rejection is checked through apply, preserving the
|
||||
# active configuration if compile-time validation rejects the backend.
|
||||
rust.apply(config, "ssrf-private-origin-disallowed", expected_success=False)
|
||||
s, h, b = http(port)
|
||||
check("ssrf.rejected_origin_does_not_replace_snapshot", s == 200, status=s)
|
||||
|
||||
query = "/echo?observability=" + rust.label
|
||||
s, h, b = http(port, query)
|
||||
request_id = json.loads(b)["headers"]["x-request-id"]
|
||||
log = None
|
||||
for _ in range(20):
|
||||
ls, lh, lb = rust.admin("/api/logs?vhost=reverse&limit=512")
|
||||
logs = json.loads(lb)
|
||||
log = next((entry for entry in logs.get("entries", []) if entry.get("request_id") == request_id), None)
|
||||
if log:
|
||||
break
|
||||
time.sleep(.05)
|
||||
check("observability.log_fields_and_body_bytes", log is not None and log.get("status") == 200
|
||||
and log.get("response_bytes") == len(b) and log.get("request_bytes") == 0
|
||||
and log.get("cache_effective_enabled") is False and log.get("transform_effective_enabled") is False
|
||||
and bool(re.fullmatch("[0-9a-f]{64}", log.get("request_id", ""))),
|
||||
recorded_status=log.get("status") if log else None,
|
||||
recorded_response_bytes=log.get("response_bytes") if log else None, response_bytes=len(b))
|
||||
if log:
|
||||
write_json(rust.fixture.results / f"{rust.label}-sample-exchange.json", log)
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["diagnostic_headers"] = {"request_id": True}
|
||||
rust.apply(config, "observability-static-eos")
|
||||
s, h, b = http(rust.static_port)
|
||||
static_request_id = h.get("x-request-id")
|
||||
static_log = None
|
||||
for _ in range(20):
|
||||
ls, lh, lb = rust.admin("/api/logs?vhost=static&limit=64")
|
||||
static_log = next((entry for entry in json.loads(lb).get("entries", [])
|
||||
if entry.get("request_id") == static_request_id), None)
|
||||
if static_log:
|
||||
break
|
||||
time.sleep(.05)
|
||||
check("observability.static_complete_after_content_length", s == 200 and static_log is not None
|
||||
and static_log.get("outcome") == "complete" and static_log.get("response_bytes") == len(b),
|
||||
outcome=static_log.get("outcome") if static_log else None,
|
||||
recorded_response_bytes=static_log.get("response_bytes") if static_log else None,
|
||||
response_bytes=len(b))
|
||||
if static_log:
|
||||
write_json(rust.fixture.results / f"{rust.label}-sample-static-exchange.json", static_log)
|
||||
s, h, b = rust.admin("/api/status")
|
||||
status = json.loads(b)
|
||||
write_json(rust.fixture.results / f"{rust.label}-runtime-status.json", status)
|
||||
check("observability.status_available", s == 200, status=s)
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Proxy streaming, status, header, request-ID and WebSocket tests."""
|
||||
import copy
|
||||
import hashlib
|
||||
from http.client import HTTPConnection, HTTPResponse
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
|
||||
from common import http
|
||||
|
||||
|
||||
def run(check, rust, peer):
|
||||
port = rust.reverse_port
|
||||
for name in ["small.html", "large.bin", "huge.bin"]:
|
||||
expected = (rust.fixture.site / name).read_bytes()
|
||||
s, h, b = http(port, "/" + name)
|
||||
check("proxy.get." + name, s == 200 and b == expected, status=s, bytes=len(b))
|
||||
s, h, b = http(port, "/" + name, "HEAD")
|
||||
check("proxy.head." + name, s == 200 and b == b"" and h.get("content-length") == str(len(expected)), status=s)
|
||||
s, h, b = http(port, "/large.bin", headers={"Range": "bytes=0-127"})
|
||||
check("proxy.range", s == 206 and b == bytes(range(128)), status=s, bytes=len(b))
|
||||
s, h, b = http(port)
|
||||
s, h, b = http(port, headers={"If-None-Match": h["etag"]})
|
||||
check("proxy.conditional304", s == 304 and b == b"", status=s)
|
||||
for status in [201, 400, 404, 500, 503]:
|
||||
s, h, b = http(port, f"/status/{status}")
|
||||
check(f"proxy.origin_status.{status}", s == status and b == b"origin status payload\n"
|
||||
and h.get("x-origin-fixture") == "yes", status=s)
|
||||
s, h, b = http(port, "/redirect")
|
||||
check("proxy.redirect", s == 302 and h.get("location") == "/small.html", status=s)
|
||||
|
||||
payload = bytes(range(256)) * 4096
|
||||
ids = []
|
||||
for method in ["POST", "PUT"]:
|
||||
s, h, b = http(port, "/echo?query=preserved", method,
|
||||
{"Content-Type": "application/octet-stream", "X-Request-ID": "untrusted-client-id"}, payload)
|
||||
data = json.loads(b)
|
||||
request_id = data["headers"].get("x-request-id", "")
|
||||
ids.append(request_id)
|
||||
check("proxy.upload." + method, s == 200 and data["method"] == method
|
||||
and data["path"] == "/echo?query=preserved" and data["body_bytes"] == len(payload)
|
||||
and data["body_sha256"] == hashlib.sha256(payload).hexdigest(), status=s, bytes=data["body_bytes"])
|
||||
check("proxy.request_id." + method, bool(re.fullmatch("[0-9a-f]{64}", request_id)),
|
||||
valid_64_hex=bool(re.fullmatch("[0-9a-f]{64}", request_id)))
|
||||
check("proxy.request_id_unique", len(set(ids)) == 2)
|
||||
s, h, b = http(port, "/echo", "GET", {"Content-Type": "application/octet-stream"}, b"nonempty-get-body")
|
||||
data = json.loads(b)
|
||||
check("proxy.nonempty_get_body_preserved", s == 200 and data["body_bytes"] == 17
|
||||
and data["body_sha256"] == hashlib.sha256(b"nonempty-get-body").hexdigest(), status=s)
|
||||
|
||||
peer.started_upload.clear()
|
||||
first, last = b"first streamed chunk" * 64, b"final streamed chunk" * 64
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
|
||||
sock.sendall((f"POST /stream-upload HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
|
||||
"Transfer-Encoding: chunked\r\nContent-Type: application/octet-stream\r\n\r\n").encode())
|
||||
sock.sendall(f"{len(first):x}\r\n".encode() + first + b"\r\n")
|
||||
forwarded_before_end = peer.started_upload.wait(2)
|
||||
sock.sendall(f"{len(last):x}\r\n".encode() + last + b"\r\n0\r\n\r\n")
|
||||
response = HTTPResponse(sock)
|
||||
response.begin()
|
||||
data = json.loads(response.read())
|
||||
check("proxy.chunked_streaming_upload", forwarded_before_end and response.status == 200
|
||||
and data["body_sha256"] == hashlib.sha256(first + last).hexdigest(),
|
||||
first_chunk_arrived_before_end=forwarded_before_end, status=response.status)
|
||||
|
||||
peer.release_stream.clear()
|
||||
connection = HTTPConnection("127.0.0.1", port, timeout=3)
|
||||
connection.request("GET", "/stream-download")
|
||||
started = time.monotonic()
|
||||
response = connection.getresponse()
|
||||
first = response.read(5)
|
||||
first_seconds = time.monotonic() - started
|
||||
peer.release_stream.set()
|
||||
last = response.read()
|
||||
connection.close()
|
||||
check("proxy.streaming_response", response.status == 200 and first == b"first" and last == b"last"
|
||||
and first_seconds < 2, first_chunk_seconds=first_seconds)
|
||||
peer.release_stream.clear()
|
||||
connection = HTTPConnection("127.0.0.1", port, timeout=3)
|
||||
connection.request("GET", "/stream-download")
|
||||
response = connection.getresponse()
|
||||
response.read(5)
|
||||
connection.close()
|
||||
peer.release_stream.set()
|
||||
s, h, b = http(port)
|
||||
check("proxy.client_disconnect_survives", s == 200, status=s)
|
||||
|
||||
before = peer.count("POST", "/no-replay")
|
||||
s, h, b = http(port, "/no-replay", "POST", {"Content-Type": "application/octet-stream"}, b"write-once")
|
||||
after = peer.count("POST", "/no-replay")
|
||||
check("proxy.failed_post_not_replayed", s in (502, 503, 504) and after - before == 1,
|
||||
status=s, upstream_writes=after - before)
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["headers"] = {"request": [{"op": "set", "name": "X-Fixture-Global", "value": "global"}],
|
||||
"response": [{"op": "set", "name": "X-Fixture-Global", "value": "response"}]}
|
||||
config["vhosts"][0]["headers"] = {"request": [{"op": "set", "name": "X-Fixture-Vhost", "value": "vhost"}]}
|
||||
config["vhosts"][0]["routes"] = [{"id": "echo", "path_prefix": "/echo", "headers": {
|
||||
"request": [{"op": "set", "name": "X-Fixture-Route", "value": "route"}],
|
||||
"response": [{"op": "set", "name": "X-Fixture-Route", "value": "response"}]}}]
|
||||
rust.apply(config, "proxy-headers")
|
||||
s, h, b = http(port, "/echo", headers={"Connection": "keep-alive, x-hop-fixture", "X-Hop-Fixture": "must-be-stripped"})
|
||||
observed = json.loads(b)["headers"]
|
||||
check("proxy.header_scopes", s == 200 and observed.get("x-fixture-global") == "global"
|
||||
and observed.get("x-fixture-vhost") == "vhost" and observed.get("x-fixture-route") == "route"
|
||||
and h.get("x-fixture-global") == "response" and h.get("x-fixture-route") == "response", status=s)
|
||||
check("proxy.hop_by_hop_removed", "x-hop-fixture" not in observed)
|
||||
s, h, b = http(port, "/echo", headers={"X-Forwarded-For": "203.0.113.22", "X-Real-IP": "203.0.113.22"})
|
||||
observed = json.loads(b)["headers"]
|
||||
check("proxy.untrusted_forwarded_ip", observed.get("x-real-ip") == "127.0.0.1"
|
||||
and "203.0.113.22" not in observed.get("x-forwarded-for", ""),
|
||||
observed_x_real_ip=observed.get("x-real-ip"), observed_x_forwarded_for=observed.get("x-forwarded-for"))
|
||||
|
||||
rust.reset()
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=4) as sock:
|
||||
sock.sendall((f"GET /ws HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n").encode())
|
||||
buffer = b""
|
||||
while b"\r\n\r\n" not in buffer:
|
||||
piece = sock.recv(4096)
|
||||
if not piece:
|
||||
break
|
||||
buffer += piece
|
||||
upgraded = buffer.startswith(b"HTTP/1.1 101")
|
||||
echoed = b""
|
||||
if upgraded:
|
||||
message, mask = b"hello", b"\x11\x22\x33\x44"
|
||||
sock.sendall(bytes([0x81, 0x80 | len(message)]) + mask +
|
||||
bytes(c ^ mask[i % 4] for i, c in enumerate(message)))
|
||||
frame = sock.recv(2)
|
||||
if len(frame) == 2:
|
||||
echoed = sock.recv(frame[1] & 127)
|
||||
check("proxy.websocket_echo", upgraded and echoed == b"hello", upgraded=upgraded, bytes=len(echoed))
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Live static-file regression cases against RustProxy's network listener."""
|
||||
import copy
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
import gzip
|
||||
|
||||
from common import http
|
||||
|
||||
|
||||
def run(check, rust, peer):
|
||||
port, fixture = rust.static_port, rust.fixture
|
||||
for name in ["small.html", "large.bin", "huge.bin", "empty.txt"]:
|
||||
expected = (fixture.site / name).read_bytes()
|
||||
status, headers, body = http(port, "/" + name)
|
||||
check(f"static.get.{name}", status == 200 and body == expected,
|
||||
status=status, bytes=len(body))
|
||||
head_status, head_headers, head_body = http(port, "/" + name, "HEAD")
|
||||
check(f"static.head.{name}", head_status == 200 and head_body == b""
|
||||
and head_headers.get("content-length") == str(len(expected)),
|
||||
status=head_status, content_length=head_headers.get("content-length"))
|
||||
|
||||
status, headers, body = http(port)
|
||||
tag = headers.get("etag")
|
||||
modified = headers.get("last-modified")
|
||||
check("static.validators_present", bool(tag and modified), etag=tag, last_modified=modified)
|
||||
for label, condition in [("etag", {"If-None-Match": tag}),
|
||||
("modified", {"If-Modified-Since": modified})]:
|
||||
s, h, b = http(port, headers=condition)
|
||||
check("static.conditional304." + label, s == 304 and b == b"", status=s, bytes=len(b))
|
||||
s, h, b = http(port, headers={"If-None-Match": '"missing"'})
|
||||
check("static.conditional_miss", s == 200 and b == body, status=s)
|
||||
|
||||
data = (fixture.site / "large.bin").read_bytes()
|
||||
cases = [("prefix", "bytes=0-127", data[:128], "bytes 0-127/65536"),
|
||||
("suffix", "bytes=-37", data[-37:], "bytes 65499-65535/65536"),
|
||||
("open", "bytes=65500-", data[65500:], "bytes 65500-65535/65536")]
|
||||
for name, range_header, expected, content_range in cases:
|
||||
s, h, b = http(port, "/large.bin", headers={"Range": range_header})
|
||||
check("static.range." + name, s == 206 and b == expected
|
||||
and h.get("content-range") == content_range,
|
||||
status=s, bytes=len(b), content_range=h.get("content-range"))
|
||||
s, h, b = http(port, "/large.bin", headers={"Range": "bytes=999999-"})
|
||||
check("static.range.unsatisfiable", s == 416 and h.get("content-range") == "bytes */65536",
|
||||
status=s, content_range=h.get("content-range"))
|
||||
s, h, b = http(port, "/large.bin", headers={"Range": "bytes=0-15,100-115"})
|
||||
mime = BytesParser(policy=default).parsebytes(
|
||||
b"Content-Type: " + h.get("content-type", "").encode() + b"\r\n\r\n" + b)
|
||||
parts = list(mime.iter_parts())
|
||||
check("static.range.multipart", s == 206 and len(parts) == 2
|
||||
and parts[0].get_payload(decode=True) == data[:16]
|
||||
and parts[1].get_payload(decode=True) == data[100:116], status=s, parts=len(parts))
|
||||
for name, value, expected_status in [("match", tag, 206), ("mismatch", '"other"', 200)]:
|
||||
s, h, b = http(port, headers={"Range": "bytes=0-15", "If-Range": value})
|
||||
expected = body[:16] if expected_status == 206 else body
|
||||
check("static.if_range." + name, s == expected_status and b == expected,
|
||||
status=s, bytes=len(b))
|
||||
|
||||
s, h, b = http(port, "/directory")
|
||||
check("static.directory_slash", s in (301, 308) and h.get("location", "").endswith("/directory/"),
|
||||
status=s, location=h.get("location"))
|
||||
s, h, b = http(port, "/directory/")
|
||||
check("static.directory_index", s == 200 and b == b"directory index\n", status=s)
|
||||
s, h, b = http(port, "/listing/")
|
||||
check("static.directory_listing_denied", s == 403, status=s)
|
||||
for path in ["/escape-link.txt", "/inside-link.txt"]:
|
||||
s, h, b = http(port, path)
|
||||
check("static.symlink_deny." + path, s == 403 and b"MUST-NOT" not in b, status=s)
|
||||
for path in ["/%2e%2e/outside-secret.txt", "/%2fetc/passwd", "/%252e%252e/outside-secret.txt",
|
||||
"/%00", "/..%5coutside-secret.txt"]:
|
||||
s, h, b = http(port, path)
|
||||
check("static.invalid_path." + path, s in (400, 403, 404) and b"MUST-NOT" not in b,
|
||||
status=s)
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
service = config["web_services"][0]
|
||||
service["symlink_policy"] = "inside_root"
|
||||
service["directory_listing"] = True
|
||||
rust.apply(config, "static-inside-root")
|
||||
s, h, b = http(port, "/inside-link.txt")
|
||||
check("static.symlink_inside_root_allowed", s == 200 and b == b"download fixture\n", status=s)
|
||||
s, h, b = http(port, "/escape-link.txt")
|
||||
check("static.symlink_inside_root_escape_denied", s == 403 and b"MUST-NOT" not in b, status=s)
|
||||
s, h, b = http(port, "/listing/")
|
||||
check("static.directory_listing_enabled", s == 200 and b"visible.txt" in b, status=s)
|
||||
# Alternate a symlink target between valid and outside-root files. The file
|
||||
# read must stay confined even when a pathname changes between requests.
|
||||
link = fixture.site / "changing-link.txt"
|
||||
race_statuses = []
|
||||
escaped = False
|
||||
for index in range(12):
|
||||
if link.is_symlink():
|
||||
link.unlink()
|
||||
link.symlink_to(fixture.outside if index % 2 else fixture.site / "download.txt")
|
||||
s, h, b = http(port, "/changing-link.txt")
|
||||
race_statuses.append(s)
|
||||
escaped = escaped or b"MUST-NOT" in b
|
||||
check("static.changing_symlink_confinement", not escaped and all(s in (200, 403) for s in race_statuses),
|
||||
statuses=race_statuses, note="Sequential target replacement; not a concurrent rename-race proof")
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["web_services"][0]["precompressed"] = {"enabled": True, "gzip": True, "brotli": False}
|
||||
rust.apply(config, "static-precompressed")
|
||||
s, h, b = http(port, headers={"Accept-Encoding": "gzip"})
|
||||
sidecar = (fixture.site / "small.html.gz").read_bytes()
|
||||
check("static.precompressed_gzip", s == 200 and h.get("content-encoding") == "gzip"
|
||||
and b == sidecar and gzip.decompress(b) == body, status=s, bytes=len(b))
|
||||
s, h, b = http(port, headers={"Accept-Encoding": "gzip", "Range": "bytes=0-9"})
|
||||
check("static.precompressed_range", s == 206 and b == sidecar[:10], status=s, bytes=len(b))
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["web_services"][0]["try_files"] = ["$uri", "/small.html"]
|
||||
config["web_services"][0]["rewrites"] = [{"pattern": "^/old$", "replacement": "/small.html", "status": 302}]
|
||||
config["web_services"][0]["download_extensions"] = ["txt"]
|
||||
config["web_services"][0]["mime_types"] = {"bin": "application/x-fixture"}
|
||||
rust.apply(config, "static-routing")
|
||||
s, h, b = http(port, "/spa/deep/link")
|
||||
check("static.try_files_spa", s == 200 and b == body, status=s)
|
||||
s, h, b = http(port, "/old")
|
||||
check("static.rewrite_redirect", s == 302 and h.get("location") == "/small.html", status=s)
|
||||
s, h, b = http(port, "/download.txt")
|
||||
check("static.download_header", s == 200 and "attachment" in h.get("content-disposition", ""), status=s)
|
||||
s, h, b = http(port, "/large.bin")
|
||||
check("static.mime_override", s == 200 and h.get("content-type") == "application/x-fixture", status=s)
|
||||
|
||||
config = copy.deepcopy(rust.base)
|
||||
(fixture.site / ".htaccess").write_text("Header set X-Fixture-Htaccess yes\nDirectoryIndex small.html\n")
|
||||
config["web_services"][0]["htaccess"] = {"enabled": True, "strict": True}
|
||||
rust.apply(config, "static-htaccess")
|
||||
s, h, b = http(port)
|
||||
check("static.htaccess_header", s == 200 and h.get("x-fixture-htaccess") == "yes", status=s)
|
||||
s, h, b = http(port, "/.htaccess")
|
||||
check("static.htaccess_not_served", s in (403, 404) and b"Header set" not in b, status=s)
|
||||
@@ -1,448 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Disposable, loopback-only fixtures for testing actual RustProxy processes.
|
||||
|
||||
Runtime databases and credentials live in TemporaryDirectory, never in results.
|
||||
No pre-existing SQLite database, fixed port, or historical /mnt/data path is used.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import gzip
|
||||
import hashlib
|
||||
from http.client import HTTPConnection, HTTPException
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
result = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
result.update(block)
|
||||
return result.hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, value) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def seal_results(directory: Path) -> None:
|
||||
"""Checksums cover every regular evidence file, except the checksum manifest."""
|
||||
paths = sorted(p for p in directory.rglob("*") if p.is_file()
|
||||
and p.name != "SHA256SUMS")
|
||||
(directory / "SHA256SUMS").write_text("".join(
|
||||
f"{sha256(path)} {path.relative_to(directory)}\n" for path in paths))
|
||||
|
||||
|
||||
def http(port: int, path="/small.html", method="GET", headers=None, body=None,
|
||||
timeout=10, source_address=None):
|
||||
connection = HTTPConnection("127.0.0.1", port, timeout=timeout,
|
||||
source_address=source_address)
|
||||
try:
|
||||
connection.request(method, path, body, headers or {})
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
return response.status, {k.lower(): v for k, v in response.getheaders()}, payload
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def wait_ready(port: int, process: subprocess.Popen, timeout=30):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"process {process.pid} exited with {process.returncode}")
|
||||
try:
|
||||
http(port, "/", timeout=.5)
|
||||
return
|
||||
except (OSError, HTTPException):
|
||||
time.sleep(.05)
|
||||
raise RuntimeError(f"loopback listener {port} did not start")
|
||||
|
||||
|
||||
class Fixture:
|
||||
def __init__(self, results: Path, workers=4, temp_parent: Path | None = None,
|
||||
server_cpus=None, origin_cpus=None, origin_workers=None):
|
||||
self.results = results.resolve()
|
||||
self.results.mkdir(parents=True, exist_ok=True)
|
||||
# Keep live state outside the evidence tree. Even an interrupted run or
|
||||
# workspace synchronization must never put databases/keys in results.
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="rustproxy-perf-", dir=temp_parent)
|
||||
self.root = Path(self.temp.name)
|
||||
self.root.chmod(0o755) # nginx workers must traverse the public fixture root
|
||||
self.site = self.root / "site"
|
||||
self.site.mkdir(mode=0o755)
|
||||
self.workers = workers
|
||||
self.server_cpus = server_cpus
|
||||
self.origin_cpus = origin_cpus
|
||||
self.origin_workers = origin_workers or workers
|
||||
self.children = []
|
||||
self.handles = []
|
||||
self.ports = set()
|
||||
self.origin_port = self.port()
|
||||
self.make_site()
|
||||
|
||||
def port(self):
|
||||
while True:
|
||||
port = free_port()
|
||||
if port not in self.ports:
|
||||
self.ports.add(port)
|
||||
return port
|
||||
|
||||
def make_site(self):
|
||||
# Exact, reproducible payload sizes match the original report's cases.
|
||||
small = b"<!doctype html><html><body>RustProxy benchmark "
|
||||
small += b"x" * (1040 - len(small) - len(b"</body></html>"))
|
||||
small += b"</body></html>"
|
||||
files = {"small.html": small,
|
||||
"large.bin": bytes(range(256)) * 256,
|
||||
"huge.bin": bytes(range(256)) * 4096,
|
||||
"empty.txt": b"",
|
||||
"app.json": (b'{\n "message": "stable fixture",\n "values": [1, 2, 3]\n}\n'),
|
||||
"app.css": b"/* fixture comment */\nbody { color: red; padding: 0px; }\n",
|
||||
"compress.txt": (b"compressible RustProxy text fixture. " * 4096),
|
||||
"download.txt": b"download fixture\n"}
|
||||
for name, body in files.items():
|
||||
(self.site / name).write_bytes(body)
|
||||
(self.site / "small.html.gz").write_bytes(gzip.compress(small, mtime=0))
|
||||
(self.site / "directory").mkdir()
|
||||
(self.site / "directory" / "index.html").write_bytes(b"directory index\n")
|
||||
(self.site / "listing").mkdir()
|
||||
(self.site / "listing" / "visible.txt").write_bytes(b"listing fixture\n")
|
||||
(self.site / "inside-link.txt").symlink_to(self.site / "download.txt")
|
||||
self.outside = self.root / "outside-secret.txt"
|
||||
self.outside.write_bytes(b"MUST-NOT-BE-SERVED-OUTSIDE-ROOT\n")
|
||||
(self.site / "escape-link.txt").symlink_to(self.outside)
|
||||
write_json(self.results / "payloads.json", {
|
||||
name: {"bytes": len(body), "sha256": hashlib.sha256(body).hexdigest()}
|
||||
for name, body in files.items()})
|
||||
|
||||
def spawn(self, argv, name, env=None, affinity=None):
|
||||
log = (self.results / f"{name}.log").open("w")
|
||||
self.handles.append(log)
|
||||
if affinity:
|
||||
argv = ["taskset", "-c", affinity, *argv]
|
||||
child = subprocess.Popen([str(x) for x in argv], stdout=log,
|
||||
stderr=subprocess.STDOUT, env=env, start_new_session=True)
|
||||
self.children.append(child)
|
||||
return child
|
||||
|
||||
@staticmethod
|
||||
def stop(process):
|
||||
if process.poll() is None:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=12)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait(timeout=3)
|
||||
|
||||
def close(self):
|
||||
for process in reversed(self.children):
|
||||
self.stop(process)
|
||||
for handle in self.handles:
|
||||
handle.close()
|
||||
self.temp.cleanup()
|
||||
write_json(self.results / "fixture-cleanup.json", {
|
||||
"temporary_state_removed": not self.root.exists(),
|
||||
"state_was_outside_results": not self.root.is_relative_to(self.results)})
|
||||
if self.root.exists():
|
||||
raise RuntimeError("temporary runtime state was not removed; do not package the fixture directory")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.close()
|
||||
|
||||
def base_config(self, reverse_port, static_port):
|
||||
endpoints = []
|
||||
for label, port in [("reverse", reverse_port), ("static", static_port)]:
|
||||
endpoints.append({"id": label, "name": label, "scope": "internal",
|
||||
"enabled": True, "binding": {
|
||||
"address": f"127.0.0.1:{port}", "tls": False,
|
||||
"http2": False, "http3": None}})
|
||||
return {
|
||||
"cache": {"enabled": False},
|
||||
"geoip_updates": {"enabled": False},
|
||||
"listeners": {"endpoints": endpoints},
|
||||
"web_services": [{"id": "fixture-static", "name": "Fixture static",
|
||||
"document_root": str(self.site), "index": ["index.html", "small.html"],
|
||||
"directory_listing": False}],
|
||||
"vhosts": [{"id": "reverse", "frontend": {
|
||||
"hosts": ["127.0.0.1", "localhost", "fixture.test"],
|
||||
"listener_ids": ["reverse"]},
|
||||
"backends": [{"id": "origin", "url": f"http://127.0.0.1:{self.origin_port}",
|
||||
"allow_private": True, "preserve_host": False}],
|
||||
"load_balancing": {"health_check": {"mode": "off"}}, "routes": []},
|
||||
{"id": "static", "frontend": {
|
||||
"hosts": ["127.0.0.1", "localhost", "fixture.test"],
|
||||
"listener_ids": ["static"]},
|
||||
"source": {"type": "web_server", "web_service_id": "fixture-static"},
|
||||
"routes": []}]
|
||||
}
|
||||
|
||||
def rust(self, binary: Path, label: str, max_idle=None):
|
||||
return RustInstance(self, binary.resolve(), label, max_idle=max_idle)
|
||||
|
||||
def nginx(self, executable: Path, frontend=True):
|
||||
"""Use a separate nginx master for the shared origin and comparator."""
|
||||
executable = executable.resolve()
|
||||
result = subprocess.run([str(executable), "-V"], capture_output=True, text=True, timeout=10)
|
||||
(self.results / "nginx-version.txt").write_text(result.stdout + result.stderr)
|
||||
origin_conf = self.nginx_config("nginx-origin", f"""
|
||||
server {{ listen 127.0.0.1:{self.origin_port}; server_name _;
|
||||
root "{self.site}"; location / {{ add_header Cache-Control "public, max-age=60"; }}
|
||||
}}
|
||||
""", workers=self.origin_workers)
|
||||
origin = self.spawn([executable, "-p", self.root, "-c", origin_conf], "nginx-origin",
|
||||
affinity=self.origin_cpus)
|
||||
wait_ready(self.origin_port, origin)
|
||||
result = {"origin": origin, "origin_port": self.origin_port}
|
||||
if frontend:
|
||||
reverse_port, static_port = self.port(), self.port()
|
||||
conf = self.nginx_config("nginx-frontend", f"""
|
||||
upstream fixture_origin {{ server 127.0.0.1:{self.origin_port}; keepalive 64; }}
|
||||
server {{ listen 127.0.0.1:{reverse_port}; server_name _;
|
||||
location / {{ proxy_pass http://fixture_origin; proxy_http_version 1.1;
|
||||
proxy_set_header Connection ""; proxy_buffering off; }}
|
||||
}}
|
||||
server {{ listen 127.0.0.1:{static_port}; server_name _; root "{self.site}"; }}
|
||||
""")
|
||||
process = self.spawn([executable, "-p", self.root, "-c", conf], "nginx-frontend",
|
||||
affinity=self.server_cpus)
|
||||
wait_ready(reverse_port, process)
|
||||
wait_ready(static_port, process)
|
||||
result.update({"process": process, "reverse_port": reverse_port,
|
||||
"static_port": static_port})
|
||||
return result
|
||||
|
||||
def nginx_config(self, name, servers, workers=None):
|
||||
path = self.root / f"{name}.conf"
|
||||
text = f"""daemon off;
|
||||
master_process on;
|
||||
user {pwd.getpwuid(os.geteuid()).pw_name};
|
||||
worker_processes {workers or self.workers};
|
||||
pid {self.root}/{name}.pid;
|
||||
error_log stderr warn;
|
||||
events {{ worker_connections 8192; }}
|
||||
http {{
|
||||
access_log off;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
keepalive_requests 1000000;
|
||||
default_type application/octet-stream;
|
||||
types {{ text/html html; text/css css; application/json json; text/plain txt; }}
|
||||
client_body_temp_path {self.root}/{name}-body;
|
||||
proxy_temp_path {self.root}/{name}-proxy;
|
||||
{servers}
|
||||
}}
|
||||
"""
|
||||
path.write_text(text)
|
||||
(self.results / path.name).write_text(text)
|
||||
return path
|
||||
|
||||
|
||||
class RustInstance:
|
||||
def __init__(self, fixture, binary, label, max_idle=None):
|
||||
self.fixture, self.binary, self.label = fixture, binary, label
|
||||
if not os.access(binary, os.X_OK):
|
||||
target = fixture.root / f"{label}-rust-proxy"
|
||||
shutil.copyfile(binary, target)
|
||||
target.chmod(0o700)
|
||||
self.binary = target
|
||||
self.admin_port, self.reverse_port, self.static_port = (fixture.port() for _ in range(3))
|
||||
self.data = fixture.root / f"{label}-data"
|
||||
self.base = fixture.base_config(self.reverse_port, self.static_port)
|
||||
if max_idle is not None:
|
||||
self.base["vhosts"][0]["load_balancing"]["keepalive"] = {"max_idle_per_host": max_idle}
|
||||
self.password = "Fixture!" + secrets.token_hex(20)
|
||||
self.username = "performance"
|
||||
self.env = os.environ.copy()
|
||||
self.env["TOKIO_WORKER_THREADS"] = str(fixture.workers)
|
||||
self.env["RUST_PROXY_PROXY_LISTEN"] = f"127.0.0.1:{self.reverse_port}"
|
||||
self.env["RUST_PROXY_UPDATE_CHECK_INTERVAL_SECS"] = "0"
|
||||
self.env.pop("RUST_PROXY_TLS_LISTEN", None)
|
||||
self.applies = []
|
||||
self.cli("admin", "init", "--username", self.username, "--password-stdin",
|
||||
input=self.password + "\n")
|
||||
self.start("bootstrap")
|
||||
self.apply(self.base, "base")
|
||||
fixture.stop(self.process)
|
||||
self.start("runtime")
|
||||
wait_ready(self.static_port, self.process)
|
||||
version = self.cli("--version").stdout.strip()
|
||||
write_json(fixture.results / f"{label}-binary.json", {
|
||||
"label": label, "path": str(binary), "sha256": sha256(binary),
|
||||
"version": version, "worker_threads": fixture.workers,
|
||||
"pid": self.process.pid, "protocol": "HTTP/1.1 plaintext loopback",
|
||||
"cpu_affinity": fixture.server_cpus,
|
||||
"max_idle_per_host_override": max_idle})
|
||||
|
||||
def cli(self, *args, input=None, check=True):
|
||||
result = subprocess.run([str(self.binary), "--data-dir", str(self.data),
|
||||
"--admin-listen", f"127.0.0.1:{self.admin_port}", *args],
|
||||
input=input, text=True, capture_output=True,
|
||||
env=self.env, timeout=40)
|
||||
if check and result.returncode:
|
||||
raise RuntimeError(f"{self.label} CLI {' '.join(args[:2])}: "
|
||||
f"{result.stdout[-2000:]} {result.stderr[-3000:]}")
|
||||
return result
|
||||
|
||||
def start(self, phase):
|
||||
self.process = self.fixture.spawn([
|
||||
self.binary, "--data-dir", self.data, "--admin-listen", f"127.0.0.1:{self.admin_port}",
|
||||
"--update-check-interval-secs", "0", "serve", "--proxy-listen",
|
||||
f"127.0.0.1:{self.reverse_port}", "--max-connections", "4096", "--max-inflight", "4096"],
|
||||
f"{self.label}-{phase}", env=self.env, affinity=self.fixture.server_cpus)
|
||||
wait_ready(self.admin_port, self.process)
|
||||
wait_ready(self.reverse_port, self.process)
|
||||
|
||||
def apply(self, config, name, expected_success=True):
|
||||
config_path = self.fixture.root / f"{self.label}-{name}.json"
|
||||
write_json(config_path, config)
|
||||
result = self.cli("config", "apply", str(config_path), check=False)
|
||||
# Configurations with ephemeral test passwords/hashes are intentionally not copied.
|
||||
safe = copy.deepcopy(config)
|
||||
for vhost in safe.get("vhosts", []):
|
||||
if "basic_auth" in vhost:
|
||||
vhost["basic_auth"]["users"] = "REDACTED fixture users"
|
||||
write_json(self.fixture.results / "configs" / f"{self.label}-{name}.json", safe)
|
||||
event = {"name": name, "sha256": sha256(config_path), "returncode": result.returncode,
|
||||
"expected_success": expected_success, "stdout": result.stdout[-2000:],
|
||||
"stderr": result.stderr[-3000:]}
|
||||
self.applies.append(event)
|
||||
write_json(self.fixture.results / f"{self.label}-config-applies.json", self.applies)
|
||||
if (result.returncode == 0) != expected_success:
|
||||
raise RuntimeError(f"config {name}: {result.stdout} {result.stderr}")
|
||||
return result
|
||||
|
||||
def reset(self):
|
||||
return self.apply(self.base, "restored-base")
|
||||
|
||||
def login(self):
|
||||
status, headers, body = http(self.admin_port, "/api/auth/login", "POST",
|
||||
{"Origin": f"http://127.0.0.1:{self.admin_port}", "Content-Type": "application/json",
|
||||
"X-RustProxy-Request": "1"},
|
||||
json.dumps({"username": self.username, "password": self.password}))
|
||||
if status != 200:
|
||||
raise RuntimeError(f"fixture login failed: HTTP {status}: {body[:200]!r}")
|
||||
self.cookie = headers["set-cookie"].split(";", 1)[0]
|
||||
self.csrf = json.loads(body)["csrf_token"]
|
||||
return status
|
||||
|
||||
def wait_quiescent(self, timeout=180, quiet_seconds=5, poll_seconds=.5):
|
||||
"""Wait for the real, one-time ACME bootstrap before any timed work.
|
||||
|
||||
This does not disable ACME or change the process environment. The
|
||||
private fixture has no issuers/jobs; a terminal unavailable state is
|
||||
valid for HTTP tests, but does not establish ACME issuance support.
|
||||
"""
|
||||
from resources import descendants, process_stats
|
||||
|
||||
if timeout <= 0 or quiet_seconds <= 0 or poll_seconds <= 0 or timeout <= quiet_seconds:
|
||||
raise ValueError("quiescence timeout must exceed the positive quiet period")
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
output = self.fixture.results / f"{self.label}-quiescence.json"
|
||||
report = {"completed": False, "label": self.label, "pid": self.process.pid,
|
||||
"timeout_seconds": timeout, "required_quiet_seconds": quiet_seconds,
|
||||
"poll_seconds": poll_seconds, "observations": [],
|
||||
"acme_issuance_tested": False}
|
||||
|
||||
def read_admin(path):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(f"{self.label} did not become quiescent within {timeout}s")
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError(f"{self.label} exited during quiescence")
|
||||
status, _, body = self.admin(path, timeout=min(1, remaining))
|
||||
if status != 200:
|
||||
raise RuntimeError(f"{self.label} quiescence {path}: HTTP {status}")
|
||||
return json.loads(body)
|
||||
|
||||
try:
|
||||
root = process_stats(self.process.pid)
|
||||
if root is None:
|
||||
raise RuntimeError(f"{self.label} has no live root process")
|
||||
identity = (root["pid"], root["start_ticks"])
|
||||
report["start_ticks"] = root["start_ticks"]
|
||||
envelope = read_admin("/api/config")
|
||||
if not isinstance(envelope, dict) or not isinstance(envelope.get("config"), dict):
|
||||
raise RuntimeError("quiescence requires the actual configuration envelope")
|
||||
certificates = envelope["config"].get("tls", {}).get("certificates", [])
|
||||
if not isinstance(certificates, list) or any(not isinstance(cert, dict) for cert in certificates):
|
||||
raise RuntimeError("unexpected certificate configuration during quiescence")
|
||||
report["configured_issuers"] = sum(cert.get("issuer") is not None for cert in certificates)
|
||||
if report["configured_issuers"]:
|
||||
raise RuntimeError("performance fixture must have no configured ACME issuers")
|
||||
quiet_started, previous_terminal = None, None
|
||||
while True:
|
||||
payload = read_admin("/api/acme")
|
||||
if not isinstance(payload, dict) or not {"runtime", "job", "renewals"} <= payload.keys():
|
||||
raise RuntimeError("quiescence requires explicit ACME runtime/job/renewal state")
|
||||
if payload["job"] is not None or payload["renewals"] != {}:
|
||||
raise RuntimeError("performance fixture has an unexpected ACME job or renewal")
|
||||
runtime = payload["runtime"]
|
||||
if not isinstance(runtime, dict) or runtime.get("status") not in {
|
||||
"checking", "initializing", "ready", "unavailable"}:
|
||||
raise RuntimeError("unexpected ACME runtime state during quiescence")
|
||||
pids = descendants(self.process.pid)
|
||||
root = process_stats(self.process.pid)
|
||||
if root is None or (root["pid"], root["start_ticks"]) != identity:
|
||||
raise RuntimeError(f"{self.label} root process identity changed during quiescence")
|
||||
now = time.monotonic()
|
||||
if now >= deadline:
|
||||
raise TimeoutError(f"{self.label} did not become quiescent within {timeout}s")
|
||||
terminal = tuple(runtime.get(key) for key in ("status", "mode", "message"))
|
||||
quiet = runtime["status"] in {"ready", "unavailable"} and pids == [self.process.pid]
|
||||
if not quiet:
|
||||
quiet_started, previous_terminal = None, None
|
||||
elif quiet_started is None or terminal != previous_terminal:
|
||||
quiet_started, previous_terminal = now, terminal
|
||||
elapsed_quiet = now - quiet_started if quiet_started is not None else 0
|
||||
report["observations"].append({
|
||||
"elapsed_seconds": now - started, "process_count": len(pids),
|
||||
"acme_status": runtime["status"], "acme_mode": runtime.get("mode"),
|
||||
"acme_message": runtime.get("message"), "quiet_seconds": elapsed_quiet})
|
||||
if elapsed_quiet >= quiet_seconds:
|
||||
report.update({"completed": True, "elapsed_seconds": now - started,
|
||||
"terminal_status": runtime["status"], "process_count": len(pids)})
|
||||
write_json(output, report)
|
||||
return report
|
||||
write_json(output, report)
|
||||
time.sleep(min(poll_seconds, max(0, deadline - time.monotonic())))
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
report.update({"elapsed_seconds": time.monotonic() - started,
|
||||
"error_type": type(error).__name__, "error": str(error)})
|
||||
write_json(output, report)
|
||||
raise
|
||||
|
||||
def admin(self, path, method="GET", body=None, csrf=True, cookie=True, origin=None,
|
||||
timeout=10):
|
||||
headers = {"Origin": origin or f"http://127.0.0.1:{self.admin_port}", "X-RustProxy-Request": "1"}
|
||||
if cookie:
|
||||
headers["Cookie"] = self.cookie
|
||||
if csrf:
|
||||
headers["X-CSRF-Token"] = self.csrf
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
body = json.dumps(body)
|
||||
return http(self.admin_port, path, method, headers, body, timeout=timeout)
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Finite live HTTP concurrency correctness stress; no throughput/latency claim."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from common import Fixture, http, seal_results, sha256, write_json
|
||||
from resources import process_stats
|
||||
|
||||
|
||||
PAYLOADS = ["small.html", "large.bin", "huge.bin"]
|
||||
|
||||
|
||||
def active_backends(rust):
|
||||
status, _, body = rust.admin("/api/status")
|
||||
if status != 200:
|
||||
return {"error": f"HTTP {status}"}
|
||||
values = []
|
||||
|
||||
def walk(value):
|
||||
if isinstance(value, dict):
|
||||
if "id" in value and "active" in value and "status" in value and "requests" in value:
|
||||
values.append({key: value[key] for key in ("id", "active", "status", "requests")})
|
||||
for child in value.values():
|
||||
walk(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
walk(child)
|
||||
walk(json.loads(body))
|
||||
return values
|
||||
|
||||
|
||||
def batch(rust, port, name, expected, concurrency):
|
||||
count = max(32, concurrency * 2)
|
||||
expected_sha = hashlib.sha256(expected).hexdigest()
|
||||
ready = threading.Barrier(concurrency, timeout=15)
|
||||
lock = threading.Lock()
|
||||
running = peak = 0
|
||||
|
||||
def fetch(index):
|
||||
nonlocal running, peak
|
||||
with lock:
|
||||
running += 1
|
||||
peak = max(peak, running)
|
||||
try:
|
||||
if index < concurrency:
|
||||
ready.wait()
|
||||
status, headers, body = http(port, "/" + name, timeout=15)
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
return {"index": index, "status": status, "bytes": len(body), "sha256": digest,
|
||||
"passed": status == 200 and len(body) == len(expected) and digest == expected_sha}
|
||||
except Exception as error:
|
||||
return {"index": index, "passed": False, "error": str(error)}
|
||||
finally:
|
||||
with lock:
|
||||
running -= 1
|
||||
|
||||
before = process_stats(rust.process.pid)
|
||||
started = time.monotonic()
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
||||
responses = list(executor.map(fetch, range(count)))
|
||||
elapsed = time.monotonic() - started
|
||||
# Completion means every client consumed or failed its full response. Give
|
||||
# asynchronous server bookkeeping a bounded interval to release its leases.
|
||||
for _ in range(40):
|
||||
active = active_backends(rust)
|
||||
if isinstance(active, list) and active and all(row["active"] == 0 for row in active):
|
||||
break
|
||||
time.sleep(.05)
|
||||
after = process_stats(rust.process.pid)
|
||||
return {"configured_concurrent_clients": concurrency, "peak_running_client_tasks": peak,
|
||||
"client_tasks_include_barrier": True, "requests": count, "elapsed_seconds": elapsed,
|
||||
"expected_bytes": len(expected), "expected_sha256": expected_sha,
|
||||
"response_failures": sum(not response["passed"] for response in responses),
|
||||
"passed": all(response["passed"] for response in responses)
|
||||
and isinstance(active, list) and bool(active) and all(row["active"] == 0 for row in active),
|
||||
"responses": responses, "active_backends_after_drain": active,
|
||||
"server_before": before, "server_after_drain": after}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
|
||||
parser.add_argument("--nginx", type=Path, required=True)
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if args.out.exists() and any(args.out.iterdir()):
|
||||
parser.error("--out must be new or empty")
|
||||
reports = {}
|
||||
for specification in args.binary:
|
||||
label, binary = specification.split("=", 1)
|
||||
cases, error = [], None
|
||||
try:
|
||||
with Fixture(args.out / label, workers=4, origin_workers=2) as fixture:
|
||||
fixture.nginx(args.nginx, frontend=False)
|
||||
rust = fixture.rust(Path(binary), label)
|
||||
rust.login()
|
||||
for source, port in [("proxy", rust.reverse_port), ("static", rust.static_port)]:
|
||||
for name in PAYLOADS:
|
||||
expected = (fixture.site / name).read_bytes()
|
||||
for concurrency in [1, 16, 128]:
|
||||
result = batch(rust, port, name, expected, concurrency)
|
||||
case = {"case": f"concurrency.{source}.{name}.c{concurrency}", **result}
|
||||
cases.append(case)
|
||||
write_json(args.out / label / "cases" / f"{source}-{name}-c{concurrency}.json", case)
|
||||
print(f"{label} {'PASS' if case['passed'] else 'FAIL'} {case['case']}", flush=True)
|
||||
if rust.process.poll() is not None:
|
||||
raise RuntimeError("RustProxy exited during concurrency regression")
|
||||
except Exception as exc:
|
||||
error = {"error": str(exc), "traceback": traceback.format_exc()}
|
||||
write_json(args.out / label / "failure.json", error)
|
||||
report = {"kind": "live HTTP/1.1 finite concurrency functional stress", "simulation": False,
|
||||
"binary_sha256": sha256(Path(binary)), "throughput_measurement": False,
|
||||
"passed_cases": sum(case["passed"] for case in cases),
|
||||
"failed_cases": sum(not case["passed"] for case in cases),
|
||||
"requests": sum(case["requests"] for case in cases),
|
||||
"response_failures": sum(case["response_failures"] for case in cases),
|
||||
"completed": error is None and len(cases) == 18,
|
||||
"cases": [{key: case[key] for key in ("case", "passed", "requests", "response_failures")} for case in cases],
|
||||
"limitations": ["configured client concurrency is not proof of simultaneous active server exchanges",
|
||||
"Python client is not a throughput or latency benchmark",
|
||||
"FD/RSS are before/after snapshots, not peaks or a long-term leak test"]}
|
||||
reports[label] = report
|
||||
write_json(args.out / label / "report.json", report)
|
||||
write_json(args.out / "comparison.json", reports)
|
||||
seal_results(args.out)
|
||||
return 0 if all(report["completed"] and not report["failed_cases"] for report in reports.values()) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small live two-origin failover and active-exchange regression, not a benchmark."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import copy
|
||||
import hashlib
|
||||
from http.client import HTTPConnection
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from common import Fixture, http, seal_results, write_json
|
||||
|
||||
|
||||
class Origin:
|
||||
def __init__(self, port, name):
|
||||
self.name, self.port = name, port
|
||||
self.fail_get = False
|
||||
self.counts, self.post_hashes = Counter(), []
|
||||
self.release, self.lock = threading.Event(), threading.Lock()
|
||||
origin = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.respond()
|
||||
|
||||
def do_POST(self):
|
||||
self.respond()
|
||||
|
||||
def respond(self):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
||||
with origin.lock:
|
||||
origin.counts[(self.command, self.path)] += 1
|
||||
if self.command == "POST":
|
||||
origin.post_hashes.append(hashlib.sha256(body).hexdigest())
|
||||
hold = self.path == "/hold"
|
||||
status = 503 if self.command == "POST" or origin.fail_get else 200
|
||||
payload = b"firstlast" if hold else origin.name.encode()
|
||||
try:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
# Fresh connections make refused-listener tests independent
|
||||
# of already accepted upstream keepalive connections.
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
if hold:
|
||||
self.wfile.write(payload[:5])
|
||||
self.wfile.flush()
|
||||
origin.release.wait(15)
|
||||
self.wfile.write(payload[5:])
|
||||
else:
|
||||
self.wfile.write(payload)
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
self.close_connection = True
|
||||
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.release.set()
|
||||
if not self.closed:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=3)
|
||||
self.closed = True
|
||||
|
||||
|
||||
def backend_views(rust):
|
||||
status, _, body = rust.admin("/api/status")
|
||||
if status != 200:
|
||||
raise RuntimeError(f"status unavailable: {status}")
|
||||
found = {}
|
||||
|
||||
def walk(value):
|
||||
if isinstance(value, dict):
|
||||
if value.get("id") in ("a", "b") and "active" in value and "status" in value:
|
||||
found[value["id"]] = {key: value[key] for key in value
|
||||
if key in ("id", "status", "active", "requests", "errors", "bytes_out")
|
||||
or key.startswith("retry_") or key.startswith("connect_errors")}
|
||||
for child in value.values():
|
||||
walk(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
walk(child)
|
||||
walk(json.loads(body))
|
||||
return found
|
||||
|
||||
|
||||
def run(check, fixture, binary, label):
|
||||
a, b = Origin(fixture.origin_port, "a"), Origin(fixture.port(), "b")
|
||||
try:
|
||||
rust = fixture.rust(binary, label)
|
||||
rust.login()
|
||||
config = copy.deepcopy(rust.base)
|
||||
vhost = config["vhosts"][0]
|
||||
vhost["backends"] = [{"id": origin.name, "url": f"http://127.0.0.1:{origin.port}",
|
||||
"allow_private": True, "preserve_host": False} for origin in [a, b]]
|
||||
vhost["load_balancing"] = {"algorithm": "round_robin", "health_check": {"mode": "off"},
|
||||
"slow_start_seconds": 0, "passive_health": {"enabled": False},
|
||||
"retry": {"max_retries": 1, "retry_statuses": [503]}}
|
||||
vhost["routes"] = [{"id": "hold-a", "path_prefix": "/hold", "backend_id": "a"}]
|
||||
rust.apply(config, "lb-two-origins")
|
||||
replies = [http(rust.reverse_port, "/rr") for _ in range(8)]
|
||||
counts = Counter(body.decode() for status, _, body in replies if status == 200)
|
||||
check("lb.round_robin_both_origins", counts == {"a": 4, "b": 4}, counts=dict(counts))
|
||||
|
||||
a.fail_get = True
|
||||
replies = [http(rust.reverse_port, "/retry-status") for _ in range(6)]
|
||||
check("lb.get_503_failover", all(status == 200 and body == b"b" for status, _, body in replies)
|
||||
and a.counts[("GET", "/retry-status")] > 0,
|
||||
failed_origin_attempts=a.counts[("GET", "/retry-status")])
|
||||
views = backend_views(rust)
|
||||
check("lb.retry_observations_recorded", sum(view.get("retry_attempts_total", 0) for view in views.values()) > 0,
|
||||
backends=views)
|
||||
a.fail_get = False
|
||||
payload = b"one POST must be attempted once"
|
||||
replies = [http(rust.reverse_port, "/post-no-replay", "POST", body=payload) for _ in range(4)]
|
||||
attempts = sum(origin.counts[("POST", "/post-no-replay")] for origin in [a, b])
|
||||
check("lb.post_503_never_replayed", all(status == 503 for status, _, _ in replies) and attempts == 4,
|
||||
client_requests=4, origin_attempts=attempts)
|
||||
check("lb.post_body_preserved", len(a.post_hashes + b.post_hashes) == 4 and
|
||||
all(digest == hashlib.sha256(payload).hexdigest() for digest in a.post_hashes + b.post_hashes))
|
||||
|
||||
for mode in ["drain", "disable"]:
|
||||
vhost["backends"][0].update({"enabled": True, "drain": False})
|
||||
rust.apply(config, f"lb-{mode}-before")
|
||||
a.release.clear()
|
||||
connection = HTTPConnection("127.0.0.1", rust.reverse_port, timeout=10)
|
||||
try:
|
||||
connection.request("GET", "/hold")
|
||||
response = connection.getresponse()
|
||||
first = response.read(5)
|
||||
before = backend_views(rust)
|
||||
check(f"lb.{mode}.lease_spans_response_body", response.status == 200 and first == b"first"
|
||||
and before.get("a", {}).get("active") == 1, backends=before)
|
||||
vhost["backends"][0].update({"drain": mode == "drain", "enabled": mode != "disable"})
|
||||
rust.apply(config, f"lb-{mode}-active")
|
||||
replies = [http(rust.reverse_port, "/new") for _ in range(4)]
|
||||
check(f"lb.{mode}.new_requests_use_healthy_member",
|
||||
all(status == 200 and body == b"b" for status, _, body in replies))
|
||||
during = backend_views(rust)
|
||||
check(f"lb.{mode}.active_lease_survives_hot_edit", during.get("a", {}).get("active") == 1,
|
||||
backends=during)
|
||||
a.release.set()
|
||||
last = response.read()
|
||||
check(f"lb.{mode}.admitted_body_finishes", first + last == b"firstlast")
|
||||
for _ in range(20):
|
||||
after = backend_views(rust)
|
||||
if after.get("a", {}).get("active") == 0:
|
||||
break
|
||||
time.sleep(.05)
|
||||
check(f"lb.{mode}.lease_released_after_body", after.get("a", {}).get("active") == 0,
|
||||
backends=after)
|
||||
finally:
|
||||
a.release.set()
|
||||
connection.close()
|
||||
|
||||
vhost["backends"][0].update({"enabled": True, "drain": False})
|
||||
rust.apply(config, "lb-connect-refused")
|
||||
a.close()
|
||||
replies = [http(rust.reverse_port, "/retry-connect") for _ in range(6)]
|
||||
views = backend_views(rust)
|
||||
check("lb.connect_refused_failover", all(status == 200 and body == b"b" for status, _, body in replies)
|
||||
and views.get("a", {}).get("connect_errors_total", 0) > 0, backends=views)
|
||||
rust.reset()
|
||||
check("lb.static_vhost_survives_reload", http(rust.static_port)[0] == 200)
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if args.out.exists() and any(args.out.iterdir()):
|
||||
parser.error("--out must be new or empty")
|
||||
reports = {}
|
||||
for specification in args.binary:
|
||||
label, binary = specification.split("=", 1)
|
||||
cases = []
|
||||
|
||||
def check(case, passed, **details):
|
||||
cases.append({"case": case, "passed": passed, **details})
|
||||
print(f"{label} {'PASS' if passed else 'FAIL'} {case}", flush=True)
|
||||
write_json(args.out / label / "checks.json", cases)
|
||||
|
||||
try:
|
||||
with Fixture(args.out / label) as fixture:
|
||||
run(check, fixture, Path(binary), label)
|
||||
except Exception as exc:
|
||||
check("lb.runner", False, error=str(exc), traceback=traceback.format_exc())
|
||||
report = {"passed": sum(case["passed"] for case in cases),
|
||||
"failed": sum(not case["passed"] for case in cases), "cases": cases,
|
||||
"simulation": False, "kind": "live RustProxy two-origin HTTP/1.1 regression",
|
||||
"not_covered": ["sticky affinity", "active health probes", "long-running connection soak",
|
||||
"TLS origin failures", "DNS rotation"]}
|
||||
reports[label] = report
|
||||
write_json(args.out / label / "report.json", report)
|
||||
write_json(args.out / "comparison.json", reports)
|
||||
seal_results(args.out)
|
||||
return 1 if any(report["failed"] for report in reports.values()) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A protocol peer, not a RustProxy emulator; used only as the test upstream.
|
||||
|
||||
Performance comparisons use nginx as the upstream, never this Python fixture.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlsplit, parse_qs
|
||||
|
||||
|
||||
class Peer:
|
||||
def __init__(self, port, site):
|
||||
self.site = site
|
||||
self.counts = collections.Counter()
|
||||
self.received = {}
|
||||
self.lock = threading.Lock()
|
||||
self.started_upload = threading.Event()
|
||||
self.release_stream = threading.Event()
|
||||
peer = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def emit(self, status, body=b"", headers=None):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
if "Content-Type" not in (headers or {}):
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
for key, value in (headers or {}).items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
if self.command != "HEAD" and body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def upload(self):
|
||||
chunks = []
|
||||
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
|
||||
while True:
|
||||
length = int(self.rfile.readline().split(b";", 1)[0], 16)
|
||||
if length == 0:
|
||||
while self.rfile.readline().strip():
|
||||
pass
|
||||
break
|
||||
payload = self.rfile.read(length)
|
||||
self.rfile.read(2)
|
||||
chunks.append(payload)
|
||||
peer.started_upload.set()
|
||||
else:
|
||||
remaining = int(self.headers.get("Content-Length", 0))
|
||||
while remaining:
|
||||
payload = self.rfile.read(min(remaining, 1024))
|
||||
if not payload:
|
||||
break
|
||||
chunks.append(payload)
|
||||
remaining -= len(payload)
|
||||
peer.started_upload.set()
|
||||
return b"".join(chunks)
|
||||
|
||||
def do_POST(self):
|
||||
self.handle_method()
|
||||
|
||||
def do_PUT(self):
|
||||
self.handle_method()
|
||||
|
||||
def do_HEAD(self):
|
||||
self.handle_method()
|
||||
|
||||
def do_GET(self):
|
||||
self.handle_method()
|
||||
|
||||
def handle_method(self):
|
||||
try:
|
||||
self.route()
|
||||
except (BrokenPipeError, ConnectionResetError, socket.timeout):
|
||||
self.close_connection = True
|
||||
|
||||
def route(self):
|
||||
parsed = urlsplit(self.path)
|
||||
path, query = parsed.path, parse_qs(parsed.query)
|
||||
with peer.lock:
|
||||
peer.counts[(self.command, path)] += 1
|
||||
count = peer.counts[(self.command, path)]
|
||||
body = self.upload() if self.command in ("POST", "PUT") or self.headers.get("Content-Length") or self.headers.get("Transfer-Encoding") else b""
|
||||
if path == "/no-replay":
|
||||
self.close_connection = True
|
||||
self.connection.shutdown(socket.SHUT_RDWR)
|
||||
self.connection.close()
|
||||
return
|
||||
if path in ("/echo", "/stream-upload"):
|
||||
data = {"method": self.command, "path": self.path,
|
||||
"headers": {key.lower(): value for key, value in self.headers.items()},
|
||||
"body_bytes": len(body), "body_sha256": hashlib.sha256(body).hexdigest()}
|
||||
with peer.lock:
|
||||
peer.received[path] = data
|
||||
self.emit(200, json.dumps(data).encode(), {"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store", "X-Origin-Fixture": "yes"})
|
||||
return
|
||||
if path == "/stream-download":
|
||||
self.send_response(200)
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"5\r\nfirst\r\n")
|
||||
self.wfile.flush()
|
||||
peer.release_stream.wait(5)
|
||||
self.wfile.write(b"4\r\nlast\r\n0\r\n\r\n")
|
||||
self.wfile.flush()
|
||||
return
|
||||
if path == "/ws":
|
||||
key = self.headers.get("Sec-WebSocket-Key", "")
|
||||
accept = base64.b64encode(hashlib.sha1(
|
||||
(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
|
||||
self.send_response(101)
|
||||
self.send_header("Upgrade", "websocket")
|
||||
self.send_header("Connection", "Upgrade")
|
||||
self.send_header("Sec-WebSocket-Accept", accept)
|
||||
self.end_headers()
|
||||
self.wfile.flush()
|
||||
frame = self.rfile.read(2)
|
||||
if len(frame) == 2:
|
||||
length = frame[1] & 127
|
||||
if length == 126:
|
||||
length = struct.unpack("!H", self.rfile.read(2))[0]
|
||||
mask = self.rfile.read(4) if frame[1] & 128 else b"\0\0\0\0"
|
||||
data = self.rfile.read(length)
|
||||
data = bytes(byte ^ mask[i % 4] for i, byte in enumerate(data))
|
||||
self.wfile.write(bytes([0x81, len(data)]) + data)
|
||||
self.wfile.flush()
|
||||
self.close_connection = True
|
||||
return
|
||||
if path.startswith("/status/"):
|
||||
self.emit(int(path.rsplit("/", 1)[1]), b"origin status payload\n",
|
||||
{"Cache-Control": "no-store", "X-Origin-Fixture": "yes"})
|
||||
return
|
||||
if path == "/redirect":
|
||||
self.emit(302, b"redirect body\n", {"Location": "/small.html", "Cache-Control": "no-store"})
|
||||
return
|
||||
if path in ("/private", "/cookie", "/no-store", "/vary", "/fresh", "/revalidate", "/concurrent"):
|
||||
cache_control = "public, max-age=60"
|
||||
headers = {"Content-Type": "text/plain"}
|
||||
if body:
|
||||
headers["X-Received-Body-Bytes"] = str(len(body))
|
||||
if path == "/private":
|
||||
cache_control = "private, max-age=60"
|
||||
if path == "/no-store":
|
||||
cache_control = "no-store"
|
||||
if path == "/cookie":
|
||||
headers["Set-Cookie"] = "application-session=fixture; Path=/"
|
||||
if path == "/vary":
|
||||
headers["Vary"] = "Accept-Language"
|
||||
if path == "/concurrent":
|
||||
time.sleep(.08)
|
||||
if path == "/revalidate":
|
||||
cache_control = "public, max-age=1, stale-if-error=10"
|
||||
headers["ETag"] = '"fixture-v1"'
|
||||
if self.headers.get("If-None-Match") == headers["ETag"]:
|
||||
self.emit(304, b"", {**headers, "Cache-Control": cache_control})
|
||||
return
|
||||
data = f"{path}:count={count}:lang={self.headers.get('Accept-Language', '')}".encode()
|
||||
if path == "/revalidate":
|
||||
data = b"stable revalidated object"
|
||||
self.emit(200, data, {**headers, "Cache-Control": cache_control})
|
||||
return
|
||||
if path == "/no-transform":
|
||||
self.emit(200, b"unchanged compression fixture " * 1024,
|
||||
{"Content-Type": "text/plain", "Cache-Control": "no-transform, public, max-age=60"})
|
||||
return
|
||||
file = peer.site / path.lstrip("/")
|
||||
if not file.is_file() or ".." in file.parts:
|
||||
self.emit(404, b"fixture not found")
|
||||
return
|
||||
payload = file.read_bytes()
|
||||
tag = '"' + hashlib.sha256(payload).hexdigest() + '"'
|
||||
headers = {"Cache-Control": "public, max-age=60", "ETag": tag,
|
||||
"Accept-Ranges": "bytes", "X-Origin-Fixture": "yes"}
|
||||
headers["Content-Type"] = {".html": "text/html", ".txt": "text/plain",
|
||||
".css": "text/css", ".json": "application/json"}.get(file.suffix, "application/octet-stream")
|
||||
if self.headers.get("If-None-Match") == tag:
|
||||
self.emit(304, b"", headers)
|
||||
return
|
||||
range_value = self.headers.get("Range")
|
||||
if range_value and self.headers.get("If-Range", tag) == tag:
|
||||
try:
|
||||
first, last = range_value.removeprefix("bytes=").split("-", 1)
|
||||
start = int(first) if first else max(0, len(payload) - int(last))
|
||||
end = min(int(last), len(payload) - 1) if first and last else len(payload) - 1
|
||||
if start >= len(payload) or end < start:
|
||||
self.emit(416, b"", {**headers, "Content-Range": f"bytes */{len(payload)}"})
|
||||
else:
|
||||
self.emit(206, payload[start:end + 1], {**headers,
|
||||
"Content-Range": f"bytes {start}-{end}/{len(payload)}"})
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
self.emit(200, payload, headers)
|
||||
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
self.server.daemon_threads = True
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def count(self, method, path):
|
||||
with self.lock:
|
||||
return self.counts[(method, path)]
|
||||
|
||||
def close(self):
|
||||
self.release_stream.set()
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=2)
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Profile selected server syscalls separately from throughput measurements.
|
||||
|
||||
Runs each RustProxy binary sequentially against a disposable loopback fixture.
|
||||
The measured operation is one persistent HTTP/1.1 connection requesting the
|
||||
64-KiB static file with cache disabled and Accept-Encoding: identity.
|
||||
Every response is validated; syscall counts include background server work.
|
||||
strace elapsed/CPU times must not be interpreted as uninstrumented performance.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from http.client import HTTPConnection
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from common import Fixture, seal_results, sha256, write_json
|
||||
from resources import descendants
|
||||
|
||||
|
||||
SYSCALLS = ("read", "pread64", "write", "writev", "openat", "close", "fcntl", "lseek", "futex")
|
||||
REQUEST_PATH = "/large.bin"
|
||||
|
||||
|
||||
def thread_tracers(pid: int) -> dict[int, int]:
|
||||
result = {}
|
||||
for task in Path(f"/proc/{pid}/task").iterdir():
|
||||
try:
|
||||
status = (task / "status").read_text()
|
||||
except FileNotFoundError:
|
||||
continue # A thread may exit between the directory read and status.
|
||||
match = re.search(r"^TracerPid:\s+(\d+)$", status, re.MULTILINE)
|
||||
if match:
|
||||
result[int(task.name)] = int(match.group(1))
|
||||
return result
|
||||
|
||||
|
||||
def wait_attached(tracer, server, timeout=10):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if tracer.poll() is not None:
|
||||
raise RuntimeError("strace exited before attachment; see strace diagnostic log (ptrace may be unavailable)")
|
||||
if server.poll() is not None:
|
||||
raise RuntimeError("server exited during strace attachment")
|
||||
traced = thread_tracers(server.pid)
|
||||
if traced and all(traced.values()):
|
||||
return sorted(traced)
|
||||
time.sleep(.01)
|
||||
raise RuntimeError("strace did not attach to every server thread within 10 seconds")
|
||||
|
||||
|
||||
def detach(tracer, server):
|
||||
# Do not signal the process group: only the independently started tracer.
|
||||
if tracer.poll() is None:
|
||||
tracer.send_signal(signal.SIGINT)
|
||||
try:
|
||||
tracer.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
tracer.terminate()
|
||||
try:
|
||||
tracer.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
tracer.kill()
|
||||
tracer.wait(timeout=3)
|
||||
raise RuntimeError("strace failed to finish on SIGINT; profile is incomplete")
|
||||
if server.poll() is not None:
|
||||
raise RuntimeError("server exited while the tracer was detached")
|
||||
traced = thread_tracers(server.pid)
|
||||
if not traced or any(traced.values()):
|
||||
raise RuntimeError("server still has traced threads after strace exited")
|
||||
return {"signal": "SIGINT to strace PID only", "strace_returncode": tracer.returncode,
|
||||
"server_alive": True, "remaining_traced_threads": 0}
|
||||
|
||||
|
||||
def parse_summary(path: Path, requests: int):
|
||||
rows = {name: {"calls": 0, "errors": 0, "calls_per_request": 0.0}
|
||||
for name in SYSCALLS}
|
||||
total = None
|
||||
for line in path.read_text().splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) not in (5, 6) or fields[-1] not in (*SYSCALLS, "total"):
|
||||
continue
|
||||
percent, seconds, usecs, calls = map(float, fields[:4])
|
||||
errors = int(fields[4]) if len(fields) == 6 else 0
|
||||
row = {"calls": int(calls), "errors": errors,
|
||||
"calls_per_request": int(calls) / requests,
|
||||
"strace_system_seconds": seconds, "strace_percent_time": percent,
|
||||
"strace_us_per_call": usecs}
|
||||
if fields[-1] == "total":
|
||||
total = row
|
||||
else:
|
||||
rows[fields[-1]] = row
|
||||
if total is None:
|
||||
raise RuntimeError(f"no complete strace summary found in {path}")
|
||||
if sum(row["calls"] for row in rows.values()) != total["calls"]:
|
||||
raise RuntimeError("parsed syscall counts do not match the raw strace total")
|
||||
return {"selected_syscalls": rows, "selected_total": total}
|
||||
|
||||
|
||||
def request_checked(connection, expected_socket, expected_sha, phase, sequence, evidence):
|
||||
if connection.sock is not expected_socket:
|
||||
raise RuntimeError("keepalive connection was replaced before the next request")
|
||||
connection.request("GET", REQUEST_PATH,
|
||||
headers={"Accept-Encoding": "identity", "Connection": "keep-alive"})
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
headers = {key.lower(): value for key, value in response.getheaders()}
|
||||
valid = (response.status == 200 and len(payload) == 65536 and digest == expected_sha
|
||||
and headers.get("content-length") == "65536"
|
||||
and headers.get("content-encoding", "identity") == "identity"
|
||||
and connection.sock is expected_socket and not response.will_close)
|
||||
result = {"phase": phase, "sequence": sequence, "status": response.status,
|
||||
"bytes": len(payload), "sha256": digest,
|
||||
"content_length": headers.get("content-length"),
|
||||
"content_encoding": headers.get("content-encoding"),
|
||||
"connection_reused": connection.sock is expected_socket,
|
||||
"will_close": response.will_close, "passed": valid}
|
||||
evidence.write(json.dumps(result, sort_keys=True) + "\n")
|
||||
evidence.flush()
|
||||
if not valid:
|
||||
raise RuntimeError(f"response verification failed in {phase} request {sequence}: {result}")
|
||||
|
||||
|
||||
def profile_variant(fixture, label, binary, args):
|
||||
rust = fixture.rust(binary, label)
|
||||
rust.login()
|
||||
rust.wait_quiescent()
|
||||
raw = args.out / "raw"
|
||||
raw.mkdir(exist_ok=True)
|
||||
summary_path = raw / f"{label}.strace.txt"
|
||||
diagnostic_path = raw / f"{label}.strace.log"
|
||||
responses_path = raw / f"{label}.responses.jsonl"
|
||||
expected_sha = sha256(fixture.site / "large.bin")
|
||||
quiescence_path = fixture.results / f"{label}-quiescence.json"
|
||||
config_path = fixture.results / "configs" / f"{label}-base.json"
|
||||
config_sha = sha256(config_path)
|
||||
if config_sha != rust.applies[-1]["sha256"]:
|
||||
raise RuntimeError("saved fixture configuration differs from the applied configuration")
|
||||
command = [str(args.strace), "-f", "-c", "-e", "trace=" + ",".join(SYSCALLS),
|
||||
"-p", str(rust.process.pid), "-o", str(summary_path)]
|
||||
connection = HTTPConnection("127.0.0.1", rust.static_port, timeout=args.timeout)
|
||||
tracer = None
|
||||
result = {"variant": label, "binary": str(binary), "binary_sha256": sha256(binary),
|
||||
"config": str(config_path.relative_to(args.out)), "config_sha256": config_sha,
|
||||
"quiescence": str(quiescence_path.relative_to(args.out)),
|
||||
"quiescence_sha256": sha256(quiescence_path),
|
||||
"pid": rust.process.pid, "request_path": REQUEST_PATH, "payload_bytes": 65536,
|
||||
"payload_sha256": expected_sha, "cache_enabled": False,
|
||||
"request_encoding": "identity", "concurrency": 1,
|
||||
"warmup_requests": args.warmup, "profile_requests": args.requests,
|
||||
"strace_command": command, "raw_summary": str(summary_path.relative_to(args.out)),
|
||||
"raw_diagnostics": str(diagnostic_path.relative_to(args.out)),
|
||||
"responses": str(responses_path.relative_to(args.out)), "passed": False}
|
||||
try:
|
||||
with responses_path.open("w") as responses, diagnostic_path.open("w") as diagnostics:
|
||||
connection.connect()
|
||||
expected_socket = connection.sock
|
||||
result["client_local_address"] = expected_socket.getsockname()
|
||||
for index in range(args.warmup):
|
||||
request_checked(connection, expected_socket, expected_sha, "warmup", index + 1, responses)
|
||||
result["processes_before_attach"] = descendants(rust.process.pid)
|
||||
if result["processes_before_attach"] != [rust.process.pid]:
|
||||
raise RuntimeError("server spawned a child after quiescence; refusing contaminated syscall trace")
|
||||
environment = os.environ.copy()
|
||||
environment["LC_ALL"] = "C"
|
||||
tracer = subprocess.Popen(command, stdout=diagnostics, stderr=diagnostics,
|
||||
env=environment, start_new_session=True)
|
||||
result["initial_traced_threads"] = wait_attached(tracer, rust.process)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
for index in range(args.requests):
|
||||
request_checked(connection, expected_socket, expected_sha, "profile", index + 1, responses)
|
||||
if tracer.poll() is not None:
|
||||
raise RuntimeError("strace exited before all profile requests completed")
|
||||
finally:
|
||||
result["instrumented_request_loop_seconds"] = time.monotonic() - started
|
||||
result["detach"] = detach(tracer, rust.process)
|
||||
tracer = None
|
||||
# An actual HTTP response after detaching proves the server stayed usable.
|
||||
request_checked(connection, expected_socket, expected_sha, "after_detach", 1, responses)
|
||||
result["processes_after_detach"] = descendants(rust.process.pid)
|
||||
if result["processes_after_detach"] != [rust.process.pid]:
|
||||
raise RuntimeError("server has a child after profiling; syscall trace needs review")
|
||||
result.update(parse_summary(summary_path, args.requests))
|
||||
result["verified_responses"] = args.requests + args.warmup + 1
|
||||
result["passed"] = True
|
||||
return result
|
||||
finally:
|
||||
if tracer is not None:
|
||||
detach(tracer, rust.process)
|
||||
connection.close()
|
||||
fixture.stop(rust.process)
|
||||
write_json(args.out / f"{label}.json", result)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
|
||||
parser.add_argument("--nginx", type=Path, required=True, help="nginx executable for the shared fixture origin")
|
||||
parser.add_argument("--strace", type=Path, default=shutil.which("strace"))
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
parser.add_argument("--requests", type=int, default=500)
|
||||
parser.add_argument("--warmup", type=int, default=20)
|
||||
parser.add_argument("--workers", type=int, default=4)
|
||||
parser.add_argument("--origin-workers", type=int, default=4)
|
||||
parser.add_argument("--server-cpus")
|
||||
parser.add_argument("--origin-cpus")
|
||||
parser.add_argument("--timeout", type=float, default=20)
|
||||
args = parser.parse_args()
|
||||
if args.requests < 1 or args.warmup < 0 or args.workers < 1 or args.origin_workers < 1 or args.timeout <= 0:
|
||||
parser.error("requests/workers/timeout must be positive, warmup must be nonnegative")
|
||||
if args.strace is None:
|
||||
parser.error("strace is required; pass --strace /absolute/path")
|
||||
if args.out.exists() and (not args.out.is_dir() or any(args.out.iterdir())):
|
||||
parser.error("--out must be a new or empty directory; existing evidence is never overwritten")
|
||||
variants = {}
|
||||
for value in args.binary:
|
||||
label, separator, filename = value.partition("=")
|
||||
if not separator or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,48}", label) or label in variants:
|
||||
parser.error("each --binary must use a unique filesystem-safe LABEL=/ABS/PATH")
|
||||
binary = Path(filename).resolve()
|
||||
if not binary.is_file():
|
||||
parser.error(f"binary does not exist: {binary}")
|
||||
variants[label] = binary
|
||||
args.out, args.nginx, args.strace = args.out.resolve(), args.nginx.resolve(), args.strace.resolve()
|
||||
for executable in (args.nginx, args.strace):
|
||||
if not executable.is_file() or not os.access(executable, os.X_OK):
|
||||
parser.error(f"executable is missing or is not executable: {executable}")
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
version = subprocess.run([str(args.strace), "-V"], capture_output=True, text=True, timeout=10)
|
||||
environment = {"kind": "selected syscall counts with ptrace instrumentation", "simulation": False,
|
||||
"platform": platform.platform(), "python": sys.version, "strace_version": version.stdout + version.stderr,
|
||||
"strace_sha256": sha256(args.strace), "nginx_sha256": sha256(args.nginx),
|
||||
"variant_order": list(variants), "selected_syscalls": SYSCALLS,
|
||||
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"settings": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()},
|
||||
"limitations": ["strace changes scheduling and latency; do not use instrumented times as RPS evidence",
|
||||
"Counts cover selected syscalls in every traced server thread, including background work",
|
||||
"Normalized counts divide the whole trace by verified profile requests; they do not isolate per-request causality",
|
||||
"This is a warm-file-cache, c1, HTTP/1.1 plaintext static-file scenario; no general capacity inference",
|
||||
"Syscall counts do not directly count Tokio task dispatches or memory allocations"]}
|
||||
for path in ("/proc/sys/kernel/yama/ptrace_scope", "/sys/fs/cgroup/cpu.max", "/sys/fs/cgroup/memory.max"):
|
||||
try:
|
||||
environment[path] = Path(path).read_text().strip()
|
||||
except OSError:
|
||||
environment[path] = None
|
||||
write_json(args.out / "environment.json", environment)
|
||||
rows, error = [], None
|
||||
try:
|
||||
with Fixture(args.out / "fixture", workers=args.workers, server_cpus=args.server_cpus,
|
||||
origin_cpus=args.origin_cpus, origin_workers=args.origin_workers) as fixture:
|
||||
fixture.nginx(args.nginx, frontend=False)
|
||||
for label, binary in variants.items():
|
||||
result = profile_variant(fixture, label, binary, args)
|
||||
rows.append(result)
|
||||
write_json(args.out / "profiles.json", rows)
|
||||
print(json.dumps({"variant": label, "requests": args.requests, "passed": result["passed"],
|
||||
"selected_syscall_calls": result["selected_total"]["calls"]}), flush=True)
|
||||
except Exception as exc:
|
||||
error = {"error": str(exc), "traceback": traceback.format_exc()}
|
||||
write_json(args.out / "failure.json", error)
|
||||
print(json.dumps(error), flush=True)
|
||||
finally:
|
||||
write_json(args.out / "profiles.json", rows)
|
||||
write_json(args.out / "completion.json", {"completed": error is None, "profiles": len(rows),
|
||||
"simulation": False, "throughput_measurement": False,
|
||||
"finished_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
|
||||
seal_results(args.out)
|
||||
return 1 if error else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run black-box acceptance tests against one or more real RustProxy binaries.
|
||||
|
||||
Example:
|
||||
python3 scripts/performance/regression.py --binary before=/abs/before \
|
||||
--binary after=/abs/after --out /abs/results/regression
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from common import Fixture, seal_results, write_json
|
||||
from peer import Peer
|
||||
import checks_cache
|
||||
import checks_policy
|
||||
import checks_proxy
|
||||
import checks_static
|
||||
|
||||
|
||||
GROUPS = {"static": checks_static.run, "proxy": checks_proxy.run,
|
||||
"policy": checks_policy.run, "cache": checks_cache.run,
|
||||
"transform": checks_cache.transforms}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
|
||||
parser.add_argument("--out", required=True, type=Path)
|
||||
parser.add_argument("--workers", type=int, default=4)
|
||||
parser.add_argument("--groups", default=",".join(GROUPS))
|
||||
args = parser.parse_args()
|
||||
groups = args.groups.split(",")
|
||||
if any(group not in GROUPS for group in groups):
|
||||
parser.error(f"groups must be selected from {','.join(GROUPS)}")
|
||||
args.out = args.out.resolve()
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
metadata = {"kind": "live RustProxy black-box HTTP acceptance", "simulation": False,
|
||||
"python": sys.version, "platform": platform.platform(),
|
||||
"cpu_affinity": sorted(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else None,
|
||||
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"groups": groups,
|
||||
"not_covered": ["TLS/ALPN", "HTTP/2", "HTTP/3", "PHP-FPM",
|
||||
"Tinyauth verifier", "real DNS rebinding", "24-hour soak",
|
||||
"concurrent filesystem rename-race proof"]}
|
||||
write_json(args.out / "environment.json", metadata)
|
||||
reports = {}
|
||||
for binary_arg in args.binary:
|
||||
label, separator, name = binary_arg.partition("=")
|
||||
if not separator or not label.replace("-", "").replace("_", "").isalnum():
|
||||
parser.error("--binary must be LABEL=/ABS/PATH with a simple unique label")
|
||||
if label in reports:
|
||||
parser.error("binary labels must be unique")
|
||||
cases = []
|
||||
started = time.monotonic()
|
||||
output = args.out / label
|
||||
|
||||
def check(case, passed, **details):
|
||||
row = {"case": case, "passed": passed, **details}
|
||||
cases.append(row)
|
||||
print(f"{label} {'SKIP' if passed is None else 'PASS' if passed else 'FAIL'} {case}", flush=True)
|
||||
write_json(output / "checks.json", cases)
|
||||
|
||||
try:
|
||||
with Fixture(output, workers=args.workers) as fixture:
|
||||
peer = Peer(fixture.origin_port, fixture.site)
|
||||
try:
|
||||
rust = fixture.rust(Path(name), label)
|
||||
check("runtime.started", True)
|
||||
for group in groups:
|
||||
try:
|
||||
rust.reset()
|
||||
GROUPS[group](check, rust, peer)
|
||||
except Exception as exc:
|
||||
check(group + ".runner", False, error=str(exc), traceback=traceback.format_exc())
|
||||
finally:
|
||||
rust.reset()
|
||||
check("runtime.still_alive", rust.process.poll() is None)
|
||||
finally:
|
||||
peer.close()
|
||||
except Exception as exc:
|
||||
check("runtime.runner", False, error=str(exc), traceback=traceback.format_exc())
|
||||
report = {"label": label, "kind": metadata["kind"], "simulation": False,
|
||||
"seconds": time.monotonic() - started, "passed": sum(c["passed"] is True for c in cases),
|
||||
"failed": sum(c["passed"] is False for c in cases),
|
||||
"skipped": sum(c["passed"] is None for c in cases), "cases": cases}
|
||||
reports[label] = report
|
||||
write_json(output / "report.json", report)
|
||||
print(f"{label}: {report['passed']} passed, {report['failed']} failed, {report['skipped']} skipped", flush=True)
|
||||
comparison = {"variants": {name: {key: report[key] for key in ["passed", "failed", "skipped", "seconds"]}
|
||||
for name, report in reports.items()},
|
||||
"cases": [{"case": case, **{name: next((entry["passed"] for entry in report["cases"]
|
||||
if entry["case"] == case), "not_run") for name, report in reports.items()}}
|
||||
for case in sorted({entry["case"] for report in reports.values() for entry in report["cases"]})]}
|
||||
write_json(args.out / "comparison.json", comparison)
|
||||
seal_results(args.out)
|
||||
return 1 if any(report["failed"] for report in reports.values()) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,228 +0,0 @@
|
||||
"""Linux /proc sampling outside the server process; no synthetic resource data."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def cgroup_cpu_stat(path=Path("/sys/fs/cgroup/cpu.stat")):
|
||||
values = {key: int(value) for key, value in (line.split() for line in path.read_text().splitlines())}
|
||||
if not {"nr_throttled", "throttled_usec"} <= values.keys():
|
||||
raise RuntimeError(f"{path} lacks cgroup v2 throttling counters")
|
||||
return {"path": str(path), "observed_monotonic": time.monotonic(), "values": values}
|
||||
|
||||
|
||||
def cgroup_cpu_delta(before, after):
|
||||
delta = {key: after["values"][key] - value for key, value in before["values"].items()
|
||||
if key in after["values"]}
|
||||
if any(value < 0 for value in delta.values()):
|
||||
raise RuntimeError("cgroup CPU counters reset during the measured run")
|
||||
return delta
|
||||
|
||||
|
||||
def parse_cpu_set(value):
|
||||
if not value:
|
||||
raise ValueError("an explicit, nonempty CPU set is required")
|
||||
result = set()
|
||||
for part in value.split(","):
|
||||
bounds = part.strip().split("-")
|
||||
if len(bounds) not in (1, 2) or any(not item.isascii() or not item.isdigit() for item in bounds):
|
||||
raise ValueError(f"invalid CPU set {value!r}; use comma-separated numbers or ranges")
|
||||
start, end = int(bounds[0]), int(bounds[-1])
|
||||
if start > end or end - start > 65536:
|
||||
raise ValueError(f"invalid CPU range {part!r}")
|
||||
result.update(range(start, end + 1))
|
||||
return result
|
||||
|
||||
|
||||
def validate_cpu_sets(values, allowed):
|
||||
groups = {label: parse_cpu_set(value) for label, value in values.items()}
|
||||
for label, cpus in groups.items():
|
||||
if not cpus <= allowed:
|
||||
raise ValueError(f"{label} CPUs {sorted(cpus - allowed)} are outside the allowed affinity")
|
||||
for other, other_cpus in groups.items():
|
||||
if label < other and cpus & other_cpus:
|
||||
raise ValueError(f"{label} and {other} CPU sets overlap: {sorted(cpus & other_cpus)}")
|
||||
return groups
|
||||
|
||||
|
||||
def affinity_snapshot(root, expected, expected_processes=None):
|
||||
"""Verify every currently visible thread in the root process and children."""
|
||||
tasks = []
|
||||
for pid in descendants(root):
|
||||
try:
|
||||
threads = list(Path(f"/proc/{pid}/task").iterdir())
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
for thread in threads:
|
||||
try:
|
||||
actual = set(os.sched_getaffinity(int(thread.name)))
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
if actual != expected:
|
||||
raise RuntimeError(f"affinity mismatch for PID {pid} TID {thread.name}: "
|
||||
f"expected {sorted(expected)}, got {sorted(actual)}")
|
||||
tasks.append({"pid": pid, "tid": int(thread.name), "cpus": sorted(actual)})
|
||||
if not tasks or not any(task["pid"] == root for task in tasks):
|
||||
raise RuntimeError(f"cannot inspect affinity of live process {root}")
|
||||
processes = len({task["pid"] for task in tasks})
|
||||
if expected_processes is not None and processes != expected_processes:
|
||||
raise RuntimeError(f"process count mismatch for PID {root}: "
|
||||
f"expected {expected_processes}, inspected {processes}")
|
||||
return {"root_pid": root, "processes": processes,
|
||||
"threads": len(tasks), "tasks": tasks}
|
||||
|
||||
|
||||
def wait_for_affinity(process, expected, timeout=2):
|
||||
"""Allow taskset to execute before inspecting the generator's inherited mask."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"generator exited before affinity verification: {process.returncode}")
|
||||
try:
|
||||
return affinity_snapshot(process.pid, expected)
|
||||
except RuntimeError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(.01)
|
||||
|
||||
|
||||
def descendants(pid, proc_root=Path("/proc")):
|
||||
"""Find the process tree through stat PPids, including nginx workers.
|
||||
|
||||
/proc/PID/task/PID/children is optional on some Linux kernels and may also
|
||||
omit children created by another thread. Missing that file is not evidence
|
||||
that a live process is a leaf. A single PPid-map scan avoids either case.
|
||||
"""
|
||||
children, seen = {}, set()
|
||||
for entry in proc_root.iterdir():
|
||||
if not entry.name.isdecimal():
|
||||
continue
|
||||
current = int(entry.name)
|
||||
try:
|
||||
fields = (entry / "stat").read_text().rsplit(")", 1)[1].split()
|
||||
parent = int(fields[1])
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
# A non-root process may finish while the snapshot is assembled.
|
||||
continue
|
||||
except (OSError, ValueError, IndexError) as error:
|
||||
raise RuntimeError(f"cannot discover process tree from PID {current}: {error}") from error
|
||||
if fields[0] in {"Z", "X", "x"}:
|
||||
# A dead/zombie worker cannot satisfy the configured live count.
|
||||
continue
|
||||
seen.add(current)
|
||||
children.setdefault(parent, []).append(current)
|
||||
if pid not in seen:
|
||||
raise RuntimeError(f"no readable live root process for PID {pid}")
|
||||
result, pending = [], [pid]
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current in result:
|
||||
continue
|
||||
result.append(current)
|
||||
pending.extend(sorted(children.get(current, []), reverse=True))
|
||||
return result
|
||||
|
||||
|
||||
def process_stats(pid):
|
||||
try:
|
||||
data = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
|
||||
if data[0] in {"Z", "X", "x"}:
|
||||
return None
|
||||
ticks = os.sysconf("SC_CLK_TCK")
|
||||
pages = os.sysconf("SC_PAGE_SIZE")
|
||||
return {"pid": pid, "start_ticks": int(data[19]),
|
||||
"cpu_seconds": (int(data[11]) + int(data[12])) / ticks,
|
||||
"rss_bytes": int(data[21]) * pages,
|
||||
"fd": len(list(Path(f"/proc/{pid}/fd").iterdir())),
|
||||
"threads": int(data[17])}
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return None
|
||||
except (OSError, ValueError, IndexError) as error:
|
||||
raise RuntimeError(f"cannot read resource counters for PID {pid}: {error}") from error
|
||||
|
||||
|
||||
class Monitor:
|
||||
def __init__(self, roots, interval=.5, expected_processes=None):
|
||||
self.roots = roots
|
||||
self.expected_processes = expected_processes or {}
|
||||
self.process_identities = {}
|
||||
self.interval = interval
|
||||
if interval <= 0:
|
||||
raise ValueError("resource interval must be positive")
|
||||
self.samples = []
|
||||
self.errors = []
|
||||
self.stop_event = threading.Event()
|
||||
self.started = time.monotonic()
|
||||
self.thread = threading.Thread(target=self.run, daemon=True)
|
||||
|
||||
def sample(self):
|
||||
groups = {}
|
||||
for label, root in self.roots.items():
|
||||
processes = [stats for pid in descendants(root) if (stats := process_stats(pid)) is not None]
|
||||
if not any(process["pid"] == root for process in processes):
|
||||
raise RuntimeError(f"no readable live root process for {label} (PID {root})")
|
||||
expected = self.expected_processes.get(label)
|
||||
if expected is not None and len(processes) != expected:
|
||||
raise RuntimeError(f"resource process count mismatch for {label} (PID {root}): "
|
||||
f"expected {expected}, read {len(processes)}")
|
||||
identities = {(process["pid"], process["start_ticks"]) for process in processes}
|
||||
if expected is not None:
|
||||
original = self.process_identities.setdefault(label, identities)
|
||||
if identities != original:
|
||||
raise RuntimeError(f"process identities changed while measuring {label}: "
|
||||
f"expected {sorted(original)}, read {sorted(identities)}")
|
||||
groups[label] = {"processes": processes,
|
||||
"rss_bytes": sum(p["rss_bytes"] for p in processes),
|
||||
"fd": sum(p["fd"] for p in processes),
|
||||
"threads": sum(p["threads"] for p in processes)}
|
||||
self.samples.append({"seconds": time.monotonic() - self.started, "groups": groups})
|
||||
|
||||
def run(self):
|
||||
while not self.stop_event.wait(self.interval):
|
||||
try:
|
||||
self.sample()
|
||||
except Exception as error:
|
||||
self.errors.append(str(error))
|
||||
self.stop_event.set()
|
||||
|
||||
def start(self):
|
||||
self.sample()
|
||||
self.thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.stop_event.set()
|
||||
self.thread.join(timeout=3)
|
||||
if self.thread.is_alive():
|
||||
self.errors.append("resource sampler did not stop")
|
||||
try:
|
||||
self.sample()
|
||||
except Exception as error:
|
||||
self.errors.append(str(error))
|
||||
|
||||
def summary(self, requests):
|
||||
if self.errors:
|
||||
raise RuntimeError("resource sampling failed: " + "; ".join(self.errors))
|
||||
result = {}
|
||||
for label in self.roots:
|
||||
observations = [sample["groups"][label] for sample in self.samples]
|
||||
totals = {}
|
||||
for group in observations:
|
||||
for process in group["processes"]:
|
||||
key = (process["pid"], process["start_ticks"])
|
||||
low, high = totals.get(key, (process["cpu_seconds"], process["cpu_seconds"]))
|
||||
totals[key] = (min(low, process["cpu_seconds"]), max(high, process["cpu_seconds"]))
|
||||
if not totals:
|
||||
raise RuntimeError(f"no readable process samples for {label}; refusing zero resource metrics")
|
||||
seconds = sum(high - low for low, high in totals.values())
|
||||
result[label] = {"cpu_seconds": seconds,
|
||||
"cpu_us_per_request": seconds * 1_000_000 / requests if requests else None,
|
||||
"rss_peak_bytes": max(group["rss_bytes"] for group in observations),
|
||||
"rss_start_bytes": observations[0]["rss_bytes"],
|
||||
"rss_end_bytes": observations[-1]["rss_bytes"],
|
||||
"fd_peak": max(group["fd"] for group in observations),
|
||||
"fd_start": observations[0]["fd"], "fd_end": observations[-1]["fd"],
|
||||
"processes_seen": len(totals)}
|
||||
return result
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic analytical/synthetic cache-retention scenario, not a RustProxy load test.
|
||||
|
||||
Model: 20 vhosts with 100 HOT + 100 COLD uniformly requested objects each.
|
||||
A new configuration is saved 1,000 times. Eighty percent of saves edit only
|
||||
keepalive/LB operational controls, and 20% edit content policy on one vhost.
|
||||
Every save is followed by 500 uniform GETs; every object is public/cacheable and
|
||||
has infinite TTL. A miss immediately inserts the object in its original tier.
|
||||
Capacity is assumed adequate and there is no origin latency or concurrent IO.
|
||||
The baseline flushes both tiers globally on every save, as in 0.33.8.
|
||||
The candidate changes only the affected vhost after content edits.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
|
||||
|
||||
def simulate(vhosts=20, objects_per_tier=100, saves=1000, requests_per_save=500, seed=3400):
|
||||
if min(vhosts, objects_per_tier, saves, requests_per_save) < 1:
|
||||
raise ValueError("all counts must be positive")
|
||||
rng = random.Random(seed)
|
||||
objects = vhosts * objects_per_tier * 2
|
||||
old = set(range(objects))
|
||||
new = set(range(objects))
|
||||
old_hits = new_hits = old_misses = new_misses = 0
|
||||
old_evicted = new_evicted = 0
|
||||
edits_operational = edits_content = 0
|
||||
for step in range(saves):
|
||||
changed_vhost = rng.randrange(vhosts)
|
||||
content_edit = step % 5 == 0
|
||||
if content_edit:
|
||||
edits_content += 1
|
||||
else:
|
||||
edits_operational += 1
|
||||
old_evicted += len(old)
|
||||
old.clear()
|
||||
if content_edit:
|
||||
low = changed_vhost * objects_per_tier * 2
|
||||
high = low + objects_per_tier * 2
|
||||
removed = set(range(low, high)) & new
|
||||
new_evicted += len(removed)
|
||||
new.difference_update(removed)
|
||||
for _ in range(requests_per_save):
|
||||
object_id = rng.randrange(objects)
|
||||
if object_id in old:
|
||||
old_hits += 1
|
||||
else:
|
||||
old_misses += 1
|
||||
old.add(object_id)
|
||||
if object_id in new:
|
||||
new_hits += 1
|
||||
else:
|
||||
new_misses += 1
|
||||
new.add(object_id)
|
||||
total = saves * requests_per_save
|
||||
assert old_hits + old_misses == total
|
||||
assert new_hits + new_misses == total
|
||||
assert new_evicted <= old_evicted
|
||||
assert new_misses <= old_misses
|
||||
return {
|
||||
"scenario": {
|
||||
"vhosts": vhosts,
|
||||
"hot_objects_per_vhost": objects_per_tier,
|
||||
"cold_objects_per_vhost": objects_per_tier,
|
||||
"saves": saves,
|
||||
"operational_saves": edits_operational,
|
||||
"single_vhost_content_saves": edits_content,
|
||||
"gets_per_save": requests_per_save,
|
||||
"total_gets": total,
|
||||
"seed": seed,
|
||||
"assumptions": "All objects public/cacheable, infinite TTL, instantly refilled, uniform GETs; no bandwidth/CPU/origin timings",
|
||||
},
|
||||
"legacy_global_purge": {
|
||||
"invalidated_objects": old_evicted,
|
||||
"misses": old_misses,
|
||||
"hits": old_hits,
|
||||
"hit_ratio_percent": round(100 * old_hits / total, 3),
|
||||
},
|
||||
"scoped_cache": {
|
||||
"invalidated_objects": new_evicted,
|
||||
"misses": new_misses,
|
||||
"hits": new_hits,
|
||||
"hit_ratio_percent": round(100 * new_hits / total, 3),
|
||||
},
|
||||
"improvement": {
|
||||
"fewer_invalidations_percent": round(100 * (1 - new_evicted / old_evicted), 3) if old_evicted else 0,
|
||||
"fewer_cache_misses_percent": round(100 * (1 - new_misses / old_misses), 3) if old_misses else 0,
|
||||
"hit_ratio_gain_percentage_points": round(100 * (new_hits - old_hits) / total, 3),
|
||||
"observed_rps_gain_percent": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--vhosts", type=int, default=20)
|
||||
parser.add_argument("--objects-per-tier", type=int, default=100)
|
||||
parser.add_argument("--saves", type=int, default=1000)
|
||||
parser.add_argument("--gets-per-save", type=int, default=500)
|
||||
parser.add_argument("--seed", type=int, default=3400)
|
||||
parser.add_argument("--output", help="optional JSON results file")
|
||||
args = parser.parse_args()
|
||||
result = simulate(args.vhosts, args.objects_per_tier, args.saves, args.gets_per_save, args.seed)
|
||||
encoded = json.dumps(result, indent=2, sort_keys=True)
|
||||
if args.output:
|
||||
from pathlib import Path
|
||||
Path(args.output).write_text(encoded + "\n")
|
||||
print(encoded)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Amdahl sensitivity study only: 0.33.8 measured baseline, 0.34.0 not measured.
|
||||
|
||||
No synthetic RPS is described as benchmarked RustProxy throughput. Amdahl uses
|
||||
RPS_new = RPS_old / (1 - eligible_cost_fraction * fraction_eliminated).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Verified medians reproduced from the supplied 0.33.8 evidence. Cache OFF.
|
||||
BASELINE = [
|
||||
("reverse_proxy", 1040, 29334.12, 170610.16, False),
|
||||
("reverse_proxy", 65536, 20735.07, 35393.29, False),
|
||||
("reverse_proxy", 1048576, 4461.19, 2730.23, False),
|
||||
("www", 1040, 24906.20, 351605.13, False),
|
||||
("www", 65536, 20856.67, 203709.91, True),
|
||||
("www", 1048576, 2833.71, 12611.49, True),
|
||||
]
|
||||
|
||||
# WWW scenarios: share of per-request service time due to file copy/read;
|
||||
# assume sendfile removes HALF of that part, not the full service time.
|
||||
WWW_SHARES = {"low": 0.20, "middle": 0.40, "high": 0.60}
|
||||
COPY_COST_REMOVED = 0.50
|
||||
# Proxy independent optimization: hypothetical reduction in *total* request
|
||||
# service time, modeling removal of redundant response-frame mutex, nothing else.
|
||||
PROXY_TIME_SAVED = {"low": 0.01, "middle": 0.03, "high": 0.05}
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
items = []
|
||||
for kind, payload_bytes, rust_rps, nginx_rps, eligible in BASELINE:
|
||||
baseline_ratio = rust_rps / nginx_rps
|
||||
row = {
|
||||
"kind": kind,
|
||||
"bytes": payload_bytes,
|
||||
"baseline_version": "0.33.8",
|
||||
"baseline_rust_rps": rust_rps,
|
||||
"baseline_nginx_rps": nginx_rps,
|
||||
"baseline_of_nginx_pct": round(100 * baseline_ratio, 2),
|
||||
"factor_to_match_nginx": round(nginx_rps / rust_rps, 2),
|
||||
"sendfile_eligible_only_if_plain_linux_http1_1_no_cache_no_compression": eligible,
|
||||
"scenarios": {},
|
||||
}
|
||||
for level in ("low", "middle", "high"):
|
||||
if kind == "www" and eligible:
|
||||
saved_fraction = WWW_SHARES[level] * COPY_COST_REMOVED
|
||||
elif kind == "reverse_proxy":
|
||||
saved_fraction = PROXY_TIME_SAVED[level]
|
||||
else:
|
||||
saved_fraction = 0.0
|
||||
simulated_rps = rust_rps / (1.0 - saved_fraction)
|
||||
row["scenarios"][level] = {
|
||||
"modeled_total_time_saved_pct": round(100 * saved_fraction, 2),
|
||||
"simulated_rps": round(simulated_rps),
|
||||
"simulated_rps_gain_pct": round(100 * (simulated_rps / rust_rps - 1), 2),
|
||||
"simulated_of_nginx_pct": round(100 * simulated_rps / nginx_rps, 2),
|
||||
"simulated_remaining_gap_to_nginx_pct_points": round(
|
||||
100 * (1 - simulated_rps / nginx_rps), 2
|
||||
),
|
||||
}
|
||||
items.append(row)
|
||||
return {
|
||||
"kind": "HYPOTHETICAL_AMDAHL_SENSITIVITY_NOT_A_BENCHMARK",
|
||||
"comparison": "Existing 0.33.8 measurements (5 runs, cache off) vs measured nginx",
|
||||
"not_measured": "0.34.0 server performance and sendfile syscall/runtime correctness",
|
||||
"conditions": [
|
||||
"Identical CPU and network assumptions to supplied 0.33.8 baseline",
|
||||
"WWW zero-copy is limited to Linux, cleartext HTTP/1.1, GET >=64KiB, no cache, range, transform, compression or TLS",
|
||||
"WWW low/middle/high: file-copy contributes 20%/40%/60% of total request time; modeled sendfile eliminates 50% of that component",
|
||||
"Reverse proxy low/middle/high: removing response-frame mutex saves a hypothetical 1%/3%/5% of total service time",
|
||||
"These fractions are chosen sensitivity parameters; not measured or inferred from profiling",
|
||||
"No change is assumed for WWW 1040B or out-of-scope protocol paths",
|
||||
"No network saturation, TLS record processing, queueing, origin latency, filesystem cache misses, CPU contention or concurrency effects modeled",
|
||||
"No additional speedup due to cache-retention changes is assumed",
|
||||
],
|
||||
"formula": "RPS_sim = RPS_0.33.8 / (1 - fraction_of_total_service_time_eliminated)",
|
||||
"entries": items,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, help="write JSON to this file")
|
||||
args = parser.parse_args()
|
||||
payload = json.dumps(run(), indent=2, ensure_ascii=False) + "\n"
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(payload)
|
||||
print(f"wrote {args.output}")
|
||||
else:
|
||||
print(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,325 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fixed-rate mixed traffic, controlled client closes and cache hot-toggle soak.
|
||||
|
||||
The default is a 30-minute live run. Short invocations must be explicitly marked
|
||||
--exploratory and cannot satisfy the release soak gate. No run is auto-scheduled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
|
||||
import copy
|
||||
import hashlib
|
||||
from http.client import HTTPConnection
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from benchmark import counter_values
|
||||
from common import Fixture, http, seal_results, sha256, write_json
|
||||
from resources import Monitor
|
||||
from telemetry import traffic_analytics_snapshot, traffic_analytics_delta
|
||||
|
||||
|
||||
PAYLOADS = ["small.html", "large.bin", "huge.bin"]
|
||||
|
||||
|
||||
def fetch(index, rust, expected, abort_every):
|
||||
name = PAYLOADS[index % len(PAYLOADS)]
|
||||
source = "proxy" if (index // len(PAYLOADS)) % 2 else "static"
|
||||
port = rust.reverse_port if source == "proxy" else rust.static_port
|
||||
started = time.monotonic()
|
||||
case = {"index": index, "source": source, "file": name, "method": "GET", "intentional_abort": False}
|
||||
try:
|
||||
if abort_every and index % abort_every == 0:
|
||||
case.update({"file": "huge.bin", "intentional_abort": True})
|
||||
connection = HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
try:
|
||||
connection.request("GET", "/huge.bin", headers={"Accept-Encoding": "identity"})
|
||||
response = connection.getresponse()
|
||||
body = response.read(128)
|
||||
case.update({"status": response.status, "bytes": len(body),
|
||||
"cache": response.getheader("X-Cache"),
|
||||
"passed": response.status == 200 and body == expected["huge.bin"][:128]})
|
||||
finally:
|
||||
connection.close()
|
||||
else:
|
||||
headers = {"Accept-Encoding": "identity"}
|
||||
method = "HEAD" if index % 29 == 0 else "GET"
|
||||
if method == "GET" and index % 37 == 0:
|
||||
headers["Range"] = "bytes=0-127"
|
||||
expected_status, expected_body = 206, expected[name][:128]
|
||||
else:
|
||||
expected_status, expected_body = 200, b"" if method == "HEAD" else expected[name]
|
||||
status, response_headers, body = http(port, "/" + name, method, headers)
|
||||
case.update({"method": method, "status": status, "bytes": len(body),
|
||||
"cache": response_headers.get("x-cache"),
|
||||
"passed": status == expected_status and body == expected_body})
|
||||
if not case["passed"]:
|
||||
case.update({"expected_status": expected_status, "expected_bytes": len(expected_body),
|
||||
"body_sha256": hashlib.sha256(body).hexdigest()})
|
||||
except Exception as exc:
|
||||
case.update({"passed": False, "error": str(exc)})
|
||||
case["seconds"] = time.monotonic() - started
|
||||
return case
|
||||
|
||||
|
||||
def probe_cache_mode(rust, expected, enabled, phase, out, observations):
|
||||
"""Record two exact GETs per source, separate from the paced workload."""
|
||||
phase_observations = []
|
||||
for source, port in [("proxy", rust.reverse_port), ("static", rust.static_port)]:
|
||||
replies = []
|
||||
for attempt in [1, 2]:
|
||||
try:
|
||||
status, headers, body = http(port, "/small.html?soak_cache_probe=1",
|
||||
headers={"Accept-Encoding": "identity"})
|
||||
replies.append({"attempt": attempt, "status": status, "cache": headers.get("x-cache"),
|
||||
"bytes": len(body), "sha256": hashlib.sha256(body).hexdigest(),
|
||||
"body_passed": status == 200 and body == expected["small.html"]})
|
||||
except Exception as error:
|
||||
replies.append({"attempt": attempt, "body_passed": False, "error": str(error)})
|
||||
mode_passed = replies[1].get("cache") == "HIT" if enabled else all(
|
||||
reply.get("cache") is None for reply in replies)
|
||||
phase_observations.append({"phase": phase, "source": source, "cache_enabled": enabled,
|
||||
"responses": replies,
|
||||
"passed": all(reply["body_passed"] for reply in replies) and mode_passed})
|
||||
observations.extend(phase_observations)
|
||||
write_json(out / "cache-probes.json", observations)
|
||||
if not all(observation["passed"] for observation in phase_observations):
|
||||
raise RuntimeError(f"cache mode/body probe failed after {phase}; see cache-probes.json")
|
||||
|
||||
|
||||
def backend_drain_snapshot(rust):
|
||||
status, _, body = rust.admin("/api/status")
|
||||
if status != 200:
|
||||
return {"passed": False, "error": f"admin status HTTP {status}"}
|
||||
backends = []
|
||||
|
||||
def walk(value):
|
||||
if isinstance(value, dict):
|
||||
if "id" in value and "active" in value and "status" in value and "requests" in value:
|
||||
backends.append({key: value[key] for key in ("id", "active", "status", "requests")})
|
||||
for child in value.values():
|
||||
walk(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
walk(child)
|
||||
walk(json.loads(body))
|
||||
return {"passed": bool(backends) and all(backend["active"] == 0 for backend in backends),
|
||||
"backends": backends}
|
||||
|
||||
|
||||
def memory_trend(samples, label="server"):
|
||||
observations = [(sample["seconds"], sample["groups"][label]["rss_bytes"], sample["groups"][label]["fd"])
|
||||
for sample in samples if sample["seconds"] >= 60]
|
||||
if len(observations) < 3:
|
||||
return {"available": False, "reason": "less than three samples after the first minute"}
|
||||
tail_seconds = observations[-1][0]
|
||||
early = [row[1] for row in observations if row[0] < observations[0][0] + 60]
|
||||
late = [row[1] for row in observations if row[0] >= tail_seconds - 60]
|
||||
x_mean = statistics.mean(row[0] for row in observations)
|
||||
y_mean = statistics.mean(row[1] for row in observations)
|
||||
denominator = sum((row[0] - x_mean) ** 2 for row in observations)
|
||||
slope = sum((row[0] - x_mean) * (row[1] - y_mean) for row in observations) / denominator if denominator else 0
|
||||
return {"available": True, "rss_first_minute_after_warmup_median": statistics.median(early),
|
||||
"rss_last_minute_median": statistics.median(late),
|
||||
"rss_growth_bytes": statistics.median(late) - statistics.median(early),
|
||||
"rss_linear_bytes_per_hour": slope * 3600, "fd_first": observations[0][2],
|
||||
"fd_last": observations[-1][2], "fd_peak": max(row[2] for row in observations),
|
||||
"interpretation": "Descriptive only: cache toggles and allocator retention can change RSS without a leak"}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", type=Path, required=True)
|
||||
parser.add_argument("--label", default="after")
|
||||
parser.add_argument("--nginx", type=Path, required=True)
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
parser.add_argument("--duration", type=int, default=1800)
|
||||
parser.add_argument("--rate", type=float, default=100, help="offered requests per second, not a saturation benchmark")
|
||||
parser.add_argument("--clients", type=int, default=8)
|
||||
parser.add_argument("--workers", type=int, default=4)
|
||||
parser.add_argument("--origin-workers", type=int, default=2)
|
||||
parser.add_argument("--server-cpus")
|
||||
parser.add_argument("--origin-cpus")
|
||||
parser.add_argument("--toggle-seconds", type=int, default=120)
|
||||
parser.add_argument("--sample-seconds", type=int, default=5)
|
||||
parser.add_argument("--abort-every", type=int, default=101)
|
||||
parser.add_argument("--cooldown-seconds", type=int, default=15)
|
||||
parser.add_argument("--exploratory", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.duration < 1800 and not args.exploratory:
|
||||
parser.error("the soak gate requires >=1800 seconds; label short diagnostics with --exploratory")
|
||||
if args.rate <= 0 or not 1 <= args.clients <= 128 or args.toggle_seconds < 1 or args.sample_seconds < 1:
|
||||
parser.error("rate, sample/toggle intervals and clients must be positive; clients <=128")
|
||||
args.out = args.out.resolve()
|
||||
if args.out.exists() and any(args.out.iterdir()):
|
||||
parser.error("--out must be a new or empty evidence directory")
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
settings = {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()}
|
||||
write_json(args.out / "environment.json", {
|
||||
"kind": "actual RustProxy fixed-rate mixed-traffic resource soak", "simulation": False,
|
||||
"settings": settings, "platform": platform.platform(), "binary_sha256": sha256(args.binary),
|
||||
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"frontend_connection_policy": "A new HTTP/1.1 TCP connection for each workload request and probe",
|
||||
"controlled_close_interpretation": "Client closes after reading 128 bytes; the server may already have sent the full body. No server-cancellation count is inferred.",
|
||||
"traffic_analytics_observation_policy": "Global counters are read before traffic, periodically with status diagnostics, and after final drain. Deltas include cache-mode probes and cooldown, and are separate from HTTP errors.",
|
||||
"not_covered": ["24-hour soak", "TLS/H2/H3 soak", "multi-backend failures", "PHP-FPM",
|
||||
"real application write load", "origin DNS/TLS rotation", "production traffic",
|
||||
"long-lived frontend keepalive connections"]})
|
||||
totals = {"submitted": 0, "completed": 0, "passed": 0, "failed": 0, "controlled_client_closes": 0,
|
||||
"response_bytes": 0, "scheduler_late_events": 0, "peak_pending": 0}
|
||||
failure_examples, progress, toggles, counter_samples = [], [], [], []
|
||||
traffic_samples, traffic_delta = [], None
|
||||
cache_probes, cache_status_counts = [], {"proxy": Counter(), "static": Counter()}
|
||||
drain_snapshot, configuration_restored = None, False
|
||||
samples, resources = [], {}
|
||||
fatal, runtime_seconds = None, 0
|
||||
try:
|
||||
with Fixture(args.out / "fixture", workers=args.workers, server_cpus=args.server_cpus,
|
||||
origin_cpus=args.origin_cpus, origin_workers=args.origin_workers) as fixture:
|
||||
nginx = fixture.nginx(args.nginx, frontend=False)
|
||||
rust = fixture.rust(args.binary, args.label)
|
||||
rust.login()
|
||||
rust.wait_quiescent()
|
||||
expected = {name: (fixture.site / name).read_bytes() for name in PAYLOADS}
|
||||
config = copy.deepcopy(rust.base)
|
||||
config["cache"].update({"expose_status": True})
|
||||
config["web_services"][0]["cache_control"] = "public, max-age=60"
|
||||
rust.apply(config, "soak-initial")
|
||||
probe_cache_mode(rust, expected, False, "initial-off", args.out, cache_probes)
|
||||
traffic_before = traffic_analytics_snapshot(rust)
|
||||
traffic_samples.append({"phase": "before", "seconds": 0, "snapshot": traffic_before})
|
||||
write_json(args.out / "traffic-analytics.json", traffic_samples)
|
||||
monitor = Monitor({"server": rust.process.pid, "origin": nginx["origin"].pid}, args.sample_seconds)
|
||||
monitor.start()
|
||||
start = time.monotonic()
|
||||
deadline = start + args.duration
|
||||
next_toggle, next_snapshot, next_submit = start + args.toggle_seconds, start, start
|
||||
pending = set()
|
||||
|
||||
def consume(done):
|
||||
for future in done:
|
||||
result = future.result()
|
||||
totals["completed"] += 1
|
||||
totals["passed" if result["passed"] else "failed"] += 1
|
||||
totals["controlled_client_closes"] += int(result["intentional_abort"])
|
||||
totals["response_bytes"] += result.get("bytes", 0)
|
||||
cache_status = (result.get("cache") or "absent") if "status" in result else "no_response"
|
||||
cache_status_counts[result["source"]][cache_status] += 1
|
||||
if not result["passed"] and len(failure_examples) < 100:
|
||||
failure_examples.append(result)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=args.clients) as pool:
|
||||
while time.monotonic() < deadline:
|
||||
now = time.monotonic()
|
||||
done = {future for future in pending if future.done()}
|
||||
consume(done)
|
||||
pending -= done
|
||||
if now >= next_toggle:
|
||||
config["cache"]["enabled"] = not config["cache"]["enabled"]
|
||||
rust.apply(config, f"soak-toggle-{len(toggles) + 1}")
|
||||
toggles.append({"seconds": time.monotonic() - start, "cache_enabled": config["cache"]["enabled"]})
|
||||
write_json(args.out / "cache-toggles.json", toggles)
|
||||
probe_cache_mode(rust, expected, config["cache"]["enabled"],
|
||||
f"toggle-{len(toggles)}", args.out, cache_probes)
|
||||
next_toggle += args.toggle_seconds
|
||||
if now >= next_snapshot:
|
||||
status, headers, body = rust.admin("/api/status")
|
||||
if status != 200:
|
||||
raise RuntimeError(f"admin status became unavailable: HTTP {status}")
|
||||
counter_samples.append({"seconds": time.monotonic() - start,
|
||||
"cache_enabled": config["cache"]["enabled"],
|
||||
"counters": counter_values(json.loads(body))})
|
||||
traffic_now = traffic_analytics_snapshot(rust)
|
||||
traffic_delta = traffic_analytics_delta(traffic_before, traffic_now)
|
||||
traffic_samples.append({"phase": "during", "seconds": time.monotonic() - start,
|
||||
"snapshot": traffic_now, "delta_from_start": traffic_delta})
|
||||
row = {"seconds": time.monotonic() - start, **totals,
|
||||
"cache_enabled": config["cache"]["enabled"], "pending": len(pending),
|
||||
"observed_cache_status_counts": {source: dict(counts)
|
||||
for source, counts in cache_status_counts.items()}}
|
||||
progress.append(row)
|
||||
write_json(args.out / "progress.json", progress)
|
||||
write_json(args.out / "status-counters.json", counter_samples)
|
||||
write_json(args.out / "traffic-analytics.json", traffic_samples)
|
||||
write_json(args.out / "resources.json", list(monitor.samples))
|
||||
print(json.dumps(row), flush=True)
|
||||
next_snapshot = time.monotonic() + args.sample_seconds
|
||||
now = time.monotonic()
|
||||
if len(pending) >= args.clients * 2:
|
||||
done, pending = wait(pending, timeout=.1, return_when=FIRST_COMPLETED)
|
||||
consume(done)
|
||||
continue
|
||||
if now < next_submit:
|
||||
time.sleep(min(.02, next_submit - now))
|
||||
continue
|
||||
if now > next_submit + .25:
|
||||
totals["scheduler_late_events"] += 1
|
||||
next_submit = now
|
||||
totals["submitted"] += 1
|
||||
pending.add(pool.submit(fetch, totals["submitted"], rust, expected, args.abort_every))
|
||||
totals["peak_pending"] = max(totals["peak_pending"], len(pending))
|
||||
next_submit += 1 / args.rate
|
||||
done, pending = wait(pending, timeout=30)
|
||||
consume(done)
|
||||
if pending:
|
||||
raise RuntimeError("requests failed to settle during bounded soak drain")
|
||||
runtime_seconds = time.monotonic() - start
|
||||
rust.reset()
|
||||
configuration_restored = True
|
||||
cooldown_until = time.monotonic() + args.cooldown_seconds
|
||||
while time.monotonic() < cooldown_until:
|
||||
time.sleep(max(0, min(.5, cooldown_until - time.monotonic())))
|
||||
probe_cache_mode(rust, expected, False, "restored-off", args.out, cache_probes)
|
||||
for _ in range(40):
|
||||
drain_snapshot = backend_drain_snapshot(rust)
|
||||
if drain_snapshot["passed"]:
|
||||
break
|
||||
time.sleep(.05)
|
||||
write_json(args.out / "backend-drain.json", drain_snapshot)
|
||||
if not drain_snapshot["passed"]:
|
||||
raise RuntimeError("backend active exchanges did not drain to zero")
|
||||
traffic_after = traffic_analytics_snapshot(rust)
|
||||
traffic_delta = traffic_analytics_delta(traffic_before, traffic_after)
|
||||
traffic_samples.append({"phase": "after_drain", "seconds": time.monotonic() - start,
|
||||
"snapshot": traffic_after, "delta_from_start": traffic_delta})
|
||||
finally:
|
||||
monitor.stop()
|
||||
samples = monitor.samples
|
||||
resources = monitor.summary(totals["completed"])
|
||||
write_json(args.out / "resources.json", samples)
|
||||
write_json(args.out / "status-counters.json", counter_samples)
|
||||
write_json(args.out / "traffic-analytics.json", traffic_samples)
|
||||
write_json(args.out / "failure-examples.json", failure_examples)
|
||||
except Exception as exc:
|
||||
fatal = {"error": str(exc), "traceback": traceback.format_exc()}
|
||||
write_json(args.out / "failure.json", fatal)
|
||||
report = {"completed": fatal is None, "simulation": False, "exploratory": args.exploratory,
|
||||
"duration_seconds": runtime_seconds, "soak_duration_gate": runtime_seconds >= 1800,
|
||||
"totals": totals, "resources": resources, "memory_trend": memory_trend(samples) if samples else None,
|
||||
"cache_toggles": len(toggles), "errors": failure_examples,
|
||||
"traffic_analytics": {"observations": len(traffic_samples), "delta": traffic_delta,
|
||||
"includes_cache_mode_probes_and_cooldown": True},
|
||||
"cache_mode_probes": {"source_checks": len(cache_probes),
|
||||
"requests": sum(len(probe["responses"]) for probe in cache_probes),
|
||||
"passed": bool(cache_probes) and all(probe["passed"] for probe in cache_probes)},
|
||||
"observed_cache_status_counts": cache_status_counts,
|
||||
"configuration_restored": configuration_restored, "backend_drain": drain_snapshot,
|
||||
"actual_completed_rps": totals["completed"] / runtime_seconds if runtime_seconds else None,
|
||||
"passed": fatal is None and totals["failed"] == 0 and runtime_seconds >= args.duration}
|
||||
write_json(args.out / "report.json", report)
|
||||
seal_results(args.out)
|
||||
print(json.dumps(report), flush=True)
|
||||
return 0 if report["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Selected counters from existing authenticated APIs, without traffic records."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
def traffic_analytics_snapshot(rust, timeout=2):
|
||||
"""Read global analytics queue loss; never retain paths, IPs or user agents.
|
||||
|
||||
queue_dropped is process-global even when the API's vhost query is used.
|
||||
recorded_requests is asynchronous analytics work, not an HTTP success count.
|
||||
Call outside a timed load interval so this control-plane read is not charged
|
||||
to the request path being measured.
|
||||
"""
|
||||
status, _, body = rust.admin("/api/traffic", timeout=timeout)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"traffic analytics counter capture failed: HTTP {status}")
|
||||
payload = json.loads(body)
|
||||
if not isinstance(payload, dict) or payload.get("scope") != "*" or payload.get("since") != "process_start":
|
||||
raise RuntimeError("traffic analytics requires the global process-start scope")
|
||||
analytics = payload.get("analytics")
|
||||
if not isinstance(analytics, dict) or analytics.get("scope") != "*":
|
||||
raise RuntimeError("traffic analytics response lacks the global queue view")
|
||||
data = analytics.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("traffic analytics response lacks the recorded counters")
|
||||
values = {"queue_capacity": analytics.get("queue_capacity"),
|
||||
"queue_dropped": analytics.get("queue_dropped"),
|
||||
"recorded_requests": data.get("requests")}
|
||||
if any(type(value) is not int or value < 0 for value in values.values()) or not values["queue_capacity"]:
|
||||
raise RuntimeError("traffic analytics queue counters are absent or malformed")
|
||||
return {"endpoint": "/api/traffic", "scope": "*", "since": "process_start",
|
||||
"pid": rust.process.pid, "observed_monotonic": time.monotonic(), **values}
|
||||
|
||||
|
||||
def traffic_analytics_delta(before, after):
|
||||
"""Keep telemetry loss separate from failed HTTP requests and log rotation."""
|
||||
for key in ["pid", "endpoint", "scope", "since", "queue_capacity"]:
|
||||
if before[key] != after[key]:
|
||||
raise RuntimeError(f"traffic analytics identity changed during capture: {key}")
|
||||
result = {"queue_dropped_delta": after["queue_dropped"] - before["queue_dropped"],
|
||||
"recorded_requests_delta": after["recorded_requests"] - before["recorded_requests"],
|
||||
"interval_seconds": after["observed_monotonic"] - before["observed_monotonic"]}
|
||||
if any(value < 0 for value in result.values()):
|
||||
raise RuntimeError("traffic analytics counters or clock reset during capture")
|
||||
return result
|
||||
@@ -1,191 +0,0 @@
|
||||
"""Strict H2/TLS and H3/QUIC clients for local RustProxy acceptance tests.
|
||||
|
||||
The generated fixture certificate is explicitly trusted. Neither transport
|
||||
silently falls back to HTTP/1.1 or disables certificate verification.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
|
||||
import httpx
|
||||
from aioquic.asyncio import QuicConnectionProtocol, connect
|
||||
from aioquic.h3.connection import H3Connection, MessageError
|
||||
from aioquic.h3.events import DataReceived, HeadersReceived
|
||||
from aioquic.quic.configuration import QuicConfiguration
|
||||
from aioquic.quic.events import ConnectionTerminated, ProtocolNegotiated, StreamReset
|
||||
|
||||
|
||||
@dataclass
|
||||
class Response:
|
||||
status: int
|
||||
headers: dict[str, str]
|
||||
body: bytes
|
||||
protocol: str
|
||||
alpn: str | None
|
||||
stream_id: int | None = None
|
||||
|
||||
|
||||
class H2Client:
|
||||
def __init__(self, client: httpx.AsyncClient, port: int):
|
||||
self.client, self.port = client, port
|
||||
|
||||
async def request(self, method, path, headers=None, body=None):
|
||||
async def chunks():
|
||||
for offset in range(0, len(body), 16384):
|
||||
yield body[offset:offset + 16384]
|
||||
await asyncio.sleep(0)
|
||||
|
||||
response = await self.client.request(
|
||||
method, f"https://127.0.0.1:{self.port}{path}",
|
||||
headers=headers or {}, content=chunks() if body is not None else None,
|
||||
)
|
||||
network = response.extensions.get("network_stream")
|
||||
tls = network.get_extra_info("ssl_object") if network else None
|
||||
alpn = tls.selected_alpn_protocol() if tls else None
|
||||
if response.http_version != "HTTP/2" or alpn != "h2":
|
||||
raise AssertionError(f"H2 required, got {response.http_version}, ALPN={alpn!r}")
|
||||
return Response(response.status_code, dict(response.headers), response.content,
|
||||
"HTTP/2", alpn, response.extensions.get("stream_id"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pending:
|
||||
future: asyncio.Future
|
||||
status: int = 0
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
body: bytearray = field(default_factory=bytearray)
|
||||
|
||||
|
||||
class HeadAwareH3Connection(H3Connection):
|
||||
"""Supply request-method context missing from aioquic 1.7.0's length check.
|
||||
|
||||
RFC 9110 section 8.6 permits HEAD's Content-Length to describe a GET body.
|
||||
Still reject any actual HEAD DATA; keep aioquic's exact length check for all
|
||||
other requests. This changes only the test client, never the target server.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.head_streams = set()
|
||||
|
||||
def _check_content_length(self, stream):
|
||||
if stream.stream_id in self.head_streams:
|
||||
if stream.content_length:
|
||||
raise MessageError("HEAD response contained DATA")
|
||||
return
|
||||
super()._check_content_length(stream)
|
||||
|
||||
|
||||
def validate_head_adapter():
|
||||
"""Keep adapter checks separate from the real-server acceptance totals."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
connection = HeadAwareH3Connection.__new__(HeadAwareH3Connection)
|
||||
connection.head_streams = {0}
|
||||
for stream_id, declared, received, valid in [(0, 1040, 0, True), (0, 1040, 1, False),
|
||||
(4, 1040, 1040, True), (4, 1040, 0, False)]:
|
||||
stream = SimpleNamespace(stream_id=stream_id, expected_content_length=declared,
|
||||
content_length=received)
|
||||
try:
|
||||
connection._check_content_length(stream)
|
||||
accepted = True
|
||||
except MessageError:
|
||||
accepted = False
|
||||
assert accepted == valid, "H3 fixture HEAD adapter changed ordinary framing validation"
|
||||
return {"cases": 4, "passed": 4, "kind": "test-client self-test, not RustProxy coverage"}
|
||||
|
||||
|
||||
class H3Client(QuicConnectionProtocol):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.http = HeadAwareH3Connection(self._quic)
|
||||
self.pending: dict[int, Pending] = {}
|
||||
self.alpn = None
|
||||
self.authority = None
|
||||
|
||||
def quic_event_received(self, event):
|
||||
if isinstance(event, ProtocolNegotiated):
|
||||
self.alpn = event.alpn_protocol
|
||||
if isinstance(event, ConnectionTerminated):
|
||||
for item in self.pending.values():
|
||||
if not item.future.done():
|
||||
item.future.set_exception(ConnectionError(
|
||||
f"QUIC closed code={event.error_code}: {event.reason_phrase}"))
|
||||
if isinstance(event, StreamReset):
|
||||
item = self.pending.get(event.stream_id)
|
||||
if item is not None and not item.future.done():
|
||||
item.future.set_exception(ConnectionError(
|
||||
f"H3 stream reset code={event.error_code}"))
|
||||
for http_event in self.http.handle_event(event):
|
||||
if not isinstance(http_event, (HeadersReceived, DataReceived)):
|
||||
continue
|
||||
item = self.pending.get(http_event.stream_id)
|
||||
if item is None or item.future.done():
|
||||
continue
|
||||
if isinstance(http_event, HeadersReceived):
|
||||
for name, value in http_event.headers:
|
||||
if name == b":status":
|
||||
item.status = int(value)
|
||||
elif not name.startswith(b":"):
|
||||
item.headers[name.decode("ascii").lower()] = value.decode("latin-1")
|
||||
else:
|
||||
item.body.extend(http_event.data)
|
||||
if len(item.body) > 4 * 1024 * 1024:
|
||||
item.future.set_exception(ValueError("fixture response exceeded 4 MiB bound"))
|
||||
continue
|
||||
if http_event.stream_ended:
|
||||
item.future.set_result(Response(item.status, item.headers, bytes(item.body),
|
||||
"HTTP/3", self.alpn, http_event.stream_id))
|
||||
|
||||
async def request(self, method, path, headers=None, body=None):
|
||||
if self.alpn != "h3":
|
||||
raise AssertionError(f"H3 required, got ALPN={self.alpn!r}")
|
||||
stream_id = self._quic.get_next_available_stream_id()
|
||||
item = Pending(asyncio.get_running_loop().create_future())
|
||||
self.pending[stream_id] = item
|
||||
if method == "HEAD":
|
||||
self.http.head_streams.add(stream_id)
|
||||
fields = [(b":method", method.encode("ascii")), (b":scheme", b"https"),
|
||||
(b":authority", self.authority.encode("ascii")),
|
||||
(b":path", path.encode("ascii"))]
|
||||
fields += [(name.lower().encode("ascii"), value.encode("latin-1"))
|
||||
for name, value in (headers or {}).items()]
|
||||
try:
|
||||
self.http.send_headers(stream_id, fields, end_stream=body is None)
|
||||
self.transmit()
|
||||
if body is not None:
|
||||
if not body:
|
||||
self.http.send_data(stream_id, b"", end_stream=True)
|
||||
self.transmit()
|
||||
for offset in range(0, len(body), 16384):
|
||||
end = min(offset + 16384, len(body))
|
||||
self.http.send_data(stream_id, body[offset:end], end_stream=end == len(body))
|
||||
self.transmit()
|
||||
await asyncio.sleep(0)
|
||||
return await asyncio.wait_for(item.future, timeout=10)
|
||||
finally:
|
||||
self.pending.pop(stream_id, None)
|
||||
self.http.head_streams.discard(stream_id)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def client(protocol: str, port: int, certificate: Path):
|
||||
if protocol == "h2":
|
||||
context = ssl.create_default_context(cafile=str(certificate))
|
||||
async with httpx.AsyncClient(http2=True, verify=context, trust_env=False,
|
||||
timeout=10, limits=httpx.Limits(max_connections=1)) as session:
|
||||
yield H2Client(session, port)
|
||||
elif protocol == "h3":
|
||||
config = QuicConfiguration(is_client=True, alpn_protocols=["h3"],
|
||||
server_name="localhost", idle_timeout=10)
|
||||
config.verify_mode = ssl.CERT_REQUIRED
|
||||
config.load_verify_locations(cafile=str(certificate))
|
||||
async with connect("127.0.0.1", port, configuration=config,
|
||||
create_protocol=H3Client) as session:
|
||||
session.authority = f"127.0.0.1:{port}"
|
||||
yield session
|
||||
else:
|
||||
raise ValueError(f"unsupported fixture protocol {protocol!r}")
|
||||
@@ -1,281 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real, bounded RustProxy H2/TLS, H3/QUIC and H1 WebSocket acceptance.
|
||||
|
||||
Install Python dependencies aioquic, httpx[http2] and websockets. Example:
|
||||
python3 scripts/performance/transport_regression.py --binary before=/abs/binary \
|
||||
--binary after=/abs/binary --out /abs/results --python-deps /abs/optional/pydeps
|
||||
|
||||
Only fresh temporary databases and loopback listeners are used. Certificates
|
||||
are generated by RustProxy and verified by clients; no protocol fallback is used.
|
||||
This is an interoperability/functional suite, not a throughput benchmark.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from common import Fixture, seal_results, sha256, write_json
|
||||
from peer import Peer
|
||||
|
||||
|
||||
def response_details(response):
|
||||
return {"status": response.status, "protocol": response.protocol,
|
||||
"alpn": response.alpn, "stream_id": response.stream_id,
|
||||
"bytes": len(response.body), "sha256": hashlib.sha256(response.body).hexdigest(),
|
||||
"cache": response.headers.get("x-cache"),
|
||||
"cache_reason": response.headers.get("x-cache-reason")}
|
||||
|
||||
|
||||
def tls_port(fixture):
|
||||
while True:
|
||||
port = fixture.port()
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp:
|
||||
try:
|
||||
udp.bind(("127.0.0.1", port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def transport_config(rust, reverse_tls, static_tls, websocket_port, websocket_origin):
|
||||
config = copy.deepcopy(rust.base)
|
||||
for source, port in [("reverse", reverse_tls), ("static", static_tls)]:
|
||||
identifier = f"{source}-tls"
|
||||
config["listeners"]["endpoints"].append({
|
||||
"id": identifier, "name": identifier, "scope": "internal", "enabled": True,
|
||||
"binding": {"address": f"127.0.0.1:{port}", "tls": True, "http2": True,
|
||||
"http3": {"max_concurrent_connections": 16,
|
||||
"max_concurrent_bidistream": 16,
|
||||
"max_concurrent_unistream": 16, "max_idle_timeout": 10}}})
|
||||
vhost = next(v for v in config["vhosts"] if v["id"] == source)
|
||||
vhost["frontend"]["listener_ids"].append(identifier)
|
||||
vhost["http2_enabled"] = True
|
||||
vhost["http3_enabled"] = True
|
||||
config["listeners"]["endpoints"].append({
|
||||
"id": "websocket", "name": "WebSocket", "scope": "internal", "enabled": True,
|
||||
"binding": {"address": f"127.0.0.1:{websocket_port}", "tls": False,
|
||||
"http2": False, "http3": None}})
|
||||
config["vhosts"].append({
|
||||
"id": "websocket", "frontend": {"hosts": ["127.0.0.1", "localhost"],
|
||||
"listener_ids": ["websocket"]},
|
||||
"websockets": True,
|
||||
"backends": [{"id": "echo", "url": f"http://127.0.0.1:{websocket_origin}",
|
||||
"allow_private": True}],
|
||||
"load_balancing": {"health_check": {"mode": "off"}}, "routes": []})
|
||||
config["cache"].update({"enabled": False, "expose_status": True, "expose_hits": True,
|
||||
"expose_cache_status": True, "expose_only_hits": False})
|
||||
config["compression"] = {"gzip": False, "br": False, "zstd": False, "deflate": False}
|
||||
config["web_services"][0]["cache_control"] = "public, max-age=60"
|
||||
return config
|
||||
|
||||
|
||||
async def http_cases(check, session, protocol, source, cache, fixture):
|
||||
prefix = f"{protocol}.{source}.cache_{'on' if cache else 'off'}"
|
||||
expected = (fixture.site / "small.html").read_bytes()
|
||||
path = f"/transport-{protocol}.html"
|
||||
first = await session.request("GET", path)
|
||||
check(prefix + ".get", first.status == 200 and first.body == expected,
|
||||
**response_details(first))
|
||||
second = await session.request("GET", path)
|
||||
check(prefix + ".cache_delivery", second.status == 200 and second.body == expected
|
||||
and ((first.headers.get("x-cache") == "MISS" and second.headers.get("x-cache") == "HIT")
|
||||
if cache else second.headers.get("x-cache") not in ("MISS", "HIT")),
|
||||
first_cache=first.headers.get("x-cache"), **response_details(second))
|
||||
head = await session.request("HEAD", path)
|
||||
check(prefix + ".head", head.status == 200 and head.body == b""
|
||||
and head.headers.get("content-length") == str(len(expected)), **response_details(head))
|
||||
etag = second.headers.get("etag")
|
||||
check(prefix + ".validator", bool(etag), etag=etag)
|
||||
ranged = await session.request("GET", path, {"range": "bytes=11-40", "if-range": etag or ""})
|
||||
check(prefix + ".range", ranged.status == 206 and ranged.body == expected[11:41]
|
||||
and ranged.headers.get("content-range") == f"bytes 11-40/{len(expected)}",
|
||||
**response_details(ranged))
|
||||
mismatch = await session.request("GET", path, {"range": "bytes=11-40", "if-range": '"different"'})
|
||||
check(prefix + ".if_range_mismatch", mismatch.status == 200 and mismatch.body == expected,
|
||||
**response_details(mismatch))
|
||||
conditional = await session.request("GET", path, {"if-none-match": etag or ""})
|
||||
check(prefix + ".conditional_304", conditional.status == 304 and conditional.body == b"",
|
||||
**response_details(conditional))
|
||||
invalid = await session.request("GET", path, {"range": "bytes=999999-"})
|
||||
check(prefix + ".range_416", invalid.status == 416 and invalid.body == b""
|
||||
and invalid.headers.get("content-range") == f"bytes */{len(expected)}",
|
||||
**response_details(invalid))
|
||||
for name in ["large.bin", "huge.bin"]:
|
||||
expected_file = (fixture.site / name).read_bytes()
|
||||
response = await session.request("GET", "/" + name)
|
||||
check(prefix + ".get." + name, response.status == 200 and response.body == expected_file,
|
||||
**response_details(response))
|
||||
parallel = await asyncio.gather(*(session.request("GET", "/large.bin") for _ in range(4)))
|
||||
expected_large = (fixture.site / "large.bin").read_bytes()
|
||||
check(prefix + ".multiplex_four_streams", all(r.status == 200 and r.body == expected_large for r in parallel)
|
||||
and len({r.stream_id for r in parallel}) == 4,
|
||||
stream_ids=[r.stream_id for r in parallel], response_bytes=[len(r.body) for r in parallel])
|
||||
if source == "reverse":
|
||||
for payload in [b"", (bytes(range(256)) * 512) + b"streamed-end"]:
|
||||
response = await session.request("POST", "/echo?transport=preserved",
|
||||
{"content-type": "application/octet-stream"}, payload)
|
||||
data = json.loads(response.body) if response.status == 200 else {}
|
||||
check(prefix + f".post_stream_{len(payload)}", response.status == 200
|
||||
and data.get("method") == "POST" and data.get("body_bytes") == len(payload)
|
||||
and data.get("body_sha256") == hashlib.sha256(payload).hexdigest()
|
||||
and data.get("path") == "/echo?transport=preserved",
|
||||
upload_bytes=len(payload), **response_details(response))
|
||||
else:
|
||||
response = await session.request("POST", path, body=b"static-method-gate")
|
||||
check(prefix + ".post_static_method_gate", response.status == 405, **response_details(response))
|
||||
|
||||
|
||||
async def websocket_cases(check, port, cache):
|
||||
import websockets
|
||||
|
||||
prefix = f"h1.websocket.cache_{'on' if cache else 'off'}"
|
||||
async with websockets.connect(f"ws://127.0.0.1:{port}/echo", open_timeout=5,
|
||||
close_timeout=3, ping_interval=None, compression=None) as session:
|
||||
text = "RustProxy echo: Zażółć gęślą jaźń"
|
||||
await session.send(text)
|
||||
returned = await asyncio.wait_for(session.recv(), timeout=5)
|
||||
check(prefix + ".text_echo", returned == text)
|
||||
binary = bytes(range(256)) * 256
|
||||
await session.send(binary)
|
||||
returned = await asyncio.wait_for(session.recv(), timeout=5)
|
||||
check(prefix + ".binary_echo", returned == binary, bytes=len(returned))
|
||||
pong = await session.ping(b"rustproxy-transport")
|
||||
await asyncio.wait_for(pong, timeout=5)
|
||||
check(prefix + ".ping_pong", True)
|
||||
await session.close(code=1000, reason="fixture complete")
|
||||
check(prefix + ".normal_close", session.close_code == 1000, close_code=session.close_code)
|
||||
|
||||
|
||||
async def run_variant(binary, label, output, workers):
|
||||
import websockets
|
||||
from transport_clients import client
|
||||
|
||||
cases = []
|
||||
started = time.monotonic()
|
||||
|
||||
def check(case, passed, **details):
|
||||
cases.append({"case": case, "passed": bool(passed), **details})
|
||||
print(f"{label} {'PASS' if passed else 'FAIL'} {case}", flush=True)
|
||||
write_json(output / "checks.json", cases)
|
||||
|
||||
try:
|
||||
with Fixture(output, workers=workers) as fixture:
|
||||
plain = (fixture.site / "small.html").read_bytes()
|
||||
for name in ["transport-h2.html", "transport-h3.html", "transport-shared.html"]:
|
||||
(fixture.site / name).write_bytes(plain)
|
||||
peer = Peer(fixture.origin_port, fixture.site)
|
||||
websocket_origin, websocket_port = fixture.port(), fixture.port()
|
||||
|
||||
async def echo(session, _path=None):
|
||||
async for message in session:
|
||||
await session.send(message)
|
||||
|
||||
try:
|
||||
async with websockets.serve(echo, "127.0.0.1", websocket_origin,
|
||||
ping_interval=None, compression=None, close_timeout=3):
|
||||
rust = fixture.rust(binary, label)
|
||||
reverse_tls, static_tls = tls_port(fixture), tls_port(fixture)
|
||||
config = transport_config(rust, reverse_tls, static_tls, websocket_port, websocket_origin)
|
||||
rust.apply(config, "transport-listeners")
|
||||
fixture.stop(rust.process)
|
||||
rust.start("transport")
|
||||
certificate = rust.data / "certs" / "default.pem"
|
||||
write_json(output / "transport.json", {
|
||||
"certificate_sha256": sha256(certificate), "certificate_verification": True,
|
||||
"h2_alpn_required": "h2", "h3_alpn_required": "h3",
|
||||
"reverse_tls_tcp_udp": reverse_tls, "static_tls_tcp_udp": static_tls,
|
||||
"websocket_h1_tcp": websocket_port, "fallback_allowed": False})
|
||||
for cache in [False, True]:
|
||||
if cache:
|
||||
config["cache"]["enabled"] = True
|
||||
rust.apply(config, "transport-cache-on")
|
||||
for protocol in ["h2", "h3"]:
|
||||
for source, port in [("reverse", reverse_tls), ("static", static_tls)]:
|
||||
group = f"{protocol}.{source}.cache_{'on' if cache else 'off'}"
|
||||
try:
|
||||
async with client(protocol, port, certificate) as session:
|
||||
await http_cases(check, session, protocol, source, cache, fixture)
|
||||
except Exception as exc:
|
||||
check(group + ".runner", False, error=str(exc), traceback=traceback.format_exc())
|
||||
try:
|
||||
await websocket_cases(check, websocket_port, cache)
|
||||
except Exception as exc:
|
||||
check(f"h1.websocket.cache_{'on' if cache else 'off'}.runner", False,
|
||||
error=str(exc), traceback=traceback.format_exc())
|
||||
for source, port in [("reverse", reverse_tls), ("static", static_tls)]:
|
||||
try:
|
||||
async with client("h2", port, certificate) as h2:
|
||||
warm = await h2.request("GET", "/transport-shared.html")
|
||||
async with client("h3", port, certificate) as h3:
|
||||
hit = await h3.request("GET", "/transport-shared.html")
|
||||
check(f"cache_cross_protocol.{source}.h2_to_h3",
|
||||
warm.status == 200 and hit.status == 200 and hit.body == plain
|
||||
and hit.headers.get("x-cache") == "HIT",
|
||||
first_cache=warm.headers.get("x-cache"), **response_details(hit))
|
||||
except Exception as exc:
|
||||
check(f"cache_cross_protocol.{source}.runner", False,
|
||||
error=str(exc), traceback=traceback.format_exc())
|
||||
check("runtime.still_alive", rust.process.poll() is None)
|
||||
finally:
|
||||
peer.close()
|
||||
except Exception as exc:
|
||||
check("runtime.runner", False, error=str(exc), traceback=traceback.format_exc())
|
||||
report = {"label": label, "simulation": False, "seconds": time.monotonic() - started,
|
||||
"passed": sum(case["passed"] for case in cases),
|
||||
"failed": sum(not case["passed"] for case in cases), "cases": cases}
|
||||
write_json(output / "report.json", report)
|
||||
print(f"{label}: {report['passed']} passed, {report['failed']} failed", flush=True)
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
parser.add_argument("--workers", type=int, default=4)
|
||||
parser.add_argument("--python-deps", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.python_deps:
|
||||
sys.path.insert(0, str(args.python_deps.resolve()))
|
||||
variants = {}
|
||||
for value in args.binary:
|
||||
label, separator, path = value.partition("=")
|
||||
if not separator or not label.replace("-", "").replace("_", "").isalnum() or label in variants:
|
||||
parser.error("--binary must be unique SIMPLE_LABEL=/ABS/PATH")
|
||||
variants[label] = Path(path).resolve()
|
||||
args.out = args.out.resolve()
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
from transport_clients import validate_head_adapter
|
||||
write_json(args.out / "environment.json", {
|
||||
"kind": "real RustProxy transport acceptance", "simulation": False,
|
||||
"python": sys.version, "platform": platform.platform(),
|
||||
"dependencies": {name: importlib.metadata.version(name) for name in ["aioquic", "httpx", "h2", "websockets"]},
|
||||
"aioquic_head_adapter": validate_head_adapter(),
|
||||
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"not_covered": ["production certificates", "TLS upstreams", "H3 upstreams", "H2/H3 WebSocket extended CONNECT",
|
||||
"network loss or migration", "24-hour soak", "real-browser navigation"]})
|
||||
reports = {}
|
||||
for label, binary in variants.items():
|
||||
reports[label] = asyncio.run(run_variant(binary, label, args.out / label, args.workers))
|
||||
write_json(args.out / "comparison.json", {
|
||||
"variants": {label: {key: report[key] for key in ["passed", "failed", "seconds"]}
|
||||
for label, report in reports.items()},
|
||||
"cases": [{"case": case, **{label: next((row["passed"] for row in report["cases"] if row["case"] == case), "not_run")
|
||||
for label, report in reports.items()}}
|
||||
for case in sorted({row["case"] for report in reports.values() for row in report["cases"]})]})
|
||||
seal_results(args.out)
|
||||
return 1 if any(report["failed"] for report in reports.values()) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,14 +0,0 @@
|
||||
-- No response hook: benchmarks must not be limited by Lua body processing.
|
||||
-- Known payload/status/headers are verified separately before measurement.
|
||||
done = function(summary, latency, requests)
|
||||
io.write("RUSTPROXY_BENCH_JSON {")
|
||||
io.write(string.format('"requests":%d,"duration_us":%d,"bytes":%d,',
|
||||
summary.requests, summary.duration, summary.bytes))
|
||||
io.write(string.format('"p50_us":%.3f,"p95_us":%.3f,"p99_us":%.3f,',
|
||||
latency:percentile(50), latency:percentile(95), latency:percentile(99)))
|
||||
io.write(string.format('"latency_mean_us":%.3f,"latency_max_us":%.3f,', latency.mean, latency.max))
|
||||
io.write(string.format('"errors_connect":%d,"errors_read":%d,"errors_write":%d,"errors_timeout":%d,"errors_status":%d',
|
||||
summary.errors.connect, summary.errors.read, summary.errors.write,
|
||||
summary.errors.timeout, summary.errors.status))
|
||||
io.write("}\n")
|
||||
end
|
||||
@@ -74,7 +74,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
flat = module.parent.with_suffix(".rs")
|
||||
if flat.is_file():
|
||||
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
|
||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
|
||||
if not (root / path).is_file():
|
||||
raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}")
|
||||
# The Reader uses compile-time include_str! for its bundled Markdown. A
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic oracle for HTTP Age / local freshness-cap algebra.
|
||||
No binary execution, no claim of measured throughput. Run with Python 3 only.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def old_freshness(max_age, header_age, apparent_age, response_delay, local_limit):
|
||||
age = max(header_age, apparent_age) + response_delay
|
||||
return max(0, min(max_age, local_limit) - age)
|
||||
|
||||
|
||||
def new_freshness(max_age, header_age, apparent_age, response_delay, local_limit):
|
||||
# RFC 9111 corrected_initial_age = max(apparent_age, age_value + response_delay)
|
||||
age = max(apparent_age, header_age + response_delay)
|
||||
lifetime = min(max_age, age + local_limit)
|
||||
return max(0, lifetime - age)
|
||||
|
||||
|
||||
def run():
|
||||
cases = [
|
||||
('user_cdn_example', 2_592_000, 1_553_483, 0, 0, 86_400),
|
||||
('user_cdn_example_7d', 2_592_000, 1_553_483, 0, 0, 604_800),
|
||||
('origin_nearly_stale', 2_592_000, 2_591_990, 0, 0, 86_400),
|
||||
('origin_stale', 2_592_000, 2_592_010, 0, 0, 86_400),
|
||||
('normal_small', 3_600, 0, 0, 0, 86_400),
|
||||
('date_is_older', 3_600, 10, 600, 2, 86_400),
|
||||
('rfc_delay_case', 3_600, 3, 10, 2, 86_400),
|
||||
]
|
||||
report = {'reference': 'RFC 9111 4.2.3 (Age) and 4.2.1 (freshness lifetime)',
|
||||
'kind': 'algebra simulation; not RustProxy RPS benchmark',
|
||||
'scenarios': [], 'randomized_checks': 0, 'false_miss_cases_old': 0}
|
||||
for name, max_age, age, apparent, delay, local in cases:
|
||||
old = old_freshness(max_age, age, apparent, delay, local)
|
||||
new = new_freshness(max_age, age, apparent, delay, local)
|
||||
report['scenarios'].append({'scenario': name, 'origin_max_age_seconds': max_age,
|
||||
'received_age_seconds': age, 'apparent_age_seconds': apparent,
|
||||
'response_delay_seconds': delay, 'local_max_ttl_seconds': local,
|
||||
'old_freshness_seconds': old, 'new_freshness_seconds': new})
|
||||
rng = random.Random(0x3400)
|
||||
for _ in range(100_000):
|
||||
origin = rng.randrange(0, 1 << 24)
|
||||
header_age = rng.randrange(0, 1 << 24)
|
||||
apparent = rng.randrange(0, 1 << 24)
|
||||
delay = rng.randrange(0, 300)
|
||||
local = rng.randrange(0, 604_801)
|
||||
corrected_age = max(apparent, header_age + delay)
|
||||
new = new_freshness(origin, header_age, apparent, delay, local)
|
||||
expected = min(max(0, origin - corrected_age), local)
|
||||
assert new == expected, (origin, header_age, apparent, delay, local, new, expected)
|
||||
assert new <= local and new <= max(0, origin - corrected_age)
|
||||
old = old_freshness(origin, header_age, apparent, delay, local)
|
||||
report['false_miss_cases_old'] += old == 0 < new
|
||||
report['randomized_checks'] += 1
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
report = run()
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
@@ -1,212 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Loopback integration regression: transformed Age, HOT/COLD, diagnostics, isolation.
|
||||
|
||||
Run after compiling:
|
||||
python3 scripts/tests/cache_age_transform_034_live.py --binary target/release/rust-proxy
|
||||
To reproduce the original regression with the user-supplied 0.34.0 binary:
|
||||
python3 scripts/tests/cache_age_transform_034_live.py --binary /path/to/old/rust-proxy --expect-old-age-bug
|
||||
No internet and no user data; test config/data/passwords are in a disposable temporary dir.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'performance'))
|
||||
from common import Fixture, http, sha256, write_json
|
||||
|
||||
BODY = b'/* test cache */\nbody { color: red; padding: 0px; }\n'
|
||||
# Fixed sample from Brotli encoder, embedded to avoid Python dependency on brotli.
|
||||
BROTLI_BODY = b"\x1b3\x00\xe8\x1d\x07\xee\xbe\r\xa2\xbc3'\x8cP\xe9\xad\xa0\xf2\xb2\xa7\xe4\xfa\x12\x84\xac\x0c\x1cr\xc0\xfe\xc0>\x0fR*a\x9c\x9e2\xe8\x08\xcb\x1cP\x881\xac\xdd\x88s5(\x9d\x01"
|
||||
COUNTS = collections.Counter()
|
||||
|
||||
class Origin(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split('?', 1)[0]
|
||||
if path not in {'/aged.css', '/normal.css', '/private.css', '/no-store.css', '/other.css', '/br.css', '/aged-br.css'}:
|
||||
self.send_error(404)
|
||||
return
|
||||
COUNTS[path] += 1
|
||||
self.send_response(200)
|
||||
if path in ('/aged.css', '/aged-br.css'):
|
||||
policy = 'public, max-age=2592000'
|
||||
elif path == '/private.css':
|
||||
policy = 'private, max-age=2592000'
|
||||
elif path == '/no-store.css':
|
||||
policy = 'no-store'
|
||||
else:
|
||||
policy = 'public, max-age=3600'
|
||||
self.send_header('Cache-Control', policy)
|
||||
self.send_header('Content-Type', 'text/css')
|
||||
payload = BROTLI_BODY if path in ('/br.css', '/aged-br.css') else BODY
|
||||
self.send_header('Content-Length', str(len(payload)))
|
||||
if path in ('/br.css', '/aged-br.css'):
|
||||
self.send_header('Content-Encoding', 'br')
|
||||
self.send_header('ETag', '"origin-css"')
|
||||
self.send_header('Last-Modified', 'Thu, 07 Jan 2016 20:04:59 GMT')
|
||||
self.send_header('Vary', 'Accept-Encoding')
|
||||
if path in ('/aged.css', '/aged-br.css'):
|
||||
self.send_header('Age', '1553483')
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
|
||||
def request(port, path, encoding='identity'):
|
||||
status, headers, body = http(port, path, headers={'Accept-Encoding': encoding})
|
||||
return {'status': status, 'x_cache': headers.get('x-cache'),
|
||||
'x_cache_hits': headers.get('x-cache-hits'), 'x_cache_resident_age': headers.get('x-cache-resident-age'),
|
||||
'cache_status': headers.get('cache-status'), 'age': headers.get('age'),
|
||||
'body': body.decode('utf-8', 'replace'), 'content_length': headers.get('content-length'),
|
||||
'etag': headers.get('etag'), 'encoding': headers.get('content-encoding'), 'origin_count': COUNTS[path.split('?', 1)[0]]}
|
||||
|
||||
|
||||
def dash(rust):
|
||||
status, _, body = rust.admin('/api/cache?limit=200')
|
||||
assert status == 200, ('cache dashboard', status, body[:200])
|
||||
data = json.loads(body)['dashboard']
|
||||
return {'objects': [{k: row.get(k) for k in ('vhost','path','tier','transformed',
|
||||
'hits','age_seconds','resident_age_seconds','remaining_ttl_seconds')} for row in data['objects']],
|
||||
'domains': data['domains']}
|
||||
|
||||
|
||||
def find(d, path):
|
||||
matches = [item for item in d['objects'] if item['path'] == path and item['vhost'] == 'reverse']
|
||||
assert len(matches) == 1, (path, matches)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def run(binary: Path, output: Path, expect_old_age_bug: bool):
|
||||
outcome = {'binary_sha256': sha256(binary), 'expect_old_age_bug': expect_old_age_bug,
|
||||
'tests': [], 'passed': False}
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
with Fixture(output, workers=2) as fixture:
|
||||
origin = ThreadingHTTPServer(('127.0.0.1', fixture.origin_port), Origin)
|
||||
thread = threading.Thread(target=origin.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
rust = fixture.rust(binary, 'cache-age-034')
|
||||
rust.login()
|
||||
cfg = copy.deepcopy(rust.base)
|
||||
cfg['cache'] = {'enabled': True, 'max_ttl_seconds': 86_400,
|
||||
'expose_status': True, 'expose_hits': True,
|
||||
'expose_cache_status': True, 'expose_resident_age': True}
|
||||
cfg['transform'] = {'enabled': True, 'css': True}
|
||||
for vhost in cfg['vhosts']:
|
||||
vhost['transform'] = {'enabled': True, 'css': True}
|
||||
rust.apply(cfg, 'fresh-cache-transform')
|
||||
for path in ('/normal.css', '/aged.css', '/br.css', '/aged-br.css', '/private.css', '/no-store.css'):
|
||||
before = COUNTS[path]
|
||||
encoding = 'br' if path in ('/br.css', '/aged-br.css') else 'identity'
|
||||
first, second = request(rust.reverse_port, path, encoding), request(rust.reverse_port, path, encoding)
|
||||
row = {'path': path, 'first': first, 'second': second,
|
||||
'origin_delta': COUNTS[path] - before}
|
||||
outcome['tests'].append(row)
|
||||
if path in ('/aged.css', '/aged-br.css') and expect_old_age_bug:
|
||||
assert first['x_cache'] == second['x_cache'] == 'MISS', row
|
||||
assert row['origin_delta'] == 2, row
|
||||
elif path in ('/normal.css', '/aged.css', '/br.css', '/aged-br.css'):
|
||||
assert first['x_cache'] == 'MISS' and second['x_cache'] == 'HIT', row
|
||||
assert row['origin_delta'] == 1, row
|
||||
assert second['cache_status'] and 'transformed' in second['cache_status'], row
|
||||
assert first['etag'] == second['etag'] and first['body'] == second['body'], row
|
||||
else:
|
||||
assert first['x_cache'] == second['x_cache'] == 'MISS', row
|
||||
print(path, first['x_cache'], second['x_cache'], 'origin+', row['origin_delta'], flush=True)
|
||||
snapshot = dash(rust)
|
||||
outcome['tests'].append({'dashboard': snapshot})
|
||||
assert find(snapshot, '/normal.css')['transformed'], snapshot
|
||||
aged = find(snapshot, '/aged.css')
|
||||
if expect_old_age_bug:
|
||||
assert aged['remaining_ttl_seconds'] == 0, aged
|
||||
else:
|
||||
assert aged['transformed'] and aged['remaining_ttl_seconds'] >= 86_000, aged
|
||||
assert aged['age_seconds'] >= 1_553_483, aged
|
||||
assert aged['resident_age_seconds'] < 10, aged
|
||||
aged_br = find(snapshot, '/aged-br.css')
|
||||
assert aged_br['transformed'] and aged_br['remaining_ttl_seconds'] >= 86_000, aged_br
|
||||
|
||||
# Query-string bypass is an explicit administrator rule. Even a
|
||||
# transformed CSS with public max-age stays BYPASS while it is ON,
|
||||
# then the *same* URL must become MISS -> HIT after disabling it.
|
||||
# This is not a transform regression: it is a request policy.
|
||||
cfg['cache']['bypass_query'] = True
|
||||
rust.apply(cfg, 'query-bypass-enabled')
|
||||
query_path = '/normal.css?v=transformed-public'
|
||||
before = COUNTS['/normal.css']
|
||||
q_bypass = [request(rust.reverse_port, query_path) for _ in range(2)]
|
||||
assert [row['x_cache'] for row in q_bypass] == ['BYPASS', 'BYPASS'], q_bypass
|
||||
assert COUNTS['/normal.css'] - before == 2, q_bypass
|
||||
cfg['cache']['bypass_query'] = False
|
||||
rust.apply(cfg, 'query-bypass-disabled')
|
||||
q_cached = [request(rust.reverse_port, query_path) for _ in range(2)]
|
||||
assert [row['x_cache'] for row in q_cached] == ['MISS', 'HIT'], q_cached
|
||||
assert q_cached[1]['cache_status'] and 'transformed' in q_cached[1]['cache_status']
|
||||
assert COUNTS['/normal.css'] - before == 3, q_cached
|
||||
outcome['tests'].append({'query_bypass_policy': {'bypass': q_bypass, 'cached': q_cached}})
|
||||
|
||||
# Operational LB tuning must not invalidate a cached representation.
|
||||
before = COUNTS['/normal.css']
|
||||
cfg['vhosts'][0]['load_balancing']['keepalive'] = {'max_idle_per_host': 16}
|
||||
rust.apply(cfg, 'only-backend-keepalive-changed')
|
||||
normal = request(rust.reverse_port, '/normal.css')
|
||||
assert normal['x_cache'] == 'HIT' and COUNTS['/normal.css'] == before
|
||||
outcome['tests'].append({'keepalive_update_without_purge': normal})
|
||||
|
||||
# Diagnostics can be intentionally hidden on MISS and remain visible on HIT.
|
||||
cfg['cache']['expose_only_hits'] = True
|
||||
rust.apply(cfg, 'only-show-hit-headers')
|
||||
before = COUNTS['/other.css']
|
||||
first, second = request(rust.reverse_port, '/other.css'), request(rust.reverse_port, '/other.css')
|
||||
assert first['x_cache'] is None and second['x_cache'] == 'HIT'
|
||||
assert COUNTS['/other.css'] - before == 1
|
||||
outcome['tests'].append({'only_hits': [first, second]})
|
||||
|
||||
if not expect_old_age_bug:
|
||||
# Cold path on a 1-entry HOT tier: demotion and on-disk reuse.
|
||||
cfg['cache'].update({'max_entries': 1, 'cold_enabled': True, 'cold_on_lru': True,
|
||||
'cold_path': 'cache/age-regression-cold'})
|
||||
cfg['cache'].pop('expose_only_hits', None)
|
||||
rust.apply(cfg, 'cold-enabled-one-hot-entry')
|
||||
assert request(rust.reverse_port, '/aged.css')['x_cache'] == 'MISS'
|
||||
assert request(rust.reverse_port, '/aged.css')['x_cache'] == 'HIT'
|
||||
assert request(rust.reverse_port, '/other.css')['x_cache'] == 'MISS'
|
||||
deadline = time.monotonic() + 4
|
||||
while True:
|
||||
snapshot = dash(rust)
|
||||
matches = [x for x in snapshot['objects'] if x['path'] == '/aged.css' and x['tier'] == 'cold']
|
||||
if matches:
|
||||
assert matches[0]['remaining_ttl_seconds'] >= 86_000, matches
|
||||
break
|
||||
if time.monotonic() > deadline:
|
||||
raise AssertionError('aged transformed CSS never demoted to COLD: ' + str(snapshot))
|
||||
time.sleep(.05)
|
||||
cold_hit = request(rust.reverse_port, '/aged.css')
|
||||
assert cold_hit['x_cache'] == 'HIT' and 'transformed' in cold_hit['cache_status'], cold_hit
|
||||
outcome['tests'].append({'cold': {'row': matches[0], 'hit': cold_hit}})
|
||||
outcome['passed'] = True
|
||||
finally:
|
||||
origin.shutdown()
|
||||
origin.server_close()
|
||||
write_json(output / 'cache-age-transform-result.json', outcome)
|
||||
return outcome
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, default=Path('/tmp/rustproxy-cache-age-034-live'))
|
||||
parser.add_argument('--expect-old-age-bug', action='store_true')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
report = run(args.binary.resolve(), args.output.resolve(), args.expect_old_age_bug)
|
||||
except Exception as error:
|
||||
print('FAIL', type(error).__name__, str(error)[:1000], file=sys.stderr)
|
||||
raise
|
||||
print('PASS', len(report['tests']), 'scenarios; see', args.output / 'cache-age-transform-result.json')
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-only hot-cache safety guards; NOT a Rust compile, concurrency test or RPS result."""
|
||||
from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
read = lambda path: (ROOT / path).read_text()
|
||||
hot = read("src/cache/lookup/hot.rs")
|
||||
entry = read("src/cache/model/cache.rs")
|
||||
model = read("src/cache/model/maintenance.rs")
|
||||
inv = read("src/cache/invalidation/invalidate.rs")
|
||||
dash = read("src/cache/dashboard.rs")
|
||||
assert 'headers: Arc<HeaderMap>' in entry
|
||||
assert 'Arc::clone(&entry.headers)' in hot
|
||||
assert 'drop(store);' in hot
|
||||
assert hot.index('drop(store);\n\n let mut response') < hot.index('headers.as_ref().clone()')
|
||||
assert 'self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes)' in hot
|
||||
assert 'store.lru.remove(&old_sequence)' in hot and 'store.lru.insert(sequence, storage_key)' in hot
|
||||
assert '.sweep()' not in hot
|
||||
assert 'fn sweep(' not in entry
|
||||
stats = read('src/cache/stats.rs')
|
||||
assert 'fn saved_body_hit(' in stats
|
||||
assert 'const DOMAIN_STATS_LIMIT: usize = 4096' in stats
|
||||
assert 'ArcSwap<PublishedDomains>' in stats
|
||||
assert 'fn record_reason(&self, reason:' in stats
|
||||
assert 'self.store.lock()' not in stats
|
||||
assert 'if self.ban_rule_count.load(Ordering::Acquire) == 0' in inv
|
||||
assert 'bans.iter().any(|rule| rule.matches_hot' in inv
|
||||
assert 'fn matching_hot_key(' in model and 'entry.vary.is_empty()' in model
|
||||
assert 'self.domain_stats.clear()' in dash
|
||||
assert 'self.domain_stats.record(scope, domain, outcome, reason);' in dash
|
||||
assert 'include!("tests/hot_hit_034_tests.rs")' in read('src/cache/tests.rs')
|
||||
for path in (ROOT / 'src').rglob('*.rs'):
|
||||
if '/tests/' not in path.as_posix() and path.name != 'tests.rs':
|
||||
assert len(path.read_text().splitlines()) <= 500, path
|
||||
print('PASS HOT path static invariants (no Rust compilation or throughput claim)')
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""0.34.0 source-wiring checks only; do not mistake these for Rust tests."""
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
read = lambda path: (root / path).read_text()
|
||||
assert tomllib.loads(read("Cargo.toml"))["package"]["version"] == "0.34.0"
|
||||
assert 'version = "0.34.0"' in read("Cargo.lock")
|
||||
app = read("src/runtime/updates.rs")
|
||||
assert "CacheInvalidation::Scopes(scopes)" in app
|
||||
assert "this.cache.purge_scope(&scope)" in app
|
||||
assert "this.cache.set_control_revision(envelope.revision)" in app
|
||||
assert app.index("this.cache.set_control_revision(envelope.revision)") < app.index("match invalidation")
|
||||
source = read("src/runtime/types.rs")
|
||||
assert "view.load_balancing = Default::default();" in source
|
||||
assert "member.max_concurrent_requests = 0;" in source
|
||||
assert "fn vhost_cache_revision" in source
|
||||
assert "&web_services" in source
|
||||
assert "service.sendfile = crate::web_server::SendfileMode::Off;" in source
|
||||
assert "&config.php_fpm_profiles" in source
|
||||
for filename in ("src/proxy/forward/route_cache.rs", "src/proxy/forward/web_source.rs"):
|
||||
assert "ticket_scoped_for_config(" in read(filename)
|
||||
assert "snapshot.envelope.revision" in read(filename)
|
||||
assert "expected_control_revision" in read("src/cache/tickets.rs")
|
||||
assert "file.scope_revision != expected_scope_revision" in read("src/cache/cold/startup.rs")
|
||||
assert "scope_revision: draft.scope_revision" in read("src/cache/cold/worker.rs")
|
||||
body = read("src/proxy/body.rs")
|
||||
assert "response_backend: Option<Arc<crate::balance::Lease>>" in body
|
||||
assert "lease.bytes(count, false);" in body
|
||||
assert "request_backend: Option<Arc<crate::balance::Lease>>" in body
|
||||
assert "lease.bytes(count, true);" in body
|
||||
assert "cold_scope_fingerprint_preserves_unmodified_vhost" in read("src/cache/tests/cold_tier_tests.rs")
|
||||
assert "stale_snapshot_cannot_issue_cache_ticket" in read("src/cache/tests/scope_tests.rs")
|
||||
for file in (root / 'src').rglob('*.rs'):
|
||||
if '/tests/' in file.as_posix() or file.name == 'tests.rs':
|
||||
continue
|
||||
if len(file.read_text().splitlines()) > 500:
|
||||
raise AssertionError(f"production Rust source exceeds 500 lines: {file}")
|
||||
print('PASS source wiring for scoped invalidation, cold fingerprints, stale-snapshot barrier, response accounting and <=500-line modules (STATIC ONLY)')
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Overview traffic/connection heading cleanup against Chromium and the bundled JS.
|
||||
|
||||
Runs a simulated authenticated DOM rather than a live RustProxy service.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
import json
|
||||
import re
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
|
||||
ROUTER = (ROOT / 'src/admin/router.rs').read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def app_bundle():
|
||||
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
|
||||
assert match
|
||||
body = ROUTER[match.end():]
|
||||
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
|
||||
result = []
|
||||
for line in body.splitlines():
|
||||
line = line.strip()
|
||||
include = re.search(r'include_str!\("([^"]+)"\)', line)
|
||||
if include:
|
||||
result.append((ROOT / 'src/admin' / include.group(1)).read_text(encoding='utf-8'))
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
result.append('(() => {\n' + '\n'.join(
|
||||
(ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text(encoding='utf-8')
|
||||
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
result.append(json.loads(line[:-1]))
|
||||
return '\n'.join(result)
|
||||
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
bundle = app_bundle()
|
||||
for width in (320, 375, 768, 1440, 1920):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 920})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.add_script_tag(content=bundle)
|
||||
page.evaluate('''() => {
|
||||
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
currentPanel = 'overview';
|
||||
samples.splice(0); samples.push(1.5, 85.5, 12.3);
|
||||
redrawOverviewHistoryCharts();
|
||||
}''')
|
||||
for density in ('normal', 'compact', 'ultra'):
|
||||
for span in (3, 6):
|
||||
data = page.evaluate('''({density,span}) => {
|
||||
document.documentElement.dataset.dashboardDensity = density;
|
||||
const visible = ['traffic_chart', 'connection_chart'];
|
||||
const hidden = ADMIN_DASHBOARD_WIDGETS.filter(id => !visible.includes(id));
|
||||
applyDashboardLayout([...visible,...hidden], hidden,
|
||||
{traffic_chart:span,connection_chart:span});
|
||||
const traffic = document.querySelector('[data-dashboard-widget="traffic_chart"]');
|
||||
const connection = document.querySelector('[data-dashboard-widget="connection_chart"]');
|
||||
const heading = traffic.querySelector('.card-heading');
|
||||
const peak = traffic.querySelector('#chart-peak');
|
||||
const peakRect = peak.getBoundingClientRect();
|
||||
const cardRect = traffic.getBoundingClientRect();
|
||||
const headingRect = heading.getBoundingClientRect();
|
||||
return {
|
||||
title: heading.querySelector('h2')?.textContent.trim(),
|
||||
peak: peak.textContent,
|
||||
peakInHeading: peak.parentElement === heading,
|
||||
peakNotInFooter: !traffic.querySelector('.chart-footer #chart-peak'),
|
||||
noTrafficEyebrow: !traffic.querySelector('.small-label'),
|
||||
noTrafficLiveBadge: !traffic.querySelector('.pill.live'),
|
||||
noConnectionLiveBadge: !connection.querySelector('.pill.live'),
|
||||
connectionTitleIntact: !!connection.querySelector('.card-heading h3.overview-chart-heading[data-i18n="connectionActivity"]'),
|
||||
peakInsideCard: peakRect.left >= cardRect.left - 1 && peakRect.right <= cardRect.right + 1,
|
||||
peakInsideHeading: peakRect.top >= headingRect.top - 1 && peakRect.bottom <= headingRect.bottom + 1,
|
||||
browserOverflow: document.documentElement.scrollWidth - innerWidth,
|
||||
};
|
||||
}''', {'density':density,'span':span})
|
||||
assert data['title'] == 'Requests per second', (width, density, span, data)
|
||||
assert data['peak'] == 'max 85.5 req/s', (width, density, span, data)
|
||||
for key in ('peakInHeading', 'peakNotInFooter', 'noTrafficEyebrow',
|
||||
'noTrafficLiveBadge', 'noConnectionLiveBadge',
|
||||
'connectionTitleIntact', 'peakInsideCard', 'peakInsideHeading'):
|
||||
assert data[key], (width, density, span, key, data)
|
||||
assert data['browserOverflow'] <= 2, (width, density, span, data)
|
||||
assert not errors, (width, errors)
|
||||
print(f'PASS traffic/connection chart headers at {width}px (all densities, spans 3 and 6)')
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS Overview chart heading cleanup')
|
||||
@@ -1,168 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""All chart titles and series labels in simulated Chromium (no live Rust server).
|
||||
|
||||
Covers Overview, diagnostics, Web Server, Traffic, Cache/Transform, Vhosts,
|
||||
Streams, and DOM-created chart/legend variants. Preserve localized DOM text.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
|
||||
|
||||
CHECK = r'''(settings) => {
|
||||
const { density, compactBody, theme } = settings;
|
||||
document.documentElement.dataset.dashboardDensity = density;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.body.dataset.density = compactBody ? 'compact' : 'comfortable';
|
||||
|
||||
const titleSelectors = [
|
||||
'#panel-overview .overview-wide-chart .card-heading h2',
|
||||
'#panel-overview .activity-card .card-heading h2',
|
||||
'#panel-overview :is(.system-card, .connection-card) .overview-chart-heading',
|
||||
'#panel-overview :is(.stat-chart, .stat-donut) > span:first-child',
|
||||
'.mini-live-chart > .mini-live-chart-head > span:first-child',
|
||||
'.traffic-chart-card > h3.subheading',
|
||||
'.function-live-chart-heading > strong',
|
||||
'.vhost-chart-head > span:first-child',
|
||||
];
|
||||
const monoSelectors = [
|
||||
'.overview-wide-summary', '.overview-wide-legend',
|
||||
'.overview-donut-legend', '.www-method-legend', '.stat-chart-legend',
|
||||
'.mini-chart-legend', '.connection-legend',
|
||||
'.traffic-protocol-legend', '.traffic-chart-legend',
|
||||
'.function-live-chart-legend', '.stream-chart-legend',
|
||||
'.chart-footer', '.vhost-chart-head > :not(:first-child)',
|
||||
'.mini-live-chart-head > b', '.mini-live-chart-head > .mini-live-chart-actions',
|
||||
'.network-interface-rate',
|
||||
'.function-live-chart-summary > b', '.function-live-chart-heading small',
|
||||
'.function-live-chart-samples',
|
||||
'#panel-overview .stat-chart > small',
|
||||
'#panel-overview .overview-wide-chart > .fine',
|
||||
'#panel-overview .www-top-row', '.traffic-bar-row',
|
||||
'#panel-overview .system-card .card-heading h2',
|
||||
'#panel-overview .system-card .card-heading .meta',
|
||||
'#panel-overview .system-card .memory-line',
|
||||
'#panel-overview .connection-card .card-heading h2',
|
||||
];
|
||||
const legendSelectors = [
|
||||
'.overview-wide-legend', '.overview-donut-legend', '.www-ring-legend',
|
||||
'.www-method-legend', '.stat-chart-legend', '.mini-chart-legend',
|
||||
'.connection-legend', '.traffic-protocol-legend', '.traffic-chart-legend',
|
||||
'.function-live-chart-legend', '.stream-chart-legend', '.stream-traffic-legend'
|
||||
];
|
||||
const legendNodes = [...document.querySelectorAll(legendSelectors.join(','))];
|
||||
const requestLegend = document.querySelector('[data-dashboard-widget="traffic_chart"] .traffic-protocol-legend');
|
||||
const referenceLegendSize = getComputedStyle(requestLegend).fontSize;
|
||||
const titleNodes = Array.from(document.querySelectorAll(titleSelectors.join(',')));
|
||||
const monoNodes = Array.from(document.querySelectorAll(monoSelectors.join(',')));
|
||||
const base = document.createElement('div');
|
||||
base.style.fontFamily = 'var(--mono)';
|
||||
document.body.append(base);
|
||||
const mono = getComputedStyle(base).fontFamily;
|
||||
base.style.fontFamily = 'var(--sans)';
|
||||
const sans = getComputedStyle(base).fontFamily;
|
||||
base.remove();
|
||||
|
||||
const titleErrors = titleNodes.flatMap(node => {
|
||||
const css = getComputedStyle(node);
|
||||
const inOverview = !!node.closest('#panel-overview');
|
||||
const isSmallCaption = inOverview && !!node.closest('.mini-live-chart-head');
|
||||
const wantedFont = isSmallCaption ? mono : sans;
|
||||
const wantedSize = isSmallCaption ? '10px' : '12px';
|
||||
const wantedCase = inOverview ? 'none' : 'uppercase';
|
||||
const problems = [];
|
||||
if (css.fontFamily !== wantedFont) problems.push(`font=${css.fontFamily}`);
|
||||
if (css.fontSize !== wantedSize) problems.push(`size=${css.fontSize}`);
|
||||
if (css.textTransform !== wantedCase) problems.push(`case=${css.textTransform}`);
|
||||
return problems.map(problem => `${node.outerHTML.slice(0, 100)}: ${problem}`);
|
||||
});
|
||||
const monoErrors = monoNodes.flatMap(node => {
|
||||
const all = [node, ...node.querySelectorAll('*')].filter(el => el.tagName.toLowerCase() !== 'svg');
|
||||
return all.filter(el => getComputedStyle(el).fontFamily !== mono)
|
||||
.map(el => `${el.outerHTML.slice(0, 100)}: ${getComputedStyle(el).fontFamily}`);
|
||||
});
|
||||
const legendErrors = legendNodes.flatMap(root => {
|
||||
const children = [root, ...root.querySelectorAll('span, b, strong, small, li, em')];
|
||||
return children.flatMap(node => {
|
||||
const style = getComputedStyle(node);
|
||||
const issues = [];
|
||||
if (style.fontSize !== referenceLegendSize) issues.push(`size=${style.fontSize}`);
|
||||
if (style.fontFamily !== mono) issues.push(`font=${style.fontFamily}`);
|
||||
return issues.map(issue => `${node.outerHTML.slice(0, 95)} ${issue}`);
|
||||
});
|
||||
});
|
||||
// Keep localized Overview titles in sentence case; other sections retain
|
||||
// their independent uppercase chart-heading convention.
|
||||
const compression = document.querySelector('[data-dashboard-widget="wide_compression_activity"]');
|
||||
const compressionHeading = compression.querySelector('.card-heading h2');
|
||||
const compressionLegend = compression.querySelector('.overview-wide-legend');
|
||||
const originalTranslation = compressionHeading.textContent.trim() === 'Aktywność kompresji';
|
||||
const legendFonts = [...compressionLegend.querySelectorAll('span, b')]
|
||||
.map(el => getComputedStyle(el).fontFamily);
|
||||
const unrelatedHeading = document.querySelector('#panel-traffic .card-heading h2');
|
||||
const numericReading = document.querySelector('#system-cpu');
|
||||
return {
|
||||
counts: {titles: titleNodes.length, mono: monoNodes.length, legends: legendNodes.length},
|
||||
titleErrors, monoErrors, legendErrors, referenceLegendSize, mono, sans,
|
||||
originalTranslation, compressionTitleSize: getComputedStyle(compressionHeading).fontSize,
|
||||
compressionTitleCase: getComputedStyle(compressionHeading).textTransform,
|
||||
compressionLegendIsMono: legendFonts.every(font => font === mono),
|
||||
unrelatedTitleSize: getComputedStyle(unrelatedHeading).fontSize,
|
||||
numericReadingIsMono: getComputedStyle(numericReading).fontFamily === mono,
|
||||
};
|
||||
}'''
|
||||
|
||||
with sync_playwright() as api:
|
||||
browser = api.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (320, 375, 768, 1440, 1920):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 960})
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.evaluate('''() => {
|
||||
// Representative dynamically rendered Vhost/Web Service/Stream charts.
|
||||
for (const panel of ['panel-vhosts','panel-web-server','panel-streams']) {
|
||||
document.getElementById(panel).insertAdjacentHTML('beforeend', `
|
||||
<div class="vhost-chart"><div class="vhost-chart-head">
|
||||
<span class="small-label">Connection history</span>
|
||||
<span class="fine">42 req/s</span></div>
|
||||
<svg viewBox="0 0 300 54"><path d=""></path></svg>
|
||||
<div class="stream-chart-legend"><span>RX 2 MB/s</span><span>TX 1 MB/s</span></div>
|
||||
</div>`);
|
||||
}
|
||||
const comp = document.querySelector('[data-dashboard-widget="wide_compression_activity"] .card-heading h2');
|
||||
comp.textContent = 'Aktywność kompresji';
|
||||
// The traffic renderer inserts these series dynamically.
|
||||
document.querySelectorAll('.traffic-chart-legend').forEach(root => {
|
||||
root.innerHTML = `<li><span class="traffic-chart-label">GET requests</span><b>12</b></li>`;
|
||||
});
|
||||
const sample = document.createElement('div');
|
||||
sample.innerHTML = `<div class="www-top-row"><span>php-worker</span><b>11/s</b></div>
|
||||
<div class="traffic-bar-row"><span class="traffic-bar-label">GET</span><b class="traffic-bar-value">22</b></div>
|
||||
<div class="network-interface-rate">100 Mb/s</div>`;
|
||||
document.getElementById('panel-overview').append(sample);
|
||||
}''')
|
||||
for density in ('normal','compact','ultra'):
|
||||
for compact_body in (False, True):
|
||||
for theme in ('dark', 'light'):
|
||||
result = page.evaluate(CHECK, {'density': density, 'compactBody': compact_body, 'theme': theme})
|
||||
description = (width, density, compact_body, theme)
|
||||
assert result['counts']['titles'] >= 43, (description, result['counts'])
|
||||
assert result['counts']['mono'] >= 90, (description, result['counts'])
|
||||
assert not result['titleErrors'], (description, result['titleErrors'][:10])
|
||||
assert not result['monoErrors'], (description, result['monoErrors'][:10])
|
||||
assert result['counts']['legends'] >= 39, (description, result['counts'])
|
||||
assert result['referenceLegendSize'] == '9px', description
|
||||
assert not result['legendErrors'], (description, result['legendErrors'][:10])
|
||||
assert result['originalTranslation'], description
|
||||
assert result['compressionTitleSize'] == '12px', description
|
||||
assert result['compressionTitleCase'] == 'none', description
|
||||
assert result['compressionLegendIsMono'], description
|
||||
assert result['numericReadingIsMono'], description
|
||||
assert result['unrelatedTitleSize'] != '12px', (description, result['unrelatedTitleSize'])
|
||||
print(f'PASS all chart families at {width}px (3 dashboard densities, 2 interface densities, 2 themes)')
|
||||
page.close()
|
||||
browser.close()
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real-browser regression for Overview chart content when card width changes.
|
||||
|
||||
Runs the production HTML/CSS/assembled admin JS against a simulated DOM and
|
||||
fake network-interface samples. This does not exercise Rust HTTP/SSE.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text()
|
||||
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
|
||||
|
||||
|
||||
def app_bundle():
|
||||
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
|
||||
assert match
|
||||
body = ROUTER[match.end():]
|
||||
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
|
||||
scripts = []
|
||||
for line in body.splitlines():
|
||||
line = line.strip()
|
||||
include = re.search(r'include_str!\("([^"]+)"\)', line)
|
||||
if include:
|
||||
scripts.append((ROOT / 'src/admin' / include.group(1)).read_text())
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
scripts.append('(() => {\n' + '\n'.join(
|
||||
(ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
|
||||
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
scripts.append(json.loads(line[:-1]))
|
||||
return '\n'.join(scripts)
|
||||
|
||||
|
||||
def inspect(page):
|
||||
return page.evaluate('''() => {
|
||||
const selectors = {
|
||||
'websocket_protocol':'[data-dashboard-widget="websocket_protocol"] svg',
|
||||
'wide_transform_activity':'[data-dashboard-widget="wide_transform_activity"] svg',
|
||||
'www_requests':'[data-dashboard-widget="www_requests"] svg',
|
||||
'traffic_chart':'[data-dashboard-widget="traffic_chart"] svg',
|
||||
'system_chart':'[data-dashboard-widget="system_chart"] svg',
|
||||
'cache_diagnostics':'[data-dashboard-widget="cache_diagnostics"] .mini-live-chart svg',
|
||||
'network_diagnostics':'[data-dashboard-widget="network_diagnostics"] .mini-live-chart svg',
|
||||
'network_interface':'[data-dashboard-widget="network_diagnostics"] .network-interface-row svg',
|
||||
};
|
||||
return Object.fromEntries(Object.entries(selectors).map(([name, selector]) => {
|
||||
const svg = document.querySelector(selector);
|
||||
if (!svg) throw new Error('Missing SVG: ' + name);
|
||||
const box = svg.getBoundingClientRect();
|
||||
const matrix = svg.getScreenCTM();
|
||||
const viewBox = svg.viewBox.baseVal;
|
||||
const contentWidth = Math.hypot(matrix.a, matrix.b) * viewBox.width;
|
||||
const series = svg.querySelector('.overview-wide-series');
|
||||
return [name, {width:box.width, contentWidth,
|
||||
preservation:svg.getAttribute('preserveAspectRatio'),
|
||||
lineStroke: series ? getComputedStyle(series).vectorEffect : null}];
|
||||
}));
|
||||
}''')
|
||||
|
||||
|
||||
with sync_playwright() as browser_api:
|
||||
browser = browser_api.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for screen_width in (320, 375, 800, 1440, 1920):
|
||||
page = browser.new_page(viewport={'width': screen_width, 'height': 920})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.add_script_tag(content=app_bundle())
|
||||
page.evaluate('''() => {
|
||||
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
currentPanel = 'overview';
|
||||
renderNetworkInterfaceCharts([{name:'eth0',rx_bytes_per_second:12500,tx_bytes_per_second:5200}], true);
|
||||
}''')
|
||||
ids = ['websocket_protocol', 'wide_transform_activity', 'www_requests', 'traffic_chart',
|
||||
'system_chart', 'cache_diagnostics', 'network_diagnostics']
|
||||
results = []
|
||||
for span in (3, 12):
|
||||
page.evaluate('''({ids,span}) => {
|
||||
const hidden = ADMIN_DASHBOARD_WIDGETS.filter(id => !ids.includes(id));
|
||||
const sizes = Object.fromEntries(ids.map(id => [id, span]));
|
||||
applyDashboardLayout([...ids, ...hidden], hidden, sizes);
|
||||
}''', {'ids': ids, 'span': span})
|
||||
measured = inspect(page)
|
||||
results.append(measured)
|
||||
for chart, value in measured.items():
|
||||
assert value['width'] > 50, (screen_width, span, chart, value)
|
||||
assert abs(value['width'] - value['contentWidth']) <= 2, (
|
||||
'chart not filling its own SVG viewport', screen_width, span, chart, value)
|
||||
assert value['preservation'] == 'none', (screen_width, chart, value)
|
||||
if value['lineStroke'] is not None:
|
||||
assert value['lineStroke'] == 'non-scaling-stroke', (chart, value)
|
||||
if screen_width >= 1440:
|
||||
for name in results[0]:
|
||||
assert results[1][name]['width'] > results[0][name]['width'] * 1.4, (
|
||||
'chart does not grow when widening card', screen_width, name, results)
|
||||
assert page.evaluate('document.documentElement.scrollWidth - innerWidth') <= 2, screen_width
|
||||
assert not errors, (screen_width, errors)
|
||||
print('PASS SVG chart uses full width at spans 3 and 12:', screen_width)
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS resizable Overview chart content regression')
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Legacy CPU plot regression: low and high real values on a fixed 0-100% axis.
|
||||
|
||||
Autoscaling was intentionally replaced by 0.33.5 fixed scaling. This test
|
||||
retains the original history/reset and label checks using the current scale.
|
||||
Chromium is simulated, not a live RustProxy server.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
import re
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
|
||||
SCALE_JS = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
|
||||
WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def inspect(page):
|
||||
return page.evaluate('''() => {
|
||||
const get = id => document.querySelector(`#dashboard-overview-grid [data-dashboard-widget="${id}"]`);
|
||||
const cpu = get('wide_cpu'), cpuSvg = cpu.querySelector('svg');
|
||||
const protocolSvg = get('wide_protocol_rates').querySelector('svg');
|
||||
const backendSvg = get('backend_traffic').querySelector('svg');
|
||||
const legend = cpu.querySelector('.overview-wide-legend').getBoundingClientRect();
|
||||
const bounds = cpu.getBoundingClientRect();
|
||||
const matrix = cpuSvg.getScreenCTM();
|
||||
return {
|
||||
cpuHeight: cpuSvg.getBoundingClientRect().height,
|
||||
protocolHeight: protocolSvg.getBoundingClientRect().height,
|
||||
backendHeight: backendSvg.getBoundingClientRect().height,
|
||||
svgWidth: cpuSvg.getBoundingClientRect().width,
|
||||
drawnWidth: Math.hypot(matrix.a, matrix.b) * cpuSvg.viewBox.baseVal.width,
|
||||
ratio: cpuSvg.getAttribute('preserveAspectRatio'),
|
||||
cpuSeries: cpuSvg.querySelectorAll('.overview-wide-series').length,
|
||||
lineEffect: getComputedStyle(cpuSvg.querySelector('.overview-wide-series')).vectorEffect,
|
||||
gridEffect: getComputedStyle(cpuSvg.querySelector('.chart-grid')).vectorEffect,
|
||||
legendInCard: legend.top >= bounds.top && legend.bottom <= bounds.bottom + 1,
|
||||
overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
};
|
||||
}''')
|
||||
|
||||
|
||||
def render_cpu(page, revision, system, process):
|
||||
return page.evaluate('''({revision,system,process}) => {
|
||||
window.rustProxyWideOverview.render({revision,live_system:{
|
||||
cpu_percent:system, process_cpu_percent:process
|
||||
}});
|
||||
const read = name => {
|
||||
const prefix = `overview-wide-wide_cpu-${name}`;
|
||||
return {
|
||||
path: document.getElementById(`${prefix}-line`).getAttribute('d'),
|
||||
label: document.getElementById(`${prefix}-value`).textContent,
|
||||
};
|
||||
};
|
||||
return {system:read('system'),process:read('process')};
|
||||
}''', {'revision': revision, 'system': system, 'process': process})
|
||||
|
||||
|
||||
def ys(path):
|
||||
return [int(v) for v in re.findall(r'[ML]\d+\s+(\d+)', path)]
|
||||
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for viewport in (320, 375, 768, 1440, 1920):
|
||||
page = browser.new_page(viewport={'width': viewport, 'height': 950})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.evaluate('''() => {
|
||||
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
const grid = document.getElementById('dashboard-overview-grid');
|
||||
grid.classList.add('is-aligned');
|
||||
for (const card of grid.querySelectorAll(':scope > [data-dashboard-widget]')) {
|
||||
card.hidden = !['wide_cpu', 'wide_protocol_rates', 'backend_traffic'].includes(card.dataset.dashboardWidget);
|
||||
}
|
||||
window.currentPanel = 'overview';
|
||||
window.$ = id => document.getElementById(id);
|
||||
window.fmt = (value, digits = 0) => Number(value).toFixed(digits);
|
||||
window.fmtBytes = value => `${value} B`;
|
||||
window.rustProxyOverviewSamples = {getLimit: () => 60, onChange: () => {}};
|
||||
}''')
|
||||
page.add_script_tag(content=SCALE_JS)
|
||||
page.add_script_tag(content=WIDE_JS)
|
||||
|
||||
for density in ('compact', 'normal', 'ultra'):
|
||||
page.evaluate('(density) => document.documentElement.dataset.dashboardDensity = density', density)
|
||||
result = inspect(page)
|
||||
assert result['protocolHeight'] >= 124, (viewport, density, result)
|
||||
assert abs(result['cpuHeight'] - result['protocolHeight']) <= 1, (
|
||||
'CPU must use standard chart height', viewport, density, result)
|
||||
assert result['backendHeight'] > 0, (viewport, density, result)
|
||||
assert result['ratio'] == 'none' and result['cpuSeries'] == 2, result
|
||||
assert result['lineEffect'] == result['gridEffect'] == 'non-scaling-stroke', result
|
||||
assert result['svgWidth'] > 100 and abs(result['svgWidth'] - result['drawnWidth']) < 2, result
|
||||
assert result['legendInCard'] and result['overflow'] <= 2, (viewport, density, result)
|
||||
|
||||
# Measured 0.5-0.8% stay numerically visible without axis auto-expansion.
|
||||
first = render_cpu(page, 1, 0.5, 0.2)
|
||||
second = render_cpu(page, 1, 0.8, 0.6)
|
||||
assert first['system']['label'] == '0.5%' and second['process']['label'] == '0.6%'
|
||||
assert ys(second['system']['path']) == [136, 136], (viewport, second)
|
||||
assert ys(second['process']['path']) == [137, 136], (viewport, second)
|
||||
|
||||
# Multi-core values display accurately but clip at the fixed 100% ceiling.
|
||||
third = render_cpu(page, 1, 3.4, 2.2)
|
||||
assert ys(third['system']['path'])[-1] == 133, (viewport, third)
|
||||
assert ys(third['process']['path'])[-1] == 134, (viewport, third)
|
||||
high = render_cpu(page, 1, 250, 123)
|
||||
assert high['system']['label'] == '250.0%'
|
||||
assert ys(high['system']['path'])[-1] == 17, (viewport, high)
|
||||
assert ys(high['process']['path'])[-1] == 17, (viewport, high)
|
||||
|
||||
# Revision changes reset the history. Zero stays at the plot baseline.
|
||||
idle = render_cpu(page, 2, 0, 0)
|
||||
assert ys(idle['system']['path']) == [137] and ys(idle['process']['path']) == [137]
|
||||
missing = render_cpu(page, 3, None, None)
|
||||
assert missing['system']['path'] == '' and missing['process']['label'] == '-'
|
||||
assert not errors, (viewport, errors)
|
||||
print(f'PASS legacy CPU labels/history on fixed Y-scale at {viewport}px (all densities)')
|
||||
page.close()
|
||||
browser.close()
|
||||
@@ -1,149 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""0.33.5 fixed 0-100% CPU plot and height regression with production HTML/CSS/JS.
|
||||
|
||||
Uses Chromium with simulated authenticated DOM and status samples, not a live
|
||||
RustProxy server, SSE stream or real CPU telemetry.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
import re
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
|
||||
SCALE_JS = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
|
||||
WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def inspect(page):
|
||||
return page.evaluate('''() => {
|
||||
const get = id => document.querySelector(`#dashboard-overview-grid [data-dashboard-widget="${id}"]`);
|
||||
const cpu = get('wide_cpu'), cpuSvg = cpu.querySelector('svg');
|
||||
const protocolSvg = get('wide_protocol_rates').querySelector('svg');
|
||||
const connection = get('connection_chart');
|
||||
const backendSvg = get('backend_traffic').querySelector('svg');
|
||||
const legend = cpu.querySelector('.overview-wide-legend').getBoundingClientRect();
|
||||
const bounds = cpu.getBoundingClientRect();
|
||||
const matrix = cpuSvg.getScreenCTM();
|
||||
return {
|
||||
cpuHeight: cpuSvg.getBoundingClientRect().height,
|
||||
cpuCardHeight: bounds.height,
|
||||
connectionCardHeight: connection.getBoundingClientRect().height,
|
||||
protocolHeight: protocolSvg.getBoundingClientRect().height,
|
||||
backendHeight: backendSvg.getBoundingClientRect().height,
|
||||
svgWidth: cpuSvg.getBoundingClientRect().width,
|
||||
drawnWidth: Math.hypot(matrix.a, matrix.b) * cpuSvg.viewBox.baseVal.width,
|
||||
ratio: cpuSvg.getAttribute('preserveAspectRatio'),
|
||||
cpuSeries: cpuSvg.querySelectorAll('.overview-wide-series').length,
|
||||
lineEffect: getComputedStyle(cpuSvg.querySelector('.overview-wide-series')).vectorEffect,
|
||||
gridEffect: getComputedStyle(cpuSvg.querySelector('.chart-grid')).vectorEffect,
|
||||
gridPath: cpuSvg.querySelector('.chart-grid').getAttribute('d'),
|
||||
legendInCard: legend.top >= bounds.top && legend.bottom <= bounds.bottom + 1,
|
||||
overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
};
|
||||
}''')
|
||||
|
||||
|
||||
def render_cpu(page, revision, system, process):
|
||||
return page.evaluate('''({revision,system,process}) => {
|
||||
window.rustProxyWideOverview.render({revision,live_system:{
|
||||
cpu_percent:system, process_cpu_percent:process
|
||||
}});
|
||||
const read = name => {
|
||||
const prefix = `overview-wide-wide_cpu-${name}`;
|
||||
return {
|
||||
path: document.getElementById(`${prefix}-line`).getAttribute('d'),
|
||||
label: document.getElementById(`${prefix}-value`).textContent,
|
||||
};
|
||||
};
|
||||
return {system:read('system'),process:read('process')};
|
||||
}''', {'revision': revision, 'system': system, 'process': process})
|
||||
|
||||
|
||||
def ys(path):
|
||||
return [int(v) for v in re.findall(r'[ML]\d+\s+(\d+)', path)]
|
||||
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for viewport in (320, 375, 768, 1440, 1920):
|
||||
page = browser.new_page(viewport={'width': viewport, 'height': 950})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.evaluate('''() => {
|
||||
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
const grid = document.getElementById('dashboard-overview-grid');
|
||||
grid.classList.add('is-aligned');
|
||||
for (const card of grid.querySelectorAll(':scope > [data-dashboard-widget]')) {
|
||||
card.hidden = !['wide_cpu', 'wide_protocol_rates', 'backend_traffic', 'connection_chart'].includes(card.dataset.dashboardWidget);
|
||||
}
|
||||
window.currentPanel = 'overview';
|
||||
window.$ = id => document.getElementById(id);
|
||||
window.fmt = (value, digits = 0) => Number(value).toFixed(digits);
|
||||
window.fmtBytes = value => `${value} B`;
|
||||
window.rustProxyOverviewSamples = {getLimit: () => 60, onChange: () => {}};
|
||||
}''')
|
||||
page.add_script_tag(content=SCALE_JS)
|
||||
page.add_script_tag(content=WIDE_JS)
|
||||
|
||||
for density in ('compact', 'normal', 'ultra'):
|
||||
page.evaluate('(density) => document.documentElement.dataset.dashboardDensity = density', density)
|
||||
result = inspect(page)
|
||||
assert result['protocolHeight'] >= 124, (viewport, density, result)
|
||||
assert abs(result['cpuHeight'] - 125) <= 1, (
|
||||
'CPU height must remain fixed at 125px', viewport, density, result)
|
||||
assert result['backendHeight'] > 0, (viewport, density, result)
|
||||
assert result['ratio'] == 'none' and result['cpuSeries'] == 2, result
|
||||
assert result['lineEffect'] == result['gridEffect'] == 'non-scaling-stroke', result
|
||||
assert result['gridPath'] == 'M0 47H600 M0 77H600 M0 107H600', result
|
||||
assert result['svgWidth'] > 100 and abs(result['svgWidth'] - result['drawnWidth']) < 2, result
|
||||
assert result['legendInCard'] and result['overflow'] <= 2, (viewport, density, result)
|
||||
|
||||
# Low measured CPU values stay near zero instead of filling the plot.
|
||||
first = render_cpu(page, 1, 0.5, 0.2)
|
||||
second = render_cpu(page, 1, 0.8, 0.6)
|
||||
assert first['system']['label'] == '0.5%' and second['process']['label'] == '0.6%'
|
||||
assert ys(second['system']['path']) == [136, 136], (viewport, second)
|
||||
assert ys(second['process']['path']) == [137, 136], (viewport, second)
|
||||
|
||||
# The Y axis is always 0-100% regardless of recorded history.
|
||||
third = render_cpu(page, 1, 3.4, 2.2)
|
||||
assert ys(third['system']['path'])[-1] == 133, (viewport, third)
|
||||
assert ys(third['process']['path'])[-1] == 134, (viewport, third)
|
||||
high = render_cpu(page, 1, 250, 123)
|
||||
assert high['system']['label'] == '250.0%' and high['process']['label'] == '123.0%'
|
||||
assert ys(high['system']['path'])[-1] == 17, (viewport, high)
|
||||
assert ys(high['process']['path'])[-1] == 17, (viewport, high)
|
||||
mid = render_cpu(page, 1, 50, 25)
|
||||
assert ys(mid['system']['path'])[-1] == 77, (viewport, mid)
|
||||
assert ys(mid['process']['path'])[-1] == 107, (viewport, mid)
|
||||
|
||||
# Revision changes reset history; missing readings do not plot a zero.
|
||||
idle = render_cpu(page, 2, 0, 0)
|
||||
assert ys(idle['system']['path']) == [137] and ys(idle['process']['path']) == [137]
|
||||
full = render_cpu(page, 2, 100, 100)
|
||||
assert ys(full['system']['path'])[-1] == 17 and ys(full['process']['path'])[-1] == 17
|
||||
missing = render_cpu(page, 3, None, None)
|
||||
assert missing['system']['path'] == '' and missing['process']['label'] == '-'
|
||||
|
||||
# Samples may change paths, but not the plot/card or neighboring card sizes.
|
||||
for density in ('compact', 'normal', 'ultra'):
|
||||
page.evaluate('(density) => document.documentElement.dataset.dashboardDensity = density', density)
|
||||
unchanged = inspect(page)
|
||||
assert abs(unchanged['cpuHeight'] - 125) <= 1, (viewport, density, unchanged)
|
||||
for value in (0.1, 99.9, 250, 0.0):
|
||||
before = inspect(page)
|
||||
render_cpu(page, 4, value, value)
|
||||
after = inspect(page)
|
||||
for key in ('cpuHeight', 'cpuCardHeight', 'connectionCardHeight'):
|
||||
assert abs(before[key] - after[key]) < 1, (viewport, density, value, key, before, after)
|
||||
assert not errors, (viewport, errors)
|
||||
print(f'PASS CPU fixed 0-100% Y-scale and 125px plot height at {viewport}px (all densities)')
|
||||
page.close()
|
||||
browser.close()
|
||||
@@ -1,199 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""0.33.1 factory/default + unsaved layout regression against the full bundled JS.
|
||||
|
||||
The authenticated app bundle runs against simulated status/preferences; this is
|
||||
not a Rust server integration test.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PRESET = json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text())
|
||||
REGISTRY = json.loads((ROOT / 'src/admin/web/dashboard-widgets.json').read_text())
|
||||
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
|
||||
|
||||
|
||||
def app_bundle():
|
||||
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
|
||||
assert match
|
||||
body = ROUTER[match.end():]
|
||||
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
|
||||
result = []
|
||||
for line in body.splitlines():
|
||||
line = line.strip()
|
||||
include = re.search(r'include_str!\("([^"]+)"\)', line)
|
||||
if include:
|
||||
result.append((ROOT / 'src/admin' / include.group(1)).read_text())
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
result.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
|
||||
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
result.append(json.loads(line[:-1]))
|
||||
return '\n'.join(result)
|
||||
|
||||
|
||||
assert len(PRESET['dashboard_widgets']) == len(REGISTRY)
|
||||
assert set(PRESET['dashboard_widgets']) == {w['id'] for w in REGISTRY}
|
||||
assert len(set(PRESET['dashboard_hidden_widgets'])) == len(PRESET['dashboard_hidden_widgets'])
|
||||
assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_widgets']))
|
||||
assert PRESET['dashboard_density'] == 'compact' and PRESET['overview_samples'] == 600
|
||||
assert sum(w['id'].startswith('www_') and w['id'] in PRESET['dashboard_hidden_widgets'] for w in REGISTRY) == 12
|
||||
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text()
|
||||
BUNDLE = app_bundle()
|
||||
assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =')
|
||||
|
||||
|
||||
def load(page):
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.add_script_tag(content=BUNDLE)
|
||||
page.wait_for_function('window.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyBackendDashboard')
|
||||
|
||||
|
||||
def inspect(page, ids):
|
||||
return page.evaluate('''ids => Object.fromEntries(ids.map(id => {
|
||||
const node = document.querySelector(`[data-dashboard-widget="${id}"]`);
|
||||
return [id, {hidden:node.hidden, display:getComputedStyle(node).display}];
|
||||
}))''', ids)
|
||||
|
||||
|
||||
def assert_visible(page, visible, hidden):
|
||||
result = inspect(page, visible + hidden)
|
||||
for widget in visible:
|
||||
assert result[widget]['hidden'] is False and result[widget]['display'] != 'none', (widget, result)
|
||||
for widget in hidden:
|
||||
assert result[widget]['hidden'] is True and result[widget]['display'] == 'none', (widget, result)
|
||||
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in [320, 375, 800, 1440]:
|
||||
context = browser.new_context(viewport={'width': width, 'height': 950})
|
||||
page = context.new_page()
|
||||
errors = []
|
||||
page.on('pageerror', lambda e: errors.append(str(e)))
|
||||
load(page)
|
||||
result = page.evaluate('''() => {
|
||||
const prefs = ADMIN_APPEARANCE_DEFAULTS;
|
||||
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
document.getElementById('overview-layout-unlock').hidden = false;
|
||||
currentPanel = 'overview';
|
||||
window.__savedDashboard = JSON.parse(JSON.stringify(prefs));
|
||||
window.rustProxyPreferences.load = async () => window.__savedDashboard;
|
||||
window.rustProxyPreferences.current = () => window.__savedDashboard;
|
||||
window.rustProxyPreferences.update = async patch => {
|
||||
window.__savedDashboard = {...window.__savedDashboard, ...patch};
|
||||
applyAdminPreferences(window.__savedDashboard);
|
||||
return window.__savedDashboard;
|
||||
};
|
||||
applyAdminPreferences(window.__savedDashboard);
|
||||
api = async path => { if (path === '/api/dashboard-presets') return []; throw new Error('unexpected API ' + path); };
|
||||
appConfirm = async () => true;
|
||||
return {
|
||||
order: ADMIN_DASHBOARD_WIDGETS,
|
||||
hidden: ADMIN_DEFAULT_HIDDEN_WIDGETS,
|
||||
density: document.documentElement.dataset.dashboardDensity,
|
||||
samples:window.rustProxyOverviewSamples.getLimit(),
|
||||
serviceCount:document.querySelectorAll('#dashboard-overview-grid [data-dashboard-widget]').length,
|
||||
};
|
||||
}''')
|
||||
assert result['order'] == PRESET['dashboard_widgets'], (width, result['order'])
|
||||
assert result['hidden'] == PRESET['dashboard_hidden_widgets'], (width, result['hidden'])
|
||||
assert result['samples'] == 600 and result['density'] == 'compact' and result['serviceCount'] == 78, result
|
||||
assert_visible(page, ['backend_traffic', 'extra_response_latency', 'wide_transform_activity'],
|
||||
['backend_health', 'stream_sessions', 'www_status', 'bandwidth'])
|
||||
|
||||
# Reproduce the previously flaky operation: opt in hidden backend +
|
||||
# Stream cards and opt out a visible backend card, WITHOUT saving yet.
|
||||
page.locator('#overview-layout-unlock').click()
|
||||
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
|
||||
outcome = page.evaluate('''() => {
|
||||
const add = id => {
|
||||
const label = dashboardWidgetLabel(id);
|
||||
const item = [...document.querySelectorAll('#overview-inline-hidden-list .overview-inline-hidden-item')]
|
||||
.find(node => node.firstElementChild.textContent === label);
|
||||
if (!item) throw new Error('no hidden card to restore: ' + id);
|
||||
item.querySelector('button').click();
|
||||
};
|
||||
add('backend_health');
|
||||
add('stream_sessions');
|
||||
const card = document.querySelector('[data-dashboard-widget="backend_traffic"]');
|
||||
card.querySelector('.overview-inline-hide').click();
|
||||
return window.rustProxyInlineLayout.currentDraft();
|
||||
}''')
|
||||
assert 'backend_traffic' in outcome['hidden'] and 'backend_health' not in outcome['hidden']
|
||||
assert 'stream_sessions' not in outcome['hidden']
|
||||
assert_visible(page, ['backend_health', 'stream_sessions'], ['backend_traffic'])
|
||||
for _ in range(5):
|
||||
page.evaluate('''() => {
|
||||
const status = {revision:4,backend_pools:[],streams:{active:[],desired:[],metrics:{}}};
|
||||
window.rustProxyBackendDashboard.render(status);
|
||||
window.rustProxyStreamDashboard.render(status);
|
||||
}''')
|
||||
assert_visible(page, ['backend_health', 'stream_sessions'], ['backend_traffic'])
|
||||
# Unrelated settings update must not repaint the saved layout over the draft.
|
||||
page.evaluate('''() => applyAdminPreferences({...window.__savedDashboard, ui_scale:105})''')
|
||||
assert_visible(page, ['backend_health', 'stream_sessions'], ['backend_traffic'])
|
||||
assert page.evaluate('window.rustProxyInlineLayout.isEditing()')
|
||||
page.locator('#overview-inline-cancel').click()
|
||||
assert not page.evaluate('window.rustProxyInlineLayout.isEditing()')
|
||||
assert_visible(page, ['backend_traffic'], ['backend_health', 'stream_sessions'])
|
||||
|
||||
# Save under live traffic, ensure the in-flight preference application
|
||||
# uses the current draft before the editor exits.
|
||||
page.locator('#overview-layout-unlock').click()
|
||||
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
|
||||
page.evaluate('''() => {
|
||||
const label = dashboardWidgetLabel('backend_health');
|
||||
const item = [...document.querySelectorAll('#overview-inline-hidden-list .overview-inline-hidden-item')]
|
||||
.find(node => node.firstElementChild.textContent === label);
|
||||
item.querySelector('button').click();
|
||||
document.querySelector('[data-dashboard-widget="backend_traffic"] .overview-inline-hide').click();
|
||||
}''')
|
||||
page.locator('#overview-inline-save').click()
|
||||
page.wait_for_function('!window.rustProxyInlineLayout.isEditing()')
|
||||
assert_visible(page, ['backend_health'], ['backend_traffic'])
|
||||
saved = page.evaluate('''() => window.__savedDashboard.dashboard_hidden_widgets''')
|
||||
assert 'backend_traffic' in saved and 'backend_health' not in saved
|
||||
page.evaluate('''() => {
|
||||
const status={backend_pools:[],streams:{active:[],desired:[],metrics:{}}};
|
||||
window.rustProxyBackendDashboard.render(status);
|
||||
window.rustProxyStreamDashboard.render(status);
|
||||
}''')
|
||||
assert_visible(page, ['backend_health'], ['backend_traffic'])
|
||||
# Restore factory layout through the new named-presets modal.
|
||||
page.locator('#overview-layout-open').evaluate('(node) => node.hidden = false')
|
||||
page.locator('#overview-layout-open').click()
|
||||
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
|
||||
page.locator('#overview-presets-factory').click()
|
||||
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
|
||||
assert_visible(page, ['backend_traffic'], ['backend_health', 'stream_sessions', 'www_status'])
|
||||
assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets']
|
||||
assert page.evaluate('window.__savedDashboard.dashboard_hidden_widgets') == PRESET['dashboard_hidden_widgets']
|
||||
assert page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2
|
||||
assert not errors, (width, errors)
|
||||
context.close()
|
||||
print('PASS inline layout defaults, add/hide, live ticks, other preferences, cancel/save', width)
|
||||
# An already-persisted sample choice must override the factory 600.
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
# This sandbox blocks all network origins; stub browser storage before
|
||||
# loading the bundle rather than navigating to a real host.
|
||||
page.evaluate("""() => Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {getItem: key => key === 'rustproxy.overview.liveChartSamples' ? '120' : null,
|
||||
setItem: () => undefined},
|
||||
})""")
|
||||
load(page)
|
||||
assert page.evaluate('window.rustProxyOverviewSamples.getLimit()') == 120
|
||||
context.close()
|
||||
browser.close()
|
||||
print('PASS RustProxy 0.33.1 simulated browser layout regression')
|
||||
@@ -1,134 +0,0 @@
|
||||
import { renderAdminIndex } from './panel_html.mjs';
|
||||
// Deterministic Overview widget regression; simulated DOM/status, no running API.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const html = renderAdminIndex(root);
|
||||
const registry = JSON.parse(fs.readFileSync(path.join(root, 'src/admin/web/dashboard-widgets.json'), 'utf8'));
|
||||
const lang = JSON.parse(fs.readFileSync(path.join(root, 'langs/en_US.json'), 'utf8')).translations;
|
||||
const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8');
|
||||
const widgets = new Map(), ids = new Map();
|
||||
class Element {
|
||||
constructor() { this.hidden = false; this.attributes = new Map(); this.style = {}; this.textContent = '-'; }
|
||||
setAttribute(key, value) { this.attributes.set(key, String(value)); }
|
||||
removeAttribute(key) { this.attributes.delete(key); }
|
||||
getAttribute(key) { return this.attributes.get(key) ?? null; }
|
||||
}
|
||||
for (const [, id] of html.matchAll(/\bid="([^"]+)"/g)) {
|
||||
assert.ok(!ids.has(id), `duplicate DOM id: ${id}`);
|
||||
ids.set(id, new Element());
|
||||
}
|
||||
for (const [, id] of html.matchAll(/data-dashboard-widget="([^"]+)"/g)) widgets.set(id, new Element());
|
||||
assert.equal(registry.length, widgets.size, 'registry and DOM widget count must match');
|
||||
for (const entry of registry) assert.ok(widgets.has(entry.id), `registry references missing DOM card ${entry.id}`);
|
||||
let now = 1000;
|
||||
const document = {
|
||||
hidden: false,
|
||||
addEventListener() {},
|
||||
querySelector(query) { return widgets.get(query.match(/^\[data-dashboard-widget="([^"]+)"\]$/)?.[1]) ?? null; },
|
||||
};
|
||||
const window = { addEventListener() {}, rustProxyRefresh: { current: () => ({ overview_seconds: 3 }) } };
|
||||
const context = vm.createContext({
|
||||
window, document, performance: { now: () => now },
|
||||
currentPanel: 'overview', $: id => ids.get(id) ?? null,
|
||||
fmt: (v, digits = 0) => Number(v).toFixed(digits),
|
||||
fmtBytes: v => `${Number(v)} B`,
|
||||
t: key => lang[key] || key,
|
||||
console,
|
||||
});
|
||||
for (const script of ['app/rate-sampling.js', 'app/chart-scales.js', 'app/overview-extra-metrics.js', 'app/overview-wide-metrics.js']) {
|
||||
vm.runInContext(code(script), context, { filename: script });
|
||||
}
|
||||
const extra = window.rustProxyExtraOverview, wide = window.rustProxyWideOverview;
|
||||
const status = {
|
||||
revision: 9,
|
||||
system: { open_fds: 60, max_fds: 300 },
|
||||
cache: { hits: 100, misses: 50, resident_bytes: 150, hot_limit_bytes: 300,
|
||||
cold_queue_bytes: 25, cold_queue_limit_bytes: 100, cold_bytes: 400, cold_limit_bytes: 1000 },
|
||||
metrics: { requests: 300, http1_requests: 200, http2_requests: 80, http3_requests: 20,
|
||||
compression_gzip: 10, compression_br: 10, compression_zstd: 10, compression_deflate: 1 },
|
||||
transform_metrics: { applied: 10, cache_hits: 10, skipped: 10, errors: 1 },
|
||||
};
|
||||
extra.render(status);
|
||||
wide.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_cache_memory-meter-fill').style.width, '50%');
|
||||
assert.equal(ids.get('overview-extra-extra_cold_queue_memory-meter-fill').style.width, '25%');
|
||||
assert.equal(ids.get('overview-extra-extra_cold_storage-meter-fill').style.width, '40%');
|
||||
widgets.get('extra_cache_memory').hidden = false;
|
||||
widgets.get('extra_cache_hit_rate').hidden = false;
|
||||
widgets.get('extra_h2_share').hidden = false;
|
||||
widgets.get('extra_h1_share').hidden = false;
|
||||
widgets.get('extra_h3_share').hidden = false;
|
||||
widgets.get('extra_process_fds').hidden = false;
|
||||
extra.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_process_fds-meter-fill').style.width, '20%');
|
||||
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '-'); // first counter sample has no rate
|
||||
assert.equal(ids.get('overview-wide-wide_transform_activity-summary').textContent, '-');
|
||||
|
||||
now = 4000;
|
||||
status.cache.hits += 30;
|
||||
status.cache.misses += 10;
|
||||
status.metrics.requests += 40;
|
||||
status.metrics.http1_requests += 10;
|
||||
status.metrics.http2_requests += 20;
|
||||
status.metrics.http3_requests += 10;
|
||||
status.metrics.compression_gzip += 3;
|
||||
status.metrics.compression_br += 6;
|
||||
status.metrics.compression_zstd += 9;
|
||||
status.metrics.compression_deflate += 12;
|
||||
status.transform_metrics.applied += 15;
|
||||
status.transform_metrics.cache_hits += 3;
|
||||
status.transform_metrics.skipped += 6;
|
||||
status.transform_metrics.errors += 1;
|
||||
extra.render(status);
|
||||
wide.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '75.0%');
|
||||
assert.equal(ids.get('extra-cache-ratio-hit-arc').getAttribute('stroke-dasharray'), '75 25');
|
||||
assert.equal(ids.get('extra-cache-ratio-miss-arc').getAttribute('stroke-dashoffset'), '-75');
|
||||
assert.equal(ids.get('extra-cache-ratio-hit-count').textContent, '30');
|
||||
assert.equal(ids.get('extra-cache-ratio-miss-count').textContent, '10');
|
||||
assert.equal(ids.get('overview-extra-extra_h2_share-meter-fill').style.width, '50%');
|
||||
assert.equal(ids.get('overview-wide-wide_transform_activity-summary').textContent, '8.3/s');
|
||||
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '10.0/s');
|
||||
for (const name of ['applied', 'cache_hits', 'skipped', 'errors'])
|
||||
assert.ok(ids.get(`overview-wide-wide_transform_activity-${name}-line`).getAttribute('d')?.length);
|
||||
for (const name of ['gzip', 'br', 'zstd', 'deflate'])
|
||||
assert.ok(ids.get(`overview-wide-wide_compression_activity-${name}-line`).getAttribute('d')?.length);
|
||||
|
||||
// No hidden-tab spike or fake zero when a long interval is skipped.
|
||||
now = 100000;
|
||||
status.cache.hits += 300;
|
||||
status.cache.misses += 200;
|
||||
status.metrics.compression_gzip += 600;
|
||||
extra.render(status);
|
||||
wide.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '-');
|
||||
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '-');
|
||||
now = 103000;
|
||||
status.cache.hits += 5;
|
||||
status.cache.misses += 5;
|
||||
status.metrics.compression_gzip += 3;
|
||||
extra.render(status);
|
||||
wide.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '50.0%');
|
||||
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '1.0/s');
|
||||
|
||||
// Bounded metric with no configured budget remains unknown, not a made-up percentage.
|
||||
status.cache.cold_limit_bytes = 0;
|
||||
extra.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_cold_storage').textContent, '400 B');
|
||||
assert.equal(ids.get('overview-extra-extra_cold_storage-meter').getAttribute('aria-valuenow'), null);
|
||||
assert.equal(ids.get('overview-extra-extra_cold_storage-meter-fill').style.width, '0%');
|
||||
|
||||
extra.donut('cache-ratio', 75, 25);
|
||||
assert.equal(ids.get('cache-ratio-hit-arc').getAttribute('stroke-dasharray'), '75 25');
|
||||
assert.equal(ids.get('cache-ratio-chart').getAttribute('aria-label'), 'HIT 75, MISS 25');
|
||||
extra.donut('cache-ratio', 0, 0);
|
||||
assert.equal(ids.get('cache-ratio-hit-arc').getAttribute('stroke-dasharray'), '0 100');
|
||||
assert.ok(code('app/runtime.js').includes('rustProxyExtraOverview?.donut?.("cache-ratio"'),
|
||||
'main HIT/MISS tile must share tested donut rendering');
|
||||
console.log('PASS: bounded meters, COLD tiers, HIT/MISS rings, transform and compression rates, pause/no-limit');
|
||||
@@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression: named Overview presets, responsive modal and browser actions.
|
||||
|
||||
Runs the real panel DOM/CSS/bundled JavaScript with mocked preferences/API.
|
||||
This is browser layout testing, not Rust server acceptance.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text()
|
||||
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
|
||||
PACKS = {
|
||||
name: {'id': name, **json.loads((ROOT / 'langs' / (name + '.json')).read_text())}
|
||||
for name in ('en_US', 'pl_pl')
|
||||
}
|
||||
|
||||
|
||||
def app_bundle():
|
||||
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
|
||||
assert match
|
||||
body = ROUTER[match.end():]
|
||||
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
|
||||
result = []
|
||||
for line in body.splitlines():
|
||||
line = line.strip()
|
||||
include = re.search(r'include_str!\("([^"]+)"\)', line)
|
||||
if include:
|
||||
result.append((ROOT / 'src/admin' / include.group(1)).read_text())
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
result.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
|
||||
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
result.append(json.loads(line[:-1]))
|
||||
return '\n'.join(result)
|
||||
|
||||
|
||||
BUNDLE = app_bundle()
|
||||
|
||||
|
||||
def load(page):
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.add_script_tag(content=BUNDLE)
|
||||
page.wait_for_function('window.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyDashboardLayout')
|
||||
page.evaluate('''packs => {
|
||||
languagePacks = new Map(Object.entries(packs));
|
||||
defaultLanguage = 'en_US';
|
||||
language = 'pl_pl';
|
||||
document.documentElement.lang = 'pl-PL';
|
||||
document.querySelectorAll('#overview-presets-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
|
||||
}''', PACKS)
|
||||
page.evaluate('''() => {
|
||||
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
document.getElementById('overview-layout-unlock').hidden = false;
|
||||
document.getElementById('overview-layout-open').hidden = false;
|
||||
currentPanel = 'overview';
|
||||
window.__savedDashboard = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
|
||||
window.rustProxyPreferences.load = async () => window.__savedDashboard;
|
||||
window.rustProxyPreferences.current = () => window.__savedDashboard;
|
||||
window.rustProxyPreferences.update = async patch => {
|
||||
window.__savedDashboard = {...window.__savedDashboard, ...patch};
|
||||
applyAdminPreferences(window.__savedDashboard);
|
||||
return window.__savedDashboard;
|
||||
};
|
||||
applyAdminPreferences(window.__savedDashboard);
|
||||
language = 'pl_pl';
|
||||
document.documentElement.lang = 'pl-PL';
|
||||
document.querySelectorAll('#overview-presets-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
|
||||
}''')
|
||||
|
||||
|
||||
|
||||
# Regression for the replacement named-presets modal, with a simulated SQLite API.
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (320, 375, 800, 1440):
|
||||
context = browser.new_context(viewport={'width': width, 'height': 880})
|
||||
page = context.new_page()
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
load(page)
|
||||
page.evaluate('''() => {
|
||||
const saved = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
|
||||
window.__savedDashboard = saved;
|
||||
window.__presets = new Map();
|
||||
window.rustProxyPreferences.load = async () => window.__savedDashboard;
|
||||
window.rustProxyPreferences.current = () => window.__savedDashboard;
|
||||
window.rustProxyPreferences.update = async patch => {
|
||||
window.__savedDashboard = {...window.__savedDashboard, ...patch};
|
||||
applyAdminPreferences(window.__savedDashboard);
|
||||
return window.__savedDashboard;
|
||||
};
|
||||
api = async (path, options={}) => {
|
||||
const method = options.method || 'GET';
|
||||
if (path === '/api/dashboard-presets' && method === 'GET')
|
||||
return [...window.__presets.entries()].map(([name, data]) => ({ name, ...structuredClone(data) }));
|
||||
if (path.startsWith('/api/dashboard-presets/')) {
|
||||
const name = decodeURIComponent(path.split('/').slice(3).join('/'));
|
||||
if (method === 'PUT') { window.__presets.set(name, JSON.parse(options.body)); return {}; }
|
||||
if (method === 'DELETE') { window.__presets.delete(name); return {}; }
|
||||
if (method === 'PATCH') {
|
||||
const changed = JSON.parse(options.body).name;
|
||||
if (window.__presets.has(changed)) throw new Error('already exists');
|
||||
window.__presets.set(changed, window.__presets.get(name));
|
||||
window.__presets.delete(name);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
throw new Error('unexpected API: ' + method + ' ' + path);
|
||||
};
|
||||
appConfirm = async () => true;
|
||||
applyAdminPreferences(window.__savedDashboard);
|
||||
document.getElementById('overview-layout-open').hidden = false;
|
||||
}''')
|
||||
page.locator('#overview-layout-open').click()
|
||||
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
|
||||
assert page.locator('#overview-presets-list .overview-presets-row').count() == 0
|
||||
widths = page.evaluate('''() => {
|
||||
const d = document.getElementById('overview-presets-dialog');
|
||||
const r = d.getBoundingClientRect();
|
||||
return {left:r.left,right:r.right,overflow:d.scrollWidth - d.clientWidth};
|
||||
}''')
|
||||
assert widths['left'] >= -1 and widths['right'] <= width+1 and widths['overflow'] <= 2, (width, widths)
|
||||
# Persist the factory arrangement as a user preset.
|
||||
page.locator('#overview-presets-name').fill('Daily')
|
||||
page.locator('#overview-presets-save').click()
|
||||
page.wait_for_function("window.__presets.has('Daily')")
|
||||
assert page.locator('#overview-presets-list .overview-presets-row').count() == 1
|
||||
assert page.evaluate("window.__presets.get('Daily').overview_samples") == 600
|
||||
# Create another view, with customized widths, density and history.
|
||||
page.evaluate('''async () => {
|
||||
await window.rustProxyPreferences.update({
|
||||
dashboard_hidden_widgets:['requests'], dashboard_widget_sizes:{requests:3},
|
||||
dashboard_density:'ultra',
|
||||
});
|
||||
window.rustProxyOverviewSamples.setLimit(120, true);
|
||||
}''')
|
||||
page.locator('#overview-presets-name').fill('Detailed')
|
||||
page.locator('#overview-presets-save').click()
|
||||
page.wait_for_function("window.__presets.has('Detailed')")
|
||||
assert page.evaluate('''() => {
|
||||
const p=window.__presets.get('Detailed');
|
||||
return [p.dashboard_widget_sizes.requests,p.dashboard_density,p.overview_samples];
|
||||
}''') == [3,'ultra',120]
|
||||
# Named load must restore full layout and history.
|
||||
page.locator('.overview-presets-row').filter(has_text='Daily').locator('[data-preset-action="dashboardPresetsLoad"]').click()
|
||||
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
|
||||
assert page.evaluate('''() => [window.__savedDashboard.dashboard_density,
|
||||
window.__savedDashboard.dashboard_widget_sizes,
|
||||
window.rustProxyOverviewSamples.getLimit()]''') == ['compact',None,600]
|
||||
# Rename and delete remain administrator-scoped operations.
|
||||
page.locator('#overview-layout-open').click()
|
||||
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
|
||||
page.locator('#overview-presets-name').fill('Nightly')
|
||||
page.locator('.overview-presets-row').filter(has_text='Detailed').locator('[data-preset-action="dashboardPresetsRename"]').click()
|
||||
page.wait_for_function("window.__presets.has('Nightly')")
|
||||
assert not page.evaluate("window.__presets.has('Detailed')")
|
||||
page.locator('.overview-presets-row').filter(has_text='Nightly').locator('[data-preset-action="dashboardPresetsDelete"]').click()
|
||||
page.wait_for_function("!window.__presets.has('Nightly')")
|
||||
assert page.evaluate("window.__presets.has('Daily')")
|
||||
page.locator('#overview-presets-factory').click()
|
||||
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
|
||||
assert page.evaluate("window.__savedDashboard.dashboard_widgets") == json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text())['dashboard_widgets']
|
||||
assert not errors, (width, errors)
|
||||
print('PASS named Dashboard presets save/load/rename/delete/factory and responsive modal:', width)
|
||||
context.close()
|
||||
browser.close()
|
||||
print('PASS Overview preset modal (simulated SQLite API; no running Rust server)')
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""All Overview widget labels and small values in simulated Chromium.
|
||||
|
||||
This is a DOM/CSS regression test, not a live RustProxy integration test.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json
|
||||
from panel_html import render_admin_index
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
|
||||
PACKS = {name: json.loads((ROOT / f'langs/{name}.json').read_text(encoding='utf-8'))['translations']
|
||||
for name in ('en_US', 'pl_pl')}
|
||||
|
||||
CHECK = r'''({density, lang, translations}) => {
|
||||
document.documentElement.dataset.dashboardDensity = density;
|
||||
document.documentElement.lang = lang === 'pl_pl' ? 'pl-PL' : 'en-US';
|
||||
const grid = document.getElementById('dashboard-overview-grid');
|
||||
grid.querySelectorAll('[data-i18n]').forEach(node => {
|
||||
if (Object.hasOwn(translations, node.dataset.i18n)) node.textContent = translations[node.dataset.i18n];
|
||||
});
|
||||
const cards = [...grid.children].filter(node => node.hasAttribute('data-dashboard-widget'));
|
||||
const font = value => getComputedStyle(value).fontFamily;
|
||||
const probe = document.createElement('div');
|
||||
grid.append(probe);
|
||||
probe.style.fontFamily = 'var(--sans)'; const sans = font(probe);
|
||||
probe.style.fontFamily = 'var(--mono)'; const mono = font(probe);
|
||||
probe.remove();
|
||||
|
||||
const titleErrors = [];
|
||||
const titles = [];
|
||||
const labelErrors = [];
|
||||
const numericErrors = [];
|
||||
for (const card of cards) {
|
||||
let title;
|
||||
if (card.classList.contains('stat')) title = card.querySelector(':scope > span:first-child');
|
||||
else if (card.classList.contains('system-card') || card.classList.contains('connection-card'))
|
||||
title = card.querySelector('.overview-chart-heading');
|
||||
else if (card.querySelector('.mini-heading > h3')) title = card.querySelector('.mini-heading > h3');
|
||||
else title = card.querySelector('.card-heading h2');
|
||||
if (!title) { titleErrors.push(`${card.dataset.dashboardWidget}: missing title`); continue; }
|
||||
const style = getComputedStyle(title);
|
||||
titles.push(title);
|
||||
if (style.fontFamily !== sans || style.fontSize !== '12px' || style.textTransform !== 'none')
|
||||
titleErrors.push(`${card.dataset.dashboardWidget}: ${style.fontFamily} ${style.fontSize} ${style.textTransform}`);
|
||||
if (title.textContent.trim() === title.textContent.trim().toLocaleUpperCase('pl') &&
|
||||
/[a-ząćęłńóśżź]{4}/i.test(title.textContent) &&
|
||||
!/^(?:HIT|MISS|HTTP|CPU|RAM|WWW|RX|TX)/.test(title.textContent.trim()))
|
||||
titleErrors.push(`${card.dataset.dashboardWidget}: all-uppercase title ${title.textContent.trim()}`);
|
||||
for (const node of card.querySelectorAll(':scope > small, .mini-metrics dt, .mini-metrics dd, .mini-live-chart-head, .memory-line, .chart-footer, .meta, .small-label')) {
|
||||
if (font(node) !== mono) labelErrors.push(`${card.dataset.dashboardWidget}: ${node.outerHTML.slice(0, 85)}`);
|
||||
}
|
||||
const readings = card.classList.contains('stat')
|
||||
? [...card.querySelectorAll(':scope > strong'), ...card.querySelectorAll(':scope > small')]
|
||||
: [...card.querySelectorAll('.mini-metrics dd')];
|
||||
for (const node of readings) if (font(node) !== mono)
|
||||
numericErrors.push(`${card.dataset.dashboardWidget}: ${node.outerHTML.slice(0, 85)}`);
|
||||
}
|
||||
const nested = [...grid.querySelectorAll('.mini-live-chart > .mini-live-chart-head > span:first-child')];
|
||||
for (const node of nested) {
|
||||
const style = getComputedStyle(node);
|
||||
if (style.fontFamily !== mono || style.fontSize !== '10px' || style.textTransform !== 'none')
|
||||
labelErrors.push(`mini-live-chart: ${style.fontFamily} ${style.fontSize} ${style.textTransform}`);
|
||||
}
|
||||
const systemNumeric = getComputedStyle(document.querySelector('#system-cpu'));
|
||||
return {
|
||||
cardCount: cards.length, titleCount: titles.length, secondaryCount: nested.length,
|
||||
titleErrors, labelErrors, numericErrors,
|
||||
systemNumericMono: systemNumeric.fontFamily === mono,
|
||||
cpuLabel: document.querySelector('[data-dashboard-widget="system_chart"] .overview-chart-heading').textContent,
|
||||
connectionsLabel: document.querySelector('[data-dashboard-widget="connection_chart"] .overview-chart-heading').textContent,
|
||||
memoryLabel: document.querySelector('[data-dashboard-widget="memory"] > span:first-child').textContent,
|
||||
backendLabel: document.querySelector('[data-dashboard-widget="backend_health"] > span:first-child').textContent,
|
||||
diagnosticsTitle: document.querySelector('[data-dashboard-widget="process_diagnostics"] .mini-heading h3').textContent,
|
||||
};
|
||||
}'''
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (320, 375, 768, 1440, 1920):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 900})
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
for density in ('normal', 'compact', 'ultra'):
|
||||
for lang in ('en_US', 'pl_pl'):
|
||||
result = page.evaluate(CHECK, {'density': density, 'lang': lang, 'translations': PACKS[lang]})
|
||||
descriptor = (width, density, lang)
|
||||
assert result['cardCount'] == result['titleCount'] >= 75, (descriptor, result)
|
||||
assert result['secondaryCount'] >= 6, descriptor
|
||||
assert not result['titleErrors'], (descriptor, result['titleErrors'][:10])
|
||||
assert not result['labelErrors'], (descriptor, result['labelErrors'][:10])
|
||||
assert not result['numericErrors'], (descriptor, result['numericErrors'][:10])
|
||||
assert result['systemNumericMono'], descriptor
|
||||
expected = ('System load', 'Connection activity') if lang == 'en_US' else ('Obciążenie systemu', 'Aktywność połączeń')
|
||||
assert (result['cpuLabel'], result['connectionsLabel']) == expected, (descriptor, result)
|
||||
assert result['memoryLabel'] and result['backendLabel'] and result['diagnosticsTitle'], descriptor
|
||||
print(f'PASS all Overview titles and small readings at {width}px (3 densities, 2 languages)')
|
||||
page.close()
|
||||
browser.close()
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline SQLite regression for additive admin preset migration and 16-slot upsert."""
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
all_scripts = sorted((ROOT / 'migrations').glob('*.sql'))
|
||||
assert [p.name for p in all_scripts][-1] == '0010_dashboard_presets.sql'
|
||||
con = sqlite3.connect(':memory:')
|
||||
con.execute('PRAGMA foreign_keys = ON')
|
||||
for _ in range(2):
|
||||
for script in all_scripts:
|
||||
con.executescript(script.read_text())
|
||||
con.execute("INSERT INTO administrator(id,username,password_hash,generation,created_at) VALUES(1,'owner','hash',1,0)")
|
||||
con.execute("INSERT INTO admin_preferences(administrator_id,document) VALUES(1,'{}')")
|
||||
|
||||
upsert = '''
|
||||
INSERT INTO dashboard_presets(administrator_id, name, document, updated_at)
|
||||
SELECT a.id, ?, ?, ? FROM administrator a WHERE a.id = 1 AND a.username = ?
|
||||
AND ((SELECT count(*) FROM dashboard_presets WHERE administrator_id = a.id) < 16
|
||||
OR EXISTS(SELECT 1 FROM dashboard_presets WHERE administrator_id = a.id AND name = ?))
|
||||
ON CONFLICT(administrator_id, name) DO UPDATE SET document = excluded.document, updated_at = excluded.updated_at
|
||||
'''
|
||||
for i in range(16):
|
||||
name = f'Preset {i}'
|
||||
assert con.execute(upsert, (name,'{}',i,'owner',name)).rowcount == 1
|
||||
assert con.execute(upsert, ('Overflow','{}',16,'owner','Overflow')).rowcount == 0
|
||||
assert con.execute(upsert, ('Preset 0','{"changed":true}',17,'owner','Preset 0')).rowcount == 1
|
||||
assert con.execute(upsert, ('Other account','{}',18,'intruder','Other account')).rowcount == 0
|
||||
assert con.execute('SELECT count(*) FROM dashboard_presets').fetchone()[0] == 16
|
||||
assert con.execute('SELECT document FROM dashboard_presets WHERE name="Preset 0"').fetchone()[0] == '{"changed":true}'
|
||||
assert con.execute('UPDATE OR IGNORE dashboard_presets SET name=? WHERE name=?', ('Preset 1','Preset 0')).rowcount == 0
|
||||
assert con.execute('UPDATE OR IGNORE dashboard_presets SET name=? WHERE name=?', ('Renamed','Preset 0')).rowcount == 1
|
||||
con.execute('DELETE FROM administrator WHERE id=1')
|
||||
assert con.execute('SELECT count(*) FROM dashboard_presets').fetchone()[0] == 0
|
||||
assert con.execute('SELECT count(*) FROM admin_preferences').fetchone()[0] == 0
|
||||
print('PASS SQLite migrations repeated, named-preset CRUD, cap, account ownership and cascade')
|
||||
@@ -1,200 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""0.33.1 factory/default + unsaved layout regression against the full bundled JS.
|
||||
|
||||
The authenticated app bundle runs against simulated status/preferences; this is
|
||||
not a Rust server integration test.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PRESET = json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text())
|
||||
REGISTRY = json.loads((ROOT / 'src/admin/web/dashboard-widgets.json').read_text())
|
||||
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
|
||||
|
||||
|
||||
def app_bundle():
|
||||
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
|
||||
assert match
|
||||
body = ROUTER[match.end():]
|
||||
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
|
||||
result = []
|
||||
for line in body.splitlines():
|
||||
line = line.strip()
|
||||
include = re.search(r'include_str!\("([^"]+)"\)', line)
|
||||
if include:
|
||||
result.append((ROOT / 'src/admin' / include.group(1)).read_text())
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
result.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
|
||||
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
result.append(json.loads(line[:-1]))
|
||||
return '\n'.join(result)
|
||||
|
||||
|
||||
assert len(PRESET['dashboard_widgets']) == len(REGISTRY)
|
||||
assert set(PRESET['dashboard_widgets']) == {w['id'] for w in REGISTRY}
|
||||
assert len(set(PRESET['dashboard_hidden_widgets'])) == len(PRESET['dashboard_hidden_widgets'])
|
||||
assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_widgets']))
|
||||
assert PRESET['dashboard_density'] == 'compact' and PRESET['overview_samples'] == 600
|
||||
assert sum(w['id'].startswith('www_') and w['id'] in PRESET['dashboard_hidden_widgets'] for w in REGISTRY) == 12
|
||||
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text()
|
||||
BUNDLE = app_bundle()
|
||||
assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =')
|
||||
|
||||
|
||||
def load(page):
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.add_script_tag(content=BUNDLE)
|
||||
page.wait_for_function('window.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyBackendDashboard')
|
||||
|
||||
|
||||
def inspect(page, ids):
|
||||
return page.evaluate('''ids => Object.fromEntries(ids.map(id => {
|
||||
const node = document.querySelector(`[data-dashboard-widget="${id}"]`);
|
||||
return [id, {hidden:node.hidden, display:getComputedStyle(node).display}];
|
||||
}))''', ids)
|
||||
|
||||
|
||||
def assert_visible(page, visible, hidden):
|
||||
result = inspect(page, visible + hidden)
|
||||
for widget in visible:
|
||||
assert result[widget]['hidden'] is False and result[widget]['display'] != 'none', (widget, result)
|
||||
for widget in hidden:
|
||||
assert result[widget]['hidden'] is True and result[widget]['display'] == 'none', (widget, result)
|
||||
|
||||
|
||||
|
||||
# Test actual production CSS/JS in a browser with simulated preferences/status.
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in [320, 375, 800, 1440, 1920]:
|
||||
context = browser.new_context(viewport={'width': width, 'height': 1000})
|
||||
page = context.new_page()
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
load(page)
|
||||
page.evaluate('''() => {
|
||||
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
document.getElementById('overview-layout-unlock').hidden = false;
|
||||
document.getElementById('overview-layout-open').hidden = false;
|
||||
currentPanel = 'overview';
|
||||
window.__savedDashboard = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
|
||||
window.rustProxyPreferences.load = async () => window.__savedDashboard;
|
||||
window.rustProxyPreferences.current = () => window.__savedDashboard;
|
||||
window.rustProxyPreferences.update = async patch => {
|
||||
window.__savedDashboard = {...window.__savedDashboard, ...patch};
|
||||
applyAdminPreferences(window.__savedDashboard);
|
||||
return window.__savedDashboard;
|
||||
};
|
||||
applyAdminPreferences(window.__savedDashboard);
|
||||
api = async path => { if (path === '/api/dashboard-presets') return []; throw new Error('unexpected API ' + path); };
|
||||
appConfirm = async () => true;
|
||||
}''')
|
||||
# Unchanged previous accounts preserve historic grouped rows.
|
||||
assert page.evaluate('''() => ({
|
||||
legacy: document.getElementById('dashboard-overview-grid').dataset.overviewLayoutMode,
|
||||
widths:window.__savedDashboard.dashboard_widget_sizes,
|
||||
})''') == {'legacy':'legacy','widths':None}
|
||||
page.locator('#overview-layout-unlock').click()
|
||||
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
|
||||
# Widen a number+sparkline card from 2 to 3 and then 4 columns.
|
||||
selector = '[data-dashboard-widget="http_protocol"]'
|
||||
old_width = page.locator(selector).evaluate('(node) => node.getBoundingClientRect().width')
|
||||
page.locator(selector + ' .overview-inline-widen').click()
|
||||
assert page.locator(selector).get_attribute('data-dashboard-span') == '3'
|
||||
page.locator(selector + ' .overview-inline-widen').click()
|
||||
assert page.locator(selector).get_attribute('data-dashboard-span') == '4'
|
||||
assert page.locator(selector).evaluate('(node) => node.getBoundingClientRect().width') >= old_width * 1.7
|
||||
assert page.evaluate('window.rustProxyInlineLayout.currentDraft().sizes.http_protocol') == 4
|
||||
# Minimize a large multi-series chart to the new 3-column option.
|
||||
for _ in range(2):
|
||||
page.locator('[data-dashboard-widget="wide_transform_activity"] .overview-inline-narrow').click()
|
||||
assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '3'
|
||||
# Real UI controls allow crossing old groups: send mini-chart to the front
|
||||
# with the upward arrow, then across the original stat/chart boundary.
|
||||
# A direct drag onto the first card also uses the actual event handlers.
|
||||
page.evaluate('''() => {
|
||||
const from = document.querySelector('[data-dashboard-widget="wide_transform_activity"] .overview-inline-grip');
|
||||
const to = document.querySelector('[data-dashboard-widget="requests"] .overview-inline-grip');
|
||||
const transfer = new DataTransfer();
|
||||
from.dispatchEvent(new DragEvent('dragstart', {bubbles:true, cancelable:true, dataTransfer:transfer}));
|
||||
to.dispatchEvent(new DragEvent('drop', {bubbles:true, cancelable:true, dataTransfer:transfer, clientX:0}));
|
||||
from.dispatchEvent(new DragEvent('dragend', {bubbles:true}));
|
||||
}''')
|
||||
assert page.evaluate('window.rustProxyInlineLayout.currentDraft().order[0]') == 'wide_transform_activity'
|
||||
# Background status and unrelated preference changes cannot reset live sizes.
|
||||
page.evaluate('''() => {
|
||||
const status={revision:5,backend_pools:[],streams:{active:[],desired:[],metrics:{}}};
|
||||
window.rustProxyBackendDashboard.render(status);
|
||||
window.rustProxyStreamDashboard.render(status);
|
||||
applyAdminPreferences({...window.__savedDashboard, chart_style:'soft'});
|
||||
}''')
|
||||
assert page.locator(selector).get_attribute('data-dashboard-span') == '4'
|
||||
# Cancel should remove both size and layout changes.
|
||||
page.locator('#overview-inline-cancel').click()
|
||||
assert page.locator(selector).get_attribute('data-dashboard-span') == '2'
|
||||
assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '6'
|
||||
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None
|
||||
# The actual unlocked dashboard remains the only layout editor.
|
||||
page.locator('#overview-layout-unlock').click()
|
||||
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
|
||||
tile = '[data-dashboard-widget="wide_transform_activity"]'
|
||||
page.locator(tile + ' .overview-inline-narrow').click()
|
||||
assert page.locator(tile).get_attribute('data-dashboard-span') == '4'
|
||||
page.locator(tile + ' .overview-inline-narrow').click()
|
||||
assert page.locator(tile).get_attribute('data-dashboard-span') == '3'
|
||||
page.evaluate('''() => {
|
||||
const from = document.querySelector('[data-dashboard-widget="wide_transform_activity"] .overview-inline-grip');
|
||||
const to = document.querySelector('[data-dashboard-widget="requests"] .overview-inline-grip');
|
||||
const transfer = new DataTransfer();
|
||||
from.dispatchEvent(new DragEvent('dragstart', {bubbles:true,cancelable:true,dataTransfer:transfer}));
|
||||
to.dispatchEvent(new DragEvent('drop', {bubbles:true,cancelable:true,dataTransfer:transfer,clientX:0}));
|
||||
from.dispatchEvent(new DragEvent('dragend', {bubbles:true}));
|
||||
}''')
|
||||
page.locator('#overview-inline-save').click()
|
||||
page.wait_for_function('!window.rustProxyInlineLayout.isEditing()')
|
||||
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes.wide_transform_activity') == 3
|
||||
assert page.evaluate('window.__savedDashboard.dashboard_widgets[0]') == 'wide_transform_activity'
|
||||
# Reload account settings: new card width persists.
|
||||
page.evaluate('applyAdminPreferences(window.__savedDashboard)')
|
||||
assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '3'
|
||||
assert page.evaluate('window.__savedDashboard.dashboard_density') == 'compact'
|
||||
# Four charts in one row, set through the same layout function used by UI.
|
||||
data = page.evaluate('''() => {
|
||||
const ids=['wide_transform_activity','wide_compression_activity','http_protocol','websocket_protocol'];
|
||||
const order=[...ids,...ADMIN_DASHBOARD_WIDGETS.filter(id => !ids.includes(id))];
|
||||
const hidden=[...ADMIN_DASHBOARD_WIDGETS.filter(id => !ids.includes(id))];
|
||||
const sizes=Object.fromEntries(ids.map(id => [id,3]));
|
||||
applyDashboardLayout(order,hidden,sizes);
|
||||
const grid=document.getElementById('dashboard-overview-grid');
|
||||
return { gridWidth:grid.getBoundingClientRect().width,
|
||||
template:getComputedStyle(grid).gridTemplateColumns.split(' ').length,
|
||||
boxes:ids.map(id=>{const b=document.querySelector(`[data-dashboard-widget="${id}"]`).getBoundingClientRect();return {id,x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),scroll:b.width};})
|
||||
};
|
||||
}''')
|
||||
if data['template'] == 12:
|
||||
assert len(set(b['y'] for b in data['boxes'])) == 1, (width, data)
|
||||
assert all(b['w'] > 150 for b in data['boxes']), (width,data)
|
||||
assert page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2, (width, data)
|
||||
page.locator('#overview-layout-open').click()
|
||||
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
|
||||
page.locator('#overview-presets-factory').click()
|
||||
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
|
||||
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None
|
||||
assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets']
|
||||
assert page.evaluate('document.getElementById("dashboard-overview-grid").dataset.overviewLayoutMode') == 'legacy'
|
||||
assert not errors, (width, errors)
|
||||
print('PASS free-order, resize, live refresh, cancel/save, four chart grid', width, data['template'])
|
||||
context.close()
|
||||
browser.close()
|
||||
print('PASS 0.33.3 resizable Overview browser regression')
|
||||
@@ -1,115 +0,0 @@
|
||||
import { renderAdminIndex } from './panel_html.mjs';
|
||||
// Simulated DOM/status regression: no holes or artificial zeros after tab resume.
|
||||
// This intentionally does not exercise the running Rust server or real browser.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8');
|
||||
const html = renderAdminIndex(root);
|
||||
|
||||
class Element {
|
||||
constructor() { this.attributes = new Map(); this.style = {}; this.textContent = ''; this.children = []; this.hidden = false; }
|
||||
getAttribute(key) { return this.attributes.get(key) ?? null; }
|
||||
setAttribute(key, value) { this.attributes.set(key, String(value)); }
|
||||
removeAttribute(key) { this.attributes.delete(key); }
|
||||
append(...children) { this.children.push(...children); }
|
||||
replaceChildren(...children) { this.children = children; }
|
||||
}
|
||||
const ids = new Map([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, new Element()]));
|
||||
const widgets = new Map([...html.matchAll(/data-dashboard-widget="([^"]+)"/g)].map(([, id]) => [id, new Element()]));
|
||||
const listeners = new Map();
|
||||
const document = {
|
||||
hidden: false,
|
||||
addEventListener: (type, handler) => listeners.set(type, handler),
|
||||
querySelector: selector => widgets.get(selector.match(/^\[data-dashboard-widget="([^"]+)"\]$/)?.[1]) || null,
|
||||
createElement: () => new Element(),
|
||||
};
|
||||
const samples = {
|
||||
getLimit: () => 60,
|
||||
trim: values => { if (values.length > 60) values.splice(0, values.length - 60); },
|
||||
onChange: () => {},
|
||||
};
|
||||
const window = {
|
||||
addEventListener() {}, rustProxyOverviewSamples: samples,
|
||||
rustProxyRefresh: { current: () => ({ overview_seconds: 3 }) },
|
||||
};
|
||||
let now = 1000;
|
||||
const context = vm.createContext({
|
||||
window, document, performance: { now: () => now }, currentPanel: 'overview', latestStatus: null,
|
||||
$: id => ids.get(id) || null, fmt: (value, n = 0) => Number(value).toFixed(n),
|
||||
fmtBytes: value => `${value} B`, t: value => value,
|
||||
});
|
||||
for (const file of ['app/rate-sampling.js', 'app/chart-scales.js', 'app/overview-wide-metrics.js', 'app/web-overview.js'])
|
||||
vm.runInContext(code(file), context, { filename: file });
|
||||
|
||||
const web = {
|
||||
requests: 200, status_2xx: 190, status_3xx: 2, status_4xx: 5, status_5xx: 3,
|
||||
static_requests: 100, php_requests: 80, other_requests: 20,
|
||||
methods: [160, 20, 10, 5, 5], selected_status: [0, 0, 0, 0, 0, 0, 0, 0],
|
||||
response_headers_us: 300000, response_bytes: 1200000, active_headers: 2,
|
||||
};
|
||||
const php = {
|
||||
metrics: { requests: 80, response_headers_us: 100000, response_bytes: 320000, failures: Array(9).fill(0) },
|
||||
capacity_limit: 6, capacity_used: 1,
|
||||
};
|
||||
const status = {
|
||||
revision: 4,
|
||||
metrics: { requests: 200, http1_requests: 120, http2_requests: 60, http3_requests: 20,
|
||||
compression_gzip: 10, compression_br: 20, compression_zstd: 5, compression_deflate: 8 },
|
||||
cache: { hits: 50, misses: 30 },
|
||||
transform_metrics: { applied: 10, cache_hits: 5, skipped: 1, errors: 0 },
|
||||
web_server_metrics: { demo: { web, php } },
|
||||
};
|
||||
function advance(delta) {
|
||||
for (const key of Object.keys(status.metrics)) status.metrics[key] += delta;
|
||||
for (const key of ['hits', 'misses']) status.cache[key] += delta;
|
||||
for (const key of Object.keys(status.transform_metrics)) status.transform_metrics[key] += delta;
|
||||
for (const key of ['requests', 'status_2xx', 'static_requests', 'php_requests', 'response_headers_us', 'response_bytes']) web[key] += delta;
|
||||
for (const key of ['requests', 'response_headers_us', 'response_bytes']) php.metrics[key] += delta;
|
||||
}
|
||||
function render() { window.rustProxyWideOverview.render(status); window.rustProxyWebOverview.render(status); }
|
||||
const line = id => ids.get(id).getAttribute('d') || '';
|
||||
const compression = 'overview-wide-wide_compression_activity-gzip-line';
|
||||
const website = 'www_requests-all-line';
|
||||
const phpLine = 'www_php_requests-line';
|
||||
const starts = path => (path.match(/M/g) || []).length;
|
||||
|
||||
render(); // no first-interval rate: establish baseline only
|
||||
now = 4000; advance(12); render();
|
||||
const beforeWide = line(compression), beforeWeb = line(website);
|
||||
const beforePhp = ids.get(phpLine).getAttribute('points');
|
||||
assert.equal(starts(beforeWide), 1);
|
||||
assert.equal(starts(beforeWeb), 1);
|
||||
assert.ok(beforePhp);
|
||||
|
||||
// Background tab -> a new generation, regardless of counter increments.
|
||||
document.hidden = true; listeners.get('visibilitychange')();
|
||||
document.hidden = false;
|
||||
now = 100000; advance(600); render();
|
||||
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '-');
|
||||
assert.equal(ids.get('www_requests-value').textContent, '-');
|
||||
assert.equal(line(compression), beforeWide, 'wide rate chart must not receive a null point');
|
||||
assert.equal(line(website), beforeWeb, 'WWW rate chart must not receive a null point');
|
||||
assert.equal(ids.get(phpLine).getAttribute('points'), beforePhp, 'small PHP chart must preserve valid history');
|
||||
|
||||
// A fresh interval then connects to previous valid samples without a gap.
|
||||
now = 103000; advance(9); render();
|
||||
assert.equal(starts(line(compression)), 1, 'wide chart must have one connected path');
|
||||
assert.equal(starts(line(website)), 1, 'WWW chart must have one connected path');
|
||||
assert.ok(line(compression).includes('L'));
|
||||
assert.ok(line(website).includes('L'));
|
||||
|
||||
// Real measured zeros must continue to be plotted, not treated as pauses.
|
||||
now = 106000; render();
|
||||
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '0.0/s');
|
||||
assert.equal(ids.get('www_requests-value').textContent, '0.0/s');
|
||||
assert.equal(starts(line(compression)), 1);
|
||||
assert.equal(starts(line(website)), 1);
|
||||
|
||||
assert.match(code('web-server.js'), /if \(record && rate !== null\) \{ previous\.push\(rate\)/,
|
||||
'Web Server chart must also skip unavailable rates instead of plotting a zero dip');
|
||||
console.log('PASS: resumed wide/WWW/PHP charts stay connected; first invalid rate is unavailable; measured zero persists');
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""0.33.6 plot size/scale checks in simulated Chromium, not a running RustProxy."""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
import re
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
|
||||
SCALES = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
|
||||
RATES = (ROOT / 'src/admin/web/app/rate-sampling.js').read_text(encoding='utf-8')
|
||||
WIDE = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
|
||||
|
||||
WIDE_IDS = ('wide_protocol_rates', 'wide_cpu', 'wide_memory', 'wide_cache_activity',
|
||||
'wide_connections', 'wide_backend_health', 'wide_latency',
|
||||
'wide_transform_activity', 'wide_compression_activity',
|
||||
'www_requests', 'www_latency', 'www_failures', 'www_traffic', 'www_status_trend')
|
||||
|
||||
with sync_playwright() as api:
|
||||
browser = api.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (320, 375, 768, 1440, 1920):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 920})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.evaluate('''() => {
|
||||
document.documentElement.classList.remove('app-loading','app-startup-failed');
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
document.getElementById('dashboard-overview-grid').classList.add('is-aligned');
|
||||
for (const card of document.querySelectorAll('#dashboard-overview-grid>[data-dashboard-widget]'))
|
||||
card.hidden = !['wide_protocol_rates','wide_cpu','wide_memory','wide_cache_activity',
|
||||
'wide_connections','wide_backend_health','wide_latency',
|
||||
'wide_transform_activity','wide_compression_activity',
|
||||
'www_requests','www_latency','www_failures','www_traffic','www_status_trend'].includes(card.dataset.dashboardWidget);
|
||||
window.currentPanel='overview';
|
||||
window.$ = id => document.getElementById(id);
|
||||
window.fmt = (n, digits=0) => Number(n).toFixed(digits);
|
||||
window.fmtBytes = n => `${n} B`;
|
||||
window.rustProxyOverviewSamples = {getLimit:()=>60,onChange:()=>{}};
|
||||
window.rustProxyRefresh = {current:()=>({overview_seconds:3})};
|
||||
window.__clock = 1000;
|
||||
Object.defineProperty(performance,'now',{configurable:true,value:()=>window.__clock});
|
||||
}''')
|
||||
for script in (RATES, SCALES, WIDE):
|
||||
page.add_script_tag(content=script)
|
||||
for density in ('normal','compact','ultra'):
|
||||
measured = page.evaluate('''density => {
|
||||
document.documentElement.dataset.dashboardDensity=density;
|
||||
const results={};
|
||||
for (const card of document.querySelectorAll('#dashboard-overview-grid > .overview-wide-chart')) {
|
||||
const svg=card.querySelector('svg.overview-wide-svg');
|
||||
if (!svg) continue;
|
||||
const r=svg.getBoundingClientRect();
|
||||
results[card.dataset.dashboardWidget]={height:r.height, width:r.width,
|
||||
grid:svg.querySelector('.chart-grid').getAttribute('d'),
|
||||
contentWidth:Math.hypot(svg.getScreenCTM().a,svg.getScreenCTM().b)*600};
|
||||
}
|
||||
document.getElementById('panel-transform').hidden=false;
|
||||
document.getElementById('panel-cache').hidden=false;
|
||||
for(const id of ['transform-live-chart','cache-live-chart'])document.getElementById(id).open=true;
|
||||
results.transformDetails=document.getElementById('transform-live-activity-chart').getBoundingClientRect().height;
|
||||
results.cacheDetails=document.getElementById('cache-live-activity-chart').getBoundingClientRect().height;
|
||||
return results;
|
||||
}''', density)
|
||||
assert abs(measured['transformDetails'] - 180) < 1, (width,density,measured)
|
||||
assert abs(measured['cacheDetails'] - 180) < 1, (width,density,measured)
|
||||
for name in WIDE_IDS:
|
||||
data=measured[name]
|
||||
assert abs(data['height']-125)<1, (width,density,name,data)
|
||||
assert data['width']>50 and abs(data['width']-data['contentWidth'])<2, (width,density,name,data)
|
||||
assert data['grid']=='M0 47H600 M0 77H600 M0 107H600', (width,density,name,data)
|
||||
result = page.evaluate('''() => {
|
||||
const c=window.rustProxyChartScale;
|
||||
const first=c.peak('probe:rate','rate',[[.2,.7]]);
|
||||
const count=c.peak('probe:count','count',[[0,1]]);
|
||||
const latency=c.peak('probe:ms','ms',[[.1,1.5]]);
|
||||
const memory=c.peak('probe:bytes','bytes',[[1000]]);
|
||||
const high=c.peak('probe:rate','rate',[[101]]);
|
||||
const held=c.peak('probe:rate','rate',[[.3]]);
|
||||
c.reset('probe:');
|
||||
const reset=c.peak('probe:rate','rate',[[.3]]);
|
||||
const percent=c.peak('probe:cpu','percent',[[.1,500]]);
|
||||
const blank=c.peak('probe:blank','rate',[[null,NaN,Infinity]]);
|
||||
const before={revision:10,live_system:{cpu_percent:0.7,process_cpu_percent:0.2},
|
||||
metrics:{compression_gzip:50,compression_br:100,compression_zstd:10,compression_deflate:5},
|
||||
transform_metrics:{applied:2,cache_hits:10,skipped:1,errors:0}};
|
||||
window.rustProxyWideOverview.render(before);
|
||||
window.__clock=4000;
|
||||
before.metrics.compression_gzip+=3;before.metrics.compression_br+=6;
|
||||
before.metrics.compression_zstd+=0;before.metrics.compression_deflate+=0;
|
||||
before.transform_metrics.applied+=6;before.transform_metrics.cache_hits+=3;
|
||||
window.rustProxyWideOverview.render(before);
|
||||
const get = (id,key) => document.getElementById(`overview-wide-${id}-${key}-line`).getAttribute('d');
|
||||
const compressionLow=get('wide_compression_activity','gzip');
|
||||
const transformLow=get('wide_transform_activity','applied');
|
||||
const cpu=get('wide_cpu','system');
|
||||
const lowLabel=document.getElementById('overview-wide-wide_compression_activity-gzip-value').textContent;
|
||||
window.__clock=7000;
|
||||
before.metrics.compression_gzip+=603; // now 201/s => ceiling expands to 500/s
|
||||
before.transform_metrics.applied+=603;
|
||||
window.rustProxyWideOverview.render(before);
|
||||
const highLabel=document.getElementById('overview-wide-wide_compression_activity-gzip-value').textContent;
|
||||
const compressionHigh=get('wide_compression_activity','gzip');
|
||||
window.__clock=10000;
|
||||
before.metrics.compression_gzip+=3;
|
||||
before.transform_metrics.applied+=3;
|
||||
window.rustProxyWideOverview.render(before);
|
||||
const afterSpike=get('wide_compression_activity','gzip');
|
||||
return {first,count,latency,memory,high,held,reset,percent,blank,
|
||||
lowLabel,highLabel,compressionLow,transformLow,cpu,compressionHigh,afterSpike};
|
||||
}''')
|
||||
assert result['first']==100 and result['count']==10 and result['latency']==100, (width,result)
|
||||
assert result['memory']>=64*1024*1024, (width,result)
|
||||
assert (result['high'],result['held'],result['reset'],result['percent'],result['blank']) == (200,200,100,100,100), (width,result)
|
||||
assert result['lowLabel']=='1.0/s' and result['highLabel']=='201.0/s', (width,result)
|
||||
def yvalues(line): return [int(n) for n in re.findall(r'[ML]\d+ (\d+)',line)]
|
||||
assert yvalues(result['compressionLow'])[-1]>=135, (width,result)
|
||||
assert yvalues(result['transformLow'])[-1]>=130, (width,result)
|
||||
assert yvalues(result['cpu'])==[136,136], (width,result)
|
||||
assert yvalues(result['compressionHigh'])[-1]<100, (width,result)
|
||||
assert yvalues(result['afterSpike'])[-1]>=135, (width,result)
|
||||
assert not errors, (width,errors)
|
||||
print('PASS all 14 overview-wide plot sizes/grids + full Cache/Transform charts + stable scales:', width)
|
||||
page.close()
|
||||
browser.close()
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simulated Chromium regression for unified subdued metric/chart titles.
|
||||
|
||||
Tests computed CSS in the assembled panel DOM; not a live Rust server.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
|
||||
|
||||
CHECK = r'''({theme, density}) => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.dataset.dashboardDensity = density;
|
||||
const grid = document.getElementById('dashboard-overview-grid');
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = 'var(--muted)';
|
||||
document.body.append(probe);
|
||||
const expected = getComputedStyle(probe).color;
|
||||
probe.style.color = 'var(--text)';
|
||||
const primary = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
|
||||
const color = node => getComputedStyle(node).color;
|
||||
const cards = [...grid.children].filter(node => node.hasAttribute('data-dashboard-widget'));
|
||||
const titles = cards.map(card => {
|
||||
let node;
|
||||
if (card.classList.contains('stat')) node = card.querySelector(':scope > span:first-child');
|
||||
else if (card.matches('.system-card,.connection-card')) node = card.querySelector('.overview-chart-heading');
|
||||
else node = card.querySelector('.mini-heading > h3') || card.querySelector('.card-heading h2');
|
||||
return [card.dataset.dashboardWidget, node];
|
||||
});
|
||||
const otherSelectors = [
|
||||
'#panel-cache .stats-grid .stat > span:first-child',
|
||||
'#panel-transform .stats-grid .stat > span:first-child',
|
||||
'#panel-traffic .stat > span:first-child',
|
||||
'#panel-cache > .card .card-heading h2',
|
||||
'#panel-transform > .card .card-heading h2',
|
||||
'.traffic-chart-card > h3.subheading',
|
||||
'.function-live-chart-heading > strong',
|
||||
'.mini-live-chart-head > span:first-child',
|
||||
'.vhost-chart-head > span:first-child',
|
||||
'.vhost-live-metric > span',
|
||||
];
|
||||
// Include dynamically rendered Vhost/Streams chart and Vhost metric titles.
|
||||
const container = document.getElementById('panel-vhosts');
|
||||
const fixture = document.createElement('div');
|
||||
fixture.innerHTML = `<div class="vhost-chart"><div class="vhost-chart-head">
|
||||
<span class="small-label">Connections history</span><span class="fine">42/s</span>
|
||||
</div></div><div class="vhost-live-metric"><span>Requests</span><strong>42</strong></div>`;
|
||||
container.append(fixture);
|
||||
const otherTitles = otherSelectors.flatMap(selector => [...document.querySelectorAll(selector)]
|
||||
.map(node => [selector, node]));
|
||||
const errors = [...titles, ...otherTitles].flatMap(([name, node]) => {
|
||||
if (!node) return [`${name} missing title`];
|
||||
if (color(node) !== expected) return [`${name}: ${color(node)} != ${expected} (${node.textContent.trim().slice(0, 35)})`];
|
||||
return [];
|
||||
});
|
||||
const numeric = [
|
||||
document.querySelector('#system-cpu'),
|
||||
document.querySelector('#panel-overview .system-card .card-heading h2'),
|
||||
document.querySelector('#panel-overview .connection-card .card-heading h2'),
|
||||
document.querySelector('[data-dashboard-widget="requests"] > strong'),
|
||||
fixture.querySelector('.vhost-live-metric strong'),
|
||||
].map(node => ({node: node && node.outerHTML.slice(0, 80), color: node && color(node)}));
|
||||
fixture.remove();
|
||||
return {expected, primary, count: titles.length, otherCount: otherTitles.length,
|
||||
errors, numeric};
|
||||
}'''
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (320, 768, 1440):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 900})
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
for theme in ('dark', 'light', 'ultra_dark'):
|
||||
for density in ('normal', 'compact', 'ultra'):
|
||||
result = page.evaluate(CHECK, {'theme': theme, 'density': density})
|
||||
descriptor = width, theme, density
|
||||
assert result['count'] == 78, (descriptor, result['count'])
|
||||
assert result['otherCount'] >= 35, (descriptor, result['otherCount'])
|
||||
assert result['primary'] != result['expected'], (descriptor, result)
|
||||
assert not result['errors'], (descriptor, result['errors'][:25])
|
||||
assert all(item['color'] == result['primary'] for item in result['numeric']), (descriptor, result['numeric'])
|
||||
print(f'PASS muted titles, primary numeric readings at {width}px (3 themes, 3 densities)')
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS subdued metric/chart title CSS (simulated Chromium, no Rust runtime)')
|
||||
@@ -1,116 +0,0 @@
|
||||
import { renderAdminIndex } from './panel_html.mjs';
|
||||
// Simulated browser/status contract: this does not exercise the Rust server.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const html = renderAdminIndex(root);
|
||||
const controllerSource = read('src/admin/web/app/overview-samples.js');
|
||||
const store = new Map();
|
||||
|
||||
assert.match(html, /id="overview-layout-unlock"/);
|
||||
// History controls share the connected-core eyebrow row (not a separate
|
||||
// dashboard row), and navigation shows them only on Overview.
|
||||
const metaStart = html.indexOf('class="page-intro-meta-row"');
|
||||
const metaEnd = html.indexOf('class="page-intro-title-row"');
|
||||
const statusPos = html.indexOf('id="connection-label"');
|
||||
const selectPos = html.indexOf('id="overview-live-samples"');
|
||||
assert.ok(metaStart < statusPos && statusPos < selectPos && selectPos < metaEnd);
|
||||
assert.ok(selectPos < html.indexOf('id="panel-overview"'));
|
||||
assert.match(html, /id="overview-samples-toolbar" hidden role="group"/);
|
||||
assert.match(read('src/admin/web/app/core.js'), /\$\("overview-samples-toolbar"\)\.hidden = name !== "overview"/);
|
||||
assert.ok(html.indexOf('id="overview-live-samples"') < html.indexOf('id="overview-layout-unlock"'));
|
||||
assert.ok(html.indexOf('id="overview-live-samples"') < html.indexOf('id="dashboard-overview-grid"'));
|
||||
for (const sample of [60, 120, 300, 600]) assert.match(html, new RegExp(`<option value="${sample}">`));
|
||||
|
||||
class Element {
|
||||
constructor() { this.listeners = {}; this.value = ''; this.dataset = {}; this.disabled = false; this.attributes = new Map(); this.textContent = ''; this.hidden = false; }
|
||||
addEventListener(name, callback) { this.listeners[name] = callback; }
|
||||
fire(name) { this.listeners[name](); }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.get(name); }
|
||||
}
|
||||
const nodes = new Map();
|
||||
for (const id of ['overview-live-samples', 'overview-samples-save', 'overview-samples-state']) nodes.set(id, new Element());
|
||||
const widgets = new Map([['wide_compression_activity', new Element()]]);
|
||||
const document = {
|
||||
readyState: 'complete', getElementById: id => nodes.get(id) || null,
|
||||
querySelector: query => widgets.get(query.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null,
|
||||
};
|
||||
const localStorage = {
|
||||
getItem: key => store.get(key) ?? null,
|
||||
setItem: (key, value) => { store.set(key, value); },
|
||||
};
|
||||
let now = 1000;
|
||||
const window = { rustProxyRefresh: { current: () => ({ overview_seconds: 3 }) } };
|
||||
const context = vm.createContext({ document, localStorage, window, queueMicrotask: callback => callback(),
|
||||
t: key => key, performance: { now: () => now },
|
||||
currentPanel: 'overview', latestStatus: null, $: id => { if (!nodes.has(id)) nodes.set(id, new Element()); return nodes.get(id); },
|
||||
fmt: n => String(n), fmtBytes: n => `${n} B`,
|
||||
});
|
||||
vm.runInContext(controllerSource, context);
|
||||
const samples = window.rustProxyOverviewSamples;
|
||||
const select = document.getElementById('overview-live-samples');
|
||||
const save = document.getElementById('overview-samples-save');
|
||||
const state = document.getElementById('overview-samples-state');
|
||||
assert.equal(samples.getLimit(), 60);
|
||||
assert.equal(save.disabled, true);
|
||||
select.value = '600'; select.fire('change');
|
||||
assert.equal(samples.getLimit(), 600);
|
||||
assert.equal(state.dataset.i18n, 'overviewSamplesTemporary');
|
||||
assert.equal(store.size, 0, 'temporary samples must not touch localStorage');
|
||||
assert.equal(save.disabled, false);
|
||||
const range = Array.from({ length: 700 }, (_, i) => i);
|
||||
let calls = 0;
|
||||
samples.onChange(() => { samples.trim(range); calls++; });
|
||||
select.value = '120'; select.fire('change');
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(range.length, 120);
|
||||
assert.equal(range[0], 580);
|
||||
assert.equal(samples.setLimit(999), false);
|
||||
assert.equal(samples.getLimit(), 120);
|
||||
save.fire('click');
|
||||
assert.equal(store.get('rustproxy.overview.liveChartSamples'), '120');
|
||||
assert.equal(state.dataset.i18n, 'overviewSamplesSaved');
|
||||
assert.equal(save.disabled, true);
|
||||
|
||||
// Fresh load reads only the saved choice, not a temporary override.
|
||||
select.value = '300'; select.fire('change');
|
||||
assert.equal(samples.getLimit(), 300);
|
||||
const freshDocument = { readyState: 'loading', addEventListener() {} };
|
||||
const fresh = vm.createContext({ document: freshDocument, localStorage, window: {}, t: key => key });
|
||||
vm.runInContext(controllerSource, fresh);
|
||||
assert.equal(fresh.window.rustProxyOverviewSamples.getLimit(), 120);
|
||||
// Permanently saved choice survives a fresh load.
|
||||
save.fire('click');
|
||||
const again = vm.createContext({ document: freshDocument, localStorage, window: {}, t: key => key });
|
||||
vm.runInContext(controllerSource, again);
|
||||
assert.equal(again.window.rustProxyOverviewSamples.getLimit(), 300);
|
||||
|
||||
// Wide Overview charts must respect the same limit and redraw immediately when
|
||||
// the limit shrinks, without recording an extra point or hitting the network.
|
||||
vm.runInContext(read('src/admin/web/app/rate-sampling.js'), context);
|
||||
vm.runInContext(read('src/admin/web/app/chart-scales.js'), context);
|
||||
vm.runInContext(read('src/admin/web/app/overview-wide-metrics.js'), context);
|
||||
select.value = '600'; select.fire('change');
|
||||
const status = {
|
||||
revision: 1, metrics: { compression_gzip: 0, compression_br: 0, compression_zstd: 0, compression_deflate: 0 },
|
||||
};
|
||||
context.latestStatus = status;
|
||||
for (let i = 0; i <= 650; i++) {
|
||||
now += 3000;
|
||||
for (const key of ['compression_gzip', 'compression_br', 'compression_zstd', 'compression_deflate']) status.metrics[key] += i % 3 + 1;
|
||||
window.rustProxyWideOverview.render(status);
|
||||
}
|
||||
const line = () => nodes.get('overview-wide-wide_compression_activity-gzip-line').getAttribute('d');
|
||||
assert.equal((line().match(/ L/g) || []).length, 599, 'maximum 600 samples must be retained');
|
||||
select.value = '120'; select.fire('change');
|
||||
assert.equal((line().match(/ L/g) || []).length, 119, 'changing selection must trim and redraw without a new sample');
|
||||
assert.equal(store.get('rustproxy.overview.liveChartSamples'), '300', 'temporary choice must preserve the saved limit');
|
||||
assert.ok(!read('src/admin/web/app/overview-samples.js').includes('fetch('));
|
||||
assert.ok(!read('src/admin/web/app/overview-samples.js').includes('setInterval('));
|
||||
console.log('PASS: Overview samples 60/120/300/600, temporary vs persistent, immediate trim/redraw, no extra requests');
|
||||
@@ -1,22 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Same four compile-time substitutions as src/admin/public.rs.
|
||||
export function renderAdminIndex(root) {
|
||||
const web = path.join(root, 'src/admin/web');
|
||||
let html = fs.readFileSync(path.join(web, 'index.html'), 'utf8');
|
||||
const metrics = fs.readFileSync(path.join(web, 'partials/overview-metrics.html'), 'utf8')
|
||||
.split('<!-- RP_METRICS_SPLIT -->');
|
||||
const charts = fs.readFileSync(path.join(web, 'partials/overview-charts.html'), 'utf8')
|
||||
.split('<!-- RP_CHARTS_SPLIT -->');
|
||||
if (metrics.length !== 2 || charts.length !== 2) throw new Error('Invalid Overview partials');
|
||||
for (const [marker, text] of [
|
||||
['METRICS_MAIN', metrics[0]], ['CHARTS_MAIN', charts[0]],
|
||||
['METRICS_WEB', metrics[1]], ['CHARTS_WEB', charts[1]],
|
||||
]) {
|
||||
const token = `<!-- RP_${marker} -->`;
|
||||
if (!html.includes(token)) throw new Error(`Missing ${token}`);
|
||||
html = html.replace(token, text);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Return the actual assembled embedded Overview HTML to simulated-browser tests."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def render_admin_index(root: Path) -> str:
|
||||
web = root / 'src/admin/web'
|
||||
html = (web / 'index.html').read_text(encoding='utf-8')
|
||||
metrics = (web / 'partials/overview-metrics.html').read_text(encoding='utf-8').split('<!-- RP_METRICS_SPLIT -->')
|
||||
charts = (web / 'partials/overview-charts.html').read_text(encoding='utf-8').split('<!-- RP_CHARTS_SPLIT -->')
|
||||
assert len(metrics) == len(charts) == 2
|
||||
for key, fragment in (
|
||||
('METRICS_MAIN', metrics[0]), ('CHARTS_MAIN', charts[0]),
|
||||
('METRICS_WEB', metrics[1]), ('CHARTS_WEB', charts[1]),
|
||||
):
|
||||
marker = '<!-- RP_' + key + ' -->'
|
||||
assert html.count(marker) == 1
|
||||
html = html.replace(marker, fragment)
|
||||
return html
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Progressbar Appearance regression using the production CSS in Chromium.
|
||||
|
||||
Browser-only visual/CSS verification. Does not execute RustProxy or authenticate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
import re
|
||||
|
||||
from PIL import Image
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
|
||||
APP_HTML = render_admin_index(ROOT)
|
||||
assert re.search(r'<div class="overview-meter system-memory-meter" id="memory-meter"', APP_HTML)
|
||||
assert 'id="memory-meter-fill"' in APP_HTML
|
||||
CORNERS = {'default': '3px', 'square': '0px', 'subtle': '2px', 'rounded': '4px', 'extra': '999px'}
|
||||
STYLES = ('default', 'soft', 'technical', 'high_contrast', 'neon', 'monochrome', 'warm', 'cool')
|
||||
|
||||
HTML = '''<!doctype html><html><body>
|
||||
<section id="panel-cache"><div class="stats-grid cache-stats">
|
||||
<article class="stat"><progress id="cp-hot-entries-meter" value="55" max="100"></progress></article>
|
||||
<article class="stat"><progress id="cp-memory" value="55" max="100"></progress></article>
|
||||
<article class="stat"><progress id="cp-cold-entries-meter" value="55" max="100"></progress></article>
|
||||
<article class="stat"><progress id="cp-cold-disk" value="55" max="100"></progress></article>
|
||||
</div></section>
|
||||
<main id="panel-overview">
|
||||
<div class="stat stat-meter"><div class="overview-meter" id="overview-progress"><span class="overview-meter-fill" id="overview-fill" style="width:55%"></span></div></div>
|
||||
<article class="card system-card"><div class="overview-meter system-memory-meter" id="memory-meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="55"><span class="overview-meter-fill" id="memory-meter-fill" style="width:55%"></span></div></article>
|
||||
<div class="www-segmented" id="www-segmented"><span style="width:55%;background:var(--chart-accent)"></span></div>
|
||||
<div class="www-top-meter" id="www-top"><span id="www-top-fill" style="width:55%"></span></div>
|
||||
</main>
|
||||
<div class="traffic-bar-track" id="traffic-track"><span class="traffic-bar-fill" id="traffic-fill" style="width:55%"></span></div>
|
||||
<div id="chart-swatch" style="background:var(--chart-accent);width:4px;height:4px"></div>
|
||||
</body></html>'''
|
||||
|
||||
|
||||
def color_at_progress(page, progress_id: str) -> tuple[int, int, int]:
|
||||
rect = page.locator(f'#{progress_id}').bounding_box()
|
||||
assert rect and rect['width'] > 100
|
||||
screenshot = Image.open(BytesIO(page.screenshot())).convert('RGB')
|
||||
x = round(rect['x'] + rect['width'] * .22)
|
||||
y = round(rect['y'] + rect['height'] / 2)
|
||||
return screenshot.getpixel((x, y))
|
||||
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (375, 1440):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 900})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(HTML)
|
||||
page.add_style_tag(content=CSS)
|
||||
for corner, radius in CORNERS.items():
|
||||
page.evaluate('corner => document.documentElement.dataset.cornerStyle = corner', corner)
|
||||
actual = page.evaluate('''() => {
|
||||
const ids = ['cp-hot-entries-meter','cp-memory','cp-cold-entries-meter','cp-cold-disk',
|
||||
'overview-progress','overview-fill','memory-meter','memory-meter-fill','www-segmented','www-top','www-top-fill',
|
||||
'traffic-track','traffic-fill'];
|
||||
return Object.fromEntries(ids.map(id => [id, getComputedStyle(document.getElementById(id)).borderRadius]));
|
||||
}''')
|
||||
assert all(value == radius for value in actual.values()), (corner, radius, actual)
|
||||
actual_pseudo = page.evaluate('''() => {
|
||||
const p = document.getElementById('cp-memory');
|
||||
return ['::-webkit-progress-bar', '::-webkit-progress-value'].map(pseudo => getComputedStyle(p, pseudo).borderRadius);
|
||||
}''')
|
||||
assert actual_pseudo == [radius, radius], (corner, actual_pseudo)
|
||||
|
||||
for theme in ('dark', 'light'):
|
||||
page.evaluate('theme => document.documentElement.dataset.theme = theme', theme)
|
||||
for chart_style in STYLES:
|
||||
page.evaluate('style => document.documentElement.dataset.chartStyle = style', chart_style)
|
||||
observed = page.evaluate('''() => {
|
||||
const color = id => getComputedStyle(document.getElementById(id)).backgroundColor;
|
||||
const progress = document.getElementById('cp-memory');
|
||||
return {
|
||||
chart:color('chart-swatch'), native:getComputedStyle(progress).accentColor,
|
||||
overview:color('overview-fill'), systemRam:color('memory-meter-fill'), www:color('www-top-fill'), traffic:color('traffic-fill'),
|
||||
track:getComputedStyle(progress).backgroundColor,
|
||||
overviewTrack:color('overview-progress'), systemTrack:color('memory-meter'), wwwTrack:color('www-top'),
|
||||
};
|
||||
}''')
|
||||
assert len({observed[key] for key in ('chart', 'native', 'overview', 'systemRam', 'www', 'traffic')}) == 1, (theme, chart_style, observed)
|
||||
assert len({observed[key] for key in ('track', 'overviewTrack', 'systemTrack', 'wwwTrack')}) == 1, (theme, chart_style, observed)
|
||||
# Native progress on Chromium is painted by the WebKit pseudo-element;
|
||||
# a pixel check confirms the color, not just the accent-color property.
|
||||
if chart_style in ('default', 'neon', 'monochrome'):
|
||||
color = color_at_progress(page, 'cp-memory')
|
||||
chart_rgb = tuple(page.evaluate("""() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = getComputedStyle(document.getElementById('chart-swatch')).backgroundColor;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3);
|
||||
}"""))
|
||||
assert color == chart_rgb, (theme, chart_style, color, chart_rgb)
|
||||
assert not errors, errors
|
||||
page.close()
|
||||
browser.close()
|
||||
|
||||
print('PASS: native Cache, System RAM, Overview, WWW and Traffic bars respond to 5 corners and 8 palettes (dark/light, 375/1440px).')
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-only regression guards for the OPT3 patch (NOT a Rust compile)."""
|
||||
from pathlib import Path
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
|
||||
def read(name):
|
||||
return (root / name).read_text(encoding='utf-8')
|
||||
|
||||
assert 'version = "0.34.0"' in read('Cargo.toml')
|
||||
acl = read('src/acl.rs')
|
||||
assert "pub fn routing_path_view(raw: &str)" in acl
|
||||
assert "return routing_path(raw).map(std::borrow::Cow::Owned)" in acl
|
||||
assert 'raw.split' in acl and 'raw.len() <= 8192' in acl
|
||||
forward = read('src/proxy/forward_entry.rs')
|
||||
assert 'let routing_uri = request.uri().clone();' in forward
|
||||
assert 'crate::acl::routing_path_view(routing_uri.path())' in forward
|
||||
assert 'if context.client_ip != peer.0.ip()' in forward
|
||||
route = read('src/proxy/cache_helpers.rs')
|
||||
assert 'crate::acl::routing_path_view(request.uri().path())' in route
|
||||
for name in ('src/proxy/forward/cache_lookup.rs', 'src/proxy/forward/success_finish.rs', 'src/proxy/forward/web_source.rs'):
|
||||
assert 'path.clone()' not in read(name), name
|
||||
assert 'if !trusted_peer' in read('src/forwarding.rs')
|
||||
assert 'let mut ips = chain(incoming).unwrap_or_default();' in read('src/forwarding.rs')
|
||||
headers = read('src/proxy/headers.rs')
|
||||
assert 'let no_nominations' in headers
|
||||
assert 'let nominated: Vec<HeaderName>' in headers
|
||||
assert 'headers.remove(name)' in headers
|
||||
assert 'String::from_utf8(encoded)' in read('src/proxy/request_id.rs')
|
||||
assert 'if reason == "cache_disabled"' in read('src/cache/stats.rs')
|
||||
assert not read('src/cache/stats.rs').startswith('//!') # previous E0753 build issue
|
||||
for name in ('src/acl/tests.rs', 'src/proxy/headers/tests.rs', 'src/forwarding/tests.rs'):
|
||||
assert (root / name).exists()
|
||||
for source in (root / 'src').rglob('*.rs'):
|
||||
if '/tests/' in source.as_posix() or source.name == 'tests.rs':
|
||||
continue
|
||||
assert len(source.read_text(encoding='utf-8').splitlines()) <= 500, source
|
||||
print('PASS OPT3 source guards (STATIC ONLY; not cargo check, live HTTP or throughput)')
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OPT4 source invariants: not compilation or performance proof."""
|
||||
from pathlib import Path
|
||||
r = Path(__file__).resolve().parents[2]
|
||||
|
||||
def source(name):
|
||||
return (r / name).read_text(encoding="utf-8")
|
||||
|
||||
assert 'version = "0.34.0"' in source('Cargo.toml')
|
||||
route = source('src/proxy/forward/route_cache.rs')
|
||||
assert "Cow<'_, str>" in route
|
||||
assert 'routing_uri.path_and_query()' in route
|
||||
assert 'Ok(None) => public_suffix.clone()' in route
|
||||
assert 'std::borrow::Cow::Owned(encoded)' in route
|
||||
for name in ('src/proxy/forward/cache_lookup.rs','src/proxy/forward/success_finish.rs'):
|
||||
assert 'public_suffix: public_suffix.clone()' not in source(name)
|
||||
assert 'public_suffix: public_suffix.to_string()' in source(name)
|
||||
helpers = source('src/proxy/upstream_helpers.rs')
|
||||
assert 'fn wrap_observed_request' in helpers
|
||||
assert 'if body.is_end_stream() { body }' in helpers
|
||||
assert 'let request = request.map(|body| wrap_observed_request' in helpers
|
||||
assert 'fn log_prefix' in helpers
|
||||
assert 'input.is_ascii()' in helpers and 'input.chars().take(max_chars).collect()' in helpers
|
||||
assert 'path: log_prefix(request.uri().path(), 256)' in helpers
|
||||
assert 'pub request_origin_uri: http::Uri' in source('src/config/model.rs')
|
||||
target = source('src/proxy/types.rs')
|
||||
assert 'fn upstream_request_target(\n uri: Uri' in target
|
||||
assert 'return Some(uri);' in target
|
||||
assert 'let mut parts = uri.into_parts();' in target
|
||||
assert 'upstream_request_target(uri, request.headers_mut()' in target
|
||||
assert 'upstream_request_target(\n uri, request.headers_mut()' in source('src/proxy/forward/upstream.rs')
|
||||
forward = source('src/forwarding.rs')
|
||||
assert 'peer_node.as_deref().unwrap_or(&peer_text)' in forward
|
||||
assert 'let mut ips = chain(incoming).unwrap_or_default();' in forward
|
||||
headers = source('src/proxy/headers.rs')
|
||||
assert 'let no_nominations' in headers
|
||||
assert 'eq_ignore_ascii_case(b"close")' in headers
|
||||
assert 'let nominated: Vec<HeaderName>' in headers
|
||||
for name, test in [
|
||||
('src/proxy/body/tests.rs','optimized_empty_request_keeps_exchange_until_response'),
|
||||
('src/proxy/body/tests.rs','optimized_nonempty_request_still_meters_data_and_holds_capacity'),
|
||||
('src/proxy/tests/authority_tests.rs','optimized_log_prefix_preserves_unicode_char_count_and_ascii_bytes'),
|
||||
('src/proxy/tests/authority_tests.rs','moved_uri_keeps_h1_origin_and_query'),
|
||||
('src/proxy/headers/tests.rs','absent_and_close_connection_preserve_non_hop_headers'),
|
||||
]:
|
||||
assert test in source(name), (name, test)
|
||||
for p in (r / 'src').rglob('*.rs'):
|
||||
if '/tests/' in p.as_posix() or p.name == 'tests.rs':
|
||||
continue
|
||||
assert len(p.read_text(encoding='utf-8').splitlines()) <= 500, p
|
||||
assert not source('src/cache/stats.rs').startswith('//!')
|
||||
print('PASS OPT4 source guard (STATIC ONLY; no cargo / no OPT4 benchmark)')
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OPT5 invariants, static-only. Does not compile or benchmark Rust."""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
def read(path):
|
||||
return (root / path).read_text(encoding='utf-8')
|
||||
|
||||
assert 'version = "0.34.0"' in read('Cargo.toml')
|
||||
identifier = read('src/proxy/request_id.rs')
|
||||
assert 'rand::random()' in identifier
|
||||
assert 'let mut token: [u8; 32]' in identifier
|
||||
assert 'sequence.to_be_bytes()' in identifier
|
||||
assert '*byte ^= sequence_byte;' in identifier
|
||||
assert 'request_id_hex(&token)' in identifier
|
||||
assert 'let mut encoded = vec![0u8; digest.len() * 2]' in identifier
|
||||
assert 'Sha256::new()' not in identifier
|
||||
forwarding = read('src/forwarding.rs')
|
||||
assert 'self.context_with_original_url(incoming, peer, local, tls, authority, uri, true)' in forwarding
|
||||
assert forwarding.count('if include_original_url {') == 2
|
||||
assert 'let mut ips = chain(incoming).unwrap_or_default();' in forwarding
|
||||
assert 'crate::config::request_host(authority_text)?;' in forwarding
|
||||
assert 'frontend.is_some_and(|frontend| frontend.tinyauth.is_some())' in read('src/proxy/forward_entry.rs')
|
||||
assert '&context.original_url' in read('src/tinyauth.rs')
|
||||
lease = read('src/balance/lease_runtime.rs')
|
||||
assert 'let failure_detail = is_5xx.then(|| format!("status={status}"));' in lease
|
||||
assert 'let detail = failure_detail.as_deref().unwrap_or(reason);' in lease
|
||||
assert 'observe_passive_with_detail(' in lease
|
||||
assert 'lease.response(' in read('src/proxy/forward/upstream.rs') or 'lease.response(' in read('src/proxy/forward/success_prepare.rs')
|
||||
analytics = read('src/traffic.rs')
|
||||
for literal in ['mpsc::channel::<Sample>(QUEUE_CAPACITY)', 'recv_many(&mut batch, 64)',
|
||||
'for sample in batch.drain(..)', 'get_mut(sample.vhost.as_str())',
|
||||
'state.vhosts.len() < MAX_VHOSTS', 'try_reserve()',
|
||||
'self.dropped.fetch_add(1, Ordering::Relaxed)',
|
||||
'candidate.eq_ignore_ascii_case(needle.as_bytes())',
|
||||
'2 => self.statuses.add("2xx", 1)', 'map(Counters::view)']:
|
||||
assert literal in analytics, literal
|
||||
assert 'let lower = value.to_ascii_lowercase();' not in analytics
|
||||
helpers = read('src/proxy/upstream_helpers.rs')
|
||||
assert 'preferred_encoding(&original_headers, global_compression).is_some()' in helpers
|
||||
assert 'compression.resolve(' not in helpers
|
||||
assert 'compression::encode(' in helpers
|
||||
assert 'wrap_observed_request(body, &exchange' in helpers
|
||||
for rel, case in [
|
||||
('src/traffic/tests.rs', 'batched_worker_keeps_exact_counts_and_bounded_vhosts'),
|
||||
('src/traffic/tests.rs', 'status_top_list_preserves_common_and_extension_family_names'),
|
||||
('src/forwarding/tests.rs', 'original_url_is_omitted_only_when_unneeded_and_forwarding_is_identical'),
|
||||
('src/compression/tests.rs', 'unaccepted_codecs_cannot_be_reenabled_by_scoped_overrides'),
|
||||
('src/balance/tests.rs', 'successful_lease_response_keeps_passive_observability_and_failure_detail'),
|
||||
('src/proxy/request_id/tests.rs', 'independent_sequential_ids_keep_full_length_and_unique_values'),
|
||||
]:
|
||||
assert case in read(rel), (rel, case)
|
||||
for path in (root / 'src').rglob('*.rs'):
|
||||
if path.name == 'tests.rs' or '/tests/' in path.as_posix():
|
||||
continue
|
||||
assert len(path.read_text(encoding='utf-8').splitlines()) <= 500, path
|
||||
assert not read('src/cache/stats.rs').startswith('//!')
|
||||
print('PASS OPT5 static source checks; Rust build and runtime acceptance still required')
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-only guardrails for the uncompiled 0.34.0 hot-path candidate."""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
def src(path):
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
snapshot = src("src/config/snapshot.rs")
|
||||
assert "fn host_patterns(host: &str) -> impl Iterator" in snapshot
|
||||
assert "pub fn select_on_arc(" in snapshot
|
||||
assert "self.select_on_arc(host, path, local, tls).map(Arc::as_ref)" in snapshot
|
||||
assert "Vec<Arc<CompiledRoute>>" in src("src/config/model.rs")
|
||||
assert "route: Option<Arc<crate::config::CompiledRoute>>" in src("src/proxy/types.rs")
|
||||
assert "request.extensions_mut().insert(SelectedRouteContext" in src("src/proxy/upstream_helpers.rs")
|
||||
assert "selection.route.as_deref()" in src("src/proxy/forward_entry.rs")
|
||||
assert "let fallback_request = selected" in src("src/proxy/forward_entry.rs")
|
||||
assert "filter(|route| route.error_fallback.is_some())" in src("src/proxy/forward_entry.rs")
|
||||
assert "let Some(next_request) = replay_headers" in src("src/proxy/forward/upstream.rs") or "replay_headers.as_ref().and_then" in src("src/proxy/forward/upstream.rs")
|
||||
assert "let replay_headers = ((pool_retry_possible || h2_replay_possible)" in src("src/proxy/forward/upstream.rs")
|
||||
pool = src("src/balance/pool_core.rs")
|
||||
assert "if self.members.len() == 1 && !self.cookie_affinity()" in pool
|
||||
assert "let mut scheduling = lock(&self.scheduling);" in pool
|
||||
assert "fn candidates_excluding" in pool
|
||||
assert "health: member.health_settings.clone()" in pool
|
||||
assert "health: selected.health_settings.clone()" in pool
|
||||
assert "health_settings: Arc<HealthCheck>" in src("src/balance/health.rs")
|
||||
assert "healthy_cached: AtomicBool" in src("src/balance/model.rs")
|
||||
assert "self.healthy_cached.store(h.healthy, Ordering::Release)" in src("src/balance/health.rs")
|
||||
exchange = src("src/proxy/body.rs")
|
||||
for field in ("source", "cache_effective_enabled", "transform_effective_enabled"):
|
||||
assert f"pub {field}: AtomicU8" in exchange
|
||||
assert f"pub {field}: Mutex" not in exchange
|
||||
assert "exchange.set_source(" in src("src/proxy/forward/route_cache.rs")
|
||||
assert "exchange.set_cache_enabled(" in src("src/proxy/forward/web_source.rs")
|
||||
assert "self.live.receiver_count() != 0" in src("src/logging/format.rs")
|
||||
assert "fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes" in src("src/config/tests.rs")
|
||||
assert "fn single_backend_fast_path_observes_capacity_and_health_without_affinity_cookie" in src("src/balance/tests.rs")
|
||||
assert "fn atomic_exchange_tags_keep_original_observability_fields" in src("src/proxy/body/tests.rs")
|
||||
for path in (ROOT / "src").rglob("*.rs"):
|
||||
if "/tests/" in path.as_posix() or path.name == "tests.rs" or path.is_relative_to(ROOT / "tests"):
|
||||
continue
|
||||
assert len(path.read_text().splitlines()) <= 500, path
|
||||
assert 'version = "0.34.0"' in src("Cargo.toml")
|
||||
assert "max_idle_per_host: 64" in src("src/balance/model.rs")
|
||||
assert "pool_recycle_requests: 25_000" in src("src/balance/model.rs")
|
||||
print("PASS 0.34.0 proxy hot-path wiring, observability, bounded tuned defaults and source-size limits (STATIC ONLY)")
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source invariants for 0.34.0 RPS optimizations (NOT a Rust build or RPS test).
|
||||
|
||||
These guards reject accidental removal of important branch/safety conditions.
|
||||
They must be paired with ``cargo test`` and live black-box HTTP acceptance.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def source(path):
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# Random request identifiers still use the OS-seeded thread-local CSPRNG,
|
||||
# preserve a 256-bit unpredictable 64-hex token and mix the per-process sequence.
|
||||
# OPT5 removes the extra SHA-256 operation, not the entropy or correlation ID.
|
||||
identifier = source("src/proxy/request_id.rs")
|
||||
assert "rand::random()" in identifier
|
||||
assert "sequence.to_be_bytes()" in identifier
|
||||
assert "*byte ^= sequence_byte;" in identifier
|
||||
assert "request_id_hex(&token)" in identifier
|
||||
assert "SysRng.try_fill_bytes" not in identifier
|
||||
|
||||
# Compiled/validated target authority survives request URI construction.
|
||||
config = source("src/config/backend.rs")
|
||||
model = source("src/config/model.rs")
|
||||
request = source("src/proxy/types.rs")
|
||||
assert "let request_origin_uri: http::Uri = request_origin.parse()?;" in config
|
||||
assert "request_origin_uri," in config
|
||||
assert "pub request_origin_uri: http::Uri," in model
|
||||
assert "let path: http::uri::PathAndQuery = suffix.parse().ok()?;" in request
|
||||
assert "let mut parts = backend.request_origin_uri.clone().into_parts();" in request
|
||||
assert "parts.path_and_query = Some(path);" in request
|
||||
assert "Uri::from_parts(parts).ok()" in request
|
||||
|
||||
# No new unbounded caches or disabled LB safety gates.
|
||||
pool = source("src/balance/pool_core.rs")
|
||||
assert "if self.members.len() == 1 && !self.cookie_affinity()" in pool
|
||||
assert "try_acquire_active(member.definition.max_concurrent_requests)" in pool
|
||||
assert "m.state.healthy()" in pool
|
||||
assert "scheduling.get_mut(member.definition.id.as_str())" in pool
|
||||
assert "scheduling.entry(member.definition.id.clone())" in pool
|
||||
health = source("src/balance/lease_runtime.rs")
|
||||
assert "async fn probe(member: Arc<Member>, settings: Arc<HealthCheck>)" in health
|
||||
assert "checks.spawn(probe(Arc::clone(member), health.clone()))" in health
|
||||
assert "tokio::time::timeout" in health
|
||||
|
||||
# Configurable keep-alive policy, exact recycle-request threshold and age backup.
|
||||
upstream = source("src/upstream/client.rs")
|
||||
assert "self.current.load_full()" in upstream
|
||||
assert "request_due || age_due" in upstream
|
||||
assert "let now = monotonic_millis();" in upstream
|
||||
assert "let age_ms = now.saturating_sub(self.generation_started_ms.load(Ordering::Relaxed));" in upstream
|
||||
assert "fn maintain_rotation_at(" in upstream
|
||||
assert "self.factory.keepalive.pool_recycle_requests" in upstream
|
||||
assert "self.factory.keepalive.pool_recycle_seconds" in upstream
|
||||
assert "pool_max_idle_per_host(if keepalive.enabled" in upstream
|
||||
assert "retry_canceled_requests(false)" in upstream
|
||||
assert "observe_protocol" in upstream
|
||||
|
||||
# Request/response streamed payloads retain accounting and timeout/cancellation.
|
||||
body = source("src/proxy/body.rs")
|
||||
assert "request_backend: Option<Arc<crate::balance::Lease>>" in body
|
||||
assert "this.request_backend = exchange.backend" in body
|
||||
assert "lease.bytes(count, true)" in body
|
||||
assert "lease.bytes(count, false)" in body
|
||||
assert "exchange.request_bytes.fetch_add(count" in body
|
||||
assert "exchange.response_bytes.fetch_add(count" in body
|
||||
assert "get_or_insert_with(|| Box::pin(sleep_until(this.deadline)))" in body
|
||||
assert "this.finish();" in body
|
||||
|
||||
# Persist history and preserve live streaming/sink drop accounting.
|
||||
logs = source("src/logging/format.rs")
|
||||
assert "let has_viewers = self.live.receiver_count() != 0;" in logs
|
||||
assert "let has_sink = self.settings.load().enabled();" in logs
|
||||
assert "external.take().expect(\"entry\")" in logs
|
||||
assert "self.live.send(entry.clone())" in logs
|
||||
assert "self.sink.try_send(entry)" in logs
|
||||
assert "self.sink_dropped.fetch_add(1" in logs
|
||||
|
||||
# Never weaken routing, identity, HTTP version and cache eligibility while tuning.
|
||||
route = source("src/proxy/forward/route_cache.rs")
|
||||
assert "admin_session_passthrough" in route
|
||||
assert "cache_plan.request_policy" in route
|
||||
assert "exchange.set_cache_enabled" in route
|
||||
assert "exchange.set_transform_enabled" in route
|
||||
assert "upstream_uri(&backend, &origin_suffix)" in route
|
||||
assert "headers::strip_hop_by_hop" in route
|
||||
assert "pool.select_wait(context.client_ip" in route or ".select_wait(context.client_ip" in route
|
||||
assert "pool.select_member_wait(id)" in route
|
||||
assert "max_idle_per_host: 64" in source("src/balance/model.rs")
|
||||
assert "pool_recycle_requests: 25_000" in source("src/balance/model.rs")
|
||||
assert "(1..=64).contains(&k.max_idle_per_host)" in source("src/balance/model.rs")
|
||||
assert "keepalive.max_idle_per_host ?? 64" in source("src/admin/web/vhosts/editor.js")
|
||||
openapi = json.loads(source("src/admin/openapi.json"))
|
||||
api_keepalive = openapi["components"]["schemas"]
|
||||
assert any(isinstance(schema, dict) and "keepalive" in schema.get("properties", {})
|
||||
and schema["properties"]["keepalive"].get("properties", {})
|
||||
.get("max_idle_per_host", {}).get("default") == 64
|
||||
for schema in api_keepalive.values())
|
||||
assert "| `max_idle_per_host` | 64 | 1..64 |" in source("docs/VHOSTS.md")
|
||||
assert '"keepalive": { "max_idle_per_host": 4 }' in source("src/balance/tests.rs")
|
||||
assert "self.route.get_mut()" in source("src/proxy/body.rs")
|
||||
assert "let client_ip = self" in source("src/proxy/body.rs")
|
||||
assert "if !headers.contains_key(http::header::COOKIE)" in source("src/proxy/headers.rs")
|
||||
assert "if !headers.contains_key(http::header::SET_COOKIE)" in source("src/proxy/headers.rs")
|
||||
assert 'version = "0.34.0"' in source("Cargo.toml")
|
||||
|
||||
# Full exchange duration histogram still exports cumulative Prometheus series,
|
||||
# but only one exclusive bucket is updated per request; per-vhost and Docker
|
||||
# exporters must reconstruct bucket counts from exactly the same interface.
|
||||
histogram = source("src/metrics/vhost.rs")
|
||||
exporter = source("src/prometheus/docker_misc.rs")
|
||||
assert "let bucket = DURATION_BUCKETS_US.partition_point" in histogram
|
||||
assert "self.buckets[bucket].fetch_add(1, Ordering::Relaxed);" in histogram
|
||||
assert "pub fn duration_histogram(&self)" in histogram
|
||||
assert exporter.count("let (buckets, count) = m.duration_histogram();") == 2
|
||||
assert "rust_proxy_vhost_exchange_duration_seconds_bucket" in exporter
|
||||
assert "rust_proxy_docker_vhost_exchange_duration_seconds_bucket" in exporter
|
||||
assert "exclusive_bins_reconstruct_exact_prometheus_cumulative_boundaries" in source("src/metrics/vhost/tests.rs")
|
||||
|
||||
for file in (ROOT / "src").rglob("*.rs"):
|
||||
if "/tests/" in file.as_posix() or file.name == "tests.rs":
|
||||
continue
|
||||
assert len(file.read_text(encoding="utf-8").splitlines()) <= 500, file
|
||||
print("PASS RPS hot-path source invariants (STATIC ONLY; new binary not compiled)")
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline Chromium test: sendfile toggle serializes in real admin bundle.
|
||||
|
||||
It uses simulated config and a mocked saveSection; never claims native
|
||||
sendfile execution, Rust compilation or live server acceptance.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
from playwright.sync_api import sync_playwright
|
||||
from panel_html import render_admin_index
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ROUTER = (ROOT / "src/admin/router.rs").read_text()
|
||||
start = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
|
||||
assert start
|
||||
array = ROUTER[start.end():]
|
||||
array = array[:re.search(r'\]\s*\.join\("\\n"\)', array).start()]
|
||||
chunks = []
|
||||
for line in array.splitlines():
|
||||
line = line.strip()
|
||||
match = re.search(r'include_str!\("([^"]+)"\)', line)
|
||||
if match:
|
||||
chunks.append((ROOT / "src/admin" / match.group(1)).read_text())
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
chunks.append('(() => {\n' + '\n'.join(
|
||||
(ROOT / 'src/admin/web/vhosts' / (f + '.js')).read_text()
|
||||
for f in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
chunks.append(json.loads(line[:-1]))
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (375, 1440):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 950})
|
||||
page.set_default_timeout(4500)
|
||||
errors = []
|
||||
page.on('pageerror', lambda err: errors.append(str(err)))
|
||||
page.set_content(render_admin_index(ROOT))
|
||||
page.add_style_tag(content=(ROOT / 'src/admin/web/style.css').read_text())
|
||||
page.add_script_tag(content='\n'.join(chunks))
|
||||
page.evaluate('''() => {
|
||||
envelope = {revision: 10, config: {web_services: [{
|
||||
id: 'site-A', name: 'Static Site', document_root: '/srv/static',
|
||||
index: ['index.html'], php: {enabled: false}
|
||||
}], vhosts: [{id: 'vhost-A', keepalive: {max_connections: 8}}], php_fpm_profiles: []}};
|
||||
currentPanel = 'web-server';
|
||||
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||
document.getElementById('login').hidden = true;
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-web-server').hidden = false;
|
||||
window.__saved = [];
|
||||
saveSection = async (section, value) => {
|
||||
window.__saved.push({section, value: structuredClone(value)});
|
||||
if (section === 'web-services') envelope.config.web_services = structuredClone(value);
|
||||
return true;
|
||||
};
|
||||
window.rustProxyWebServer.render();
|
||||
}''')
|
||||
page.evaluate("document.querySelector('#web-service-list .entity-actions button').click()")
|
||||
assert page.locator('#web-service-sendfile').input_value() == 'off'
|
||||
page.evaluate("document.getElementById('web-service-sendfile').value = 'auto'")
|
||||
page.evaluate('''() => { const form = document.getElementById('web-service-form'); if (!form.checkValidity()) throw new Error('invalid fields: ' + [...form.elements].filter(e=>!e.checkValidity()).map(e=>e.id).join(', ')); form.requestSubmit(); }''')
|
||||
page.wait_for_function('window.__saved.length === 1')
|
||||
result = page.evaluate('''() => ({
|
||||
saved: window.__saved[0],
|
||||
cacheStillUnrelated: envelope.config.vhosts[0].keepalive.max_connections === 8,
|
||||
closed: !document.getElementById('web-service-dialog').open
|
||||
})''')
|
||||
assert result['saved']['section'] == 'web-services', result
|
||||
assert result['saved']['value'][0]['sendfile'] == 'auto', result
|
||||
assert result['saved']['value'][0]['document_root'] == '/srv/static', result
|
||||
assert result['cacheStillUnrelated'] and result['closed'], result
|
||||
page.evaluate("document.querySelector('#web-service-list .entity-actions button').click()")
|
||||
assert page.locator('#web-service-sendfile').input_value() == 'auto'
|
||||
page.evaluate("document.getElementById('web-service-sendfile').value = 'off'")
|
||||
page.evaluate('''() => { const form = document.getElementById('web-service-form'); if (!form.checkValidity()) throw new Error('invalid fields: ' + [...form.elements].filter(e=>!e.checkValidity()).map(e=>e.id).join(', ')); form.requestSubmit(); }''')
|
||||
page.wait_for_function('window.__saved.length === 2')
|
||||
assert page.evaluate('envelope.config.web_services[0].sendfile') == 'off'
|
||||
assert not errors, errors
|
||||
print(f'PASS simulated panel sendfile off -> auto -> off config save at {width}px')
|
||||
browser.close()
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RustProxy 0.34.0 sendfile source-wiring assertions ONLY, not native tests."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
get = lambda path: (root / path).read_text()
|
||||
|
||||
model = get('src/web_server/config/model.rs')
|
||||
assert 'pub enum SendfileMode' in model and 'sendfile: SendfileMode::Off' in model
|
||||
assert 'pub sendfile: SendfileMode' in model
|
||||
assert 'sendfile: self.sendfile' in get('src/web_server/config/compile.rs')
|
||||
assert 'pub sendfile: SendfileMode' in get('src/web_server/config/validation.rs')
|
||||
assert 'service.sendfile = crate::web_server::SendfileMode::Off;' in get('src/runtime/types.rs')
|
||||
source = get('src/proxy/forward/web_source.rs')
|
||||
for guard in ('!enabled && identity_request', 'request_method == Method::GET',
|
||||
'response.status() == StatusCode::OK', 'content-encoding',
|
||||
'transfer-encoding', 'content-range', 'candidate.length',
|
||||
'http::Version::HTTP_11'):
|
||||
assert guard in source, guard
|
||||
assert 'content_encoding.is_none()' in get('src/web_server/response/file.rs')
|
||||
assert 'single_range.is_none()' in get('src/web_server/response/file.rs')
|
||||
assert 'opened.file.try_clone()' in get('src/web_server/response/file.rs')
|
||||
bridge = get('src/server/sendfile.rs')
|
||||
for item in ('libc::sendfile(', 'MAX_PENDING: usize = 8', 'MIN_SIZE: u64 = 64 * 1024',
|
||||
'registry.slots.is_empty()', 'native_sendfile_transfers_exact_file',
|
||||
'sendfile placeholder did not match', 'UnexpectedEof'):
|
||||
assert item in bridge, item
|
||||
assert 'pub(crate) mod sendfile' in get('src/server.rs')
|
||||
assert 'SendfileIo::new(stream)' in get('src/server/tls_runtime.rs')
|
||||
assert 'sendfile_slots' in get('src/server/http1_connection.rs')
|
||||
js = get('src/admin/web/web-server.js')
|
||||
html = get('src/admin/web/index.html')
|
||||
assert '"web-service-sendfile"' in js and 'sendfile: "off"' in js
|
||||
assert 'sendfile: $("web-service-sendfile").value' in js
|
||||
assert html.count('id="web-service-sendfile"') == 1
|
||||
assert 'value="auto"' in html
|
||||
for locale in ('en_US', 'pl_pl'):
|
||||
keys = json.loads(get(f'langs/{locale}.json'))['translations']
|
||||
assert all(k in keys for k in ('webServiceSendfile', 'webServiceSendfileHelp', 'webServiceSendfileAuto'))
|
||||
api = json.loads(get('src/admin/openapi.json'))
|
||||
assert 'sendfile' in json.dumps(api)
|
||||
print('PASS 0.34.0 sendfile source guards, default OFF, Linux syscall, UI, translations, API and scoped cache (STATIC ONLY)')
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify sidebar colors use only the UI theme; chart palettes affect charts only.
|
||||
|
||||
Uses actual production HTML/CSS in headless Chromium, not a live RustProxy
|
||||
server or authenticated preference/session roundtrip.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
|
||||
THEMES = ('dark', 'ultra_dark', 'light', 'cream')
|
||||
PALETTES = ('default', 'soft', 'technical', 'high_contrast', 'neon', 'monochrome', 'warm', 'cool')
|
||||
CORNERS = {'default': 0, 'square': 0, 'subtle': 4, 'rounded': 8, 'extra': 12}
|
||||
|
||||
STYLE_INFO = r'''() => {
|
||||
const color = value => {
|
||||
const c = document.createElement('canvas'); c.width = c.height = 1;
|
||||
const ctx = c.getContext('2d'); ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1);
|
||||
return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3);
|
||||
};
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const active = document.querySelector('.sidebar .main-navigation .nav.active');
|
||||
const idle = document.querySelector('.sidebar .main-navigation .nav:not(.active)');
|
||||
const sa = getComputedStyle(active), si = getComputedStyle(idle);
|
||||
return {
|
||||
chartAccent:color(root.getPropertyValue('--chart-accent').trim()),
|
||||
themeSelected:color(root.getPropertyValue('--nav-active-bg').trim()),
|
||||
themeHovered:color(root.getPropertyValue('--nav-hover-bg').trim()),
|
||||
foreground:color(sa.color),
|
||||
background:color(sa.backgroundColor),
|
||||
borderLeft:color(sa.borderLeftColor),
|
||||
borderRight:color(sa.borderRightColor),
|
||||
radius:parseFloat(sa.borderTopLeftRadius),
|
||||
idleBackground:color(si.backgroundColor),
|
||||
mobileBackground:color(getComputedStyle(document.querySelector('.mobile-menu-toggle')).backgroundColor),
|
||||
mobileColor:color(getComputedStyle(document.querySelector('.mobile-menu-toggle')).color),
|
||||
navWidth:document.querySelector('.main-navigation').scrollWidth,
|
||||
navClientWidth:document.querySelector('.main-navigation').clientWidth,
|
||||
activeWidth:active.scrollWidth,
|
||||
activeClientWidth:active.clientWidth,
|
||||
};
|
||||
}'''
|
||||
|
||||
def contrast(fg, bg):
|
||||
def luminance(rgb):
|
||||
channels = [x / 255 for x in rgb]
|
||||
channels = [x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4 for x in channels]
|
||||
return .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2]
|
||||
lo, hi = sorted((luminance(fg), luminance(bg)))
|
||||
return (hi + .05) / (lo + .05)
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for viewport in (375, 1200):
|
||||
page = browser.new_page(viewport={'width': viewport, 'height': 820})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(HTML.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', '').replace('/assets/app___RUSTPROXY_ASSET_ID__.js', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.evaluate('''() => {
|
||||
document.documentElement.classList.remove('app-loading');
|
||||
document.getElementById('app-startup').remove();
|
||||
document.querySelector('.sidebar').classList.add('mobile-nav-open');
|
||||
document.querySelector('.mobile-menu-toggle')?.setAttribute('aria-expanded', 'true');
|
||||
}''')
|
||||
theme_colors = []
|
||||
for theme in THEMES:
|
||||
baseline = None
|
||||
chart_accents = set()
|
||||
for palette in PALETTES:
|
||||
page.evaluate('''([theme, palette]) => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.dataset.chartStyle = palette;
|
||||
}''', [theme, palette])
|
||||
for corner, expected_radius in CORNERS.items():
|
||||
page.evaluate('corner => document.documentElement.dataset.cornerStyle = corner', corner)
|
||||
style = page.evaluate(STYLE_INFO)
|
||||
assert style['radius'] == expected_radius, (viewport, theme, palette, corner, style['radius'])
|
||||
assert style['borderLeft'] == style['foreground'], (viewport, theme, palette, corner, style)
|
||||
assert style['background'] == style['themeSelected'], (viewport, theme, palette, corner, style)
|
||||
if viewport == 375:
|
||||
assert style['mobileBackground'] == style['background'], (viewport, theme, palette, corner, style)
|
||||
assert style['mobileColor'] == style['foreground'], (viewport, theme, palette, corner, style)
|
||||
assert contrast(style['foreground'], style['background']) >= 4.5, (viewport, theme, palette, corner, style)
|
||||
assert style['navWidth'] <= style['navClientWidth'] + 2, (viewport, theme, palette, corner, style)
|
||||
assert style['activeWidth'] <= style['activeClientWidth'] + 2, (viewport, theme, palette, corner, style)
|
||||
chart_accents.add(tuple(style['chartAccent']))
|
||||
# The entire selected state is unaffected by chart-style changes.
|
||||
nav_colors = (tuple(style['foreground']), tuple(style['background']), tuple(style['borderLeft']))
|
||||
if baseline is None:
|
||||
baseline = nav_colors
|
||||
else:
|
||||
assert baseline == nav_colors, (viewport, theme, palette, baseline, nav_colors)
|
||||
page.locator('.nav[data-panel="vhosts"]').hover()
|
||||
hovered = page.evaluate(STYLE_INFO)
|
||||
assert hovered['idleBackground'] == hovered['themeHovered'], (viewport, theme, palette, hovered)
|
||||
assert hovered['idleBackground'] != style['idleBackground'], (viewport, theme, palette)
|
||||
page.mouse.move(0, 0)
|
||||
if viewport == 1200 and theme == 'ultra_dark' and palette == 'cool':
|
||||
page.screenshot(path=str(ROOT.parent / 'sidebar_preview_desktop.png'))
|
||||
if viewport == 375 and theme == 'dark' and palette == 'soft':
|
||||
page.screenshot(path=str(ROOT.parent / 'sidebar_preview_mobile.png'))
|
||||
assert len(chart_accents) > 1, (viewport, theme, chart_accents)
|
||||
theme_colors.append(baseline)
|
||||
assert len(set(theme_colors)) == len(THEMES), (viewport, theme_colors)
|
||||
page.evaluate("document.documentElement.dir = 'rtl'")
|
||||
rtl = page.evaluate(STYLE_INFO)
|
||||
assert rtl['borderRight'] == rtl['foreground'], (viewport, rtl)
|
||||
assert not errors, errors
|
||||
print('PASS themed navigation independence: viewport', viewport, 'themes', len(THEMES), 'palettes', len(PALETTES), 'corners', len(CORNERS))
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS: sidebar changes with UI theme and corner setting, never with chart style')
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""System Load RAM meter updates using the actual combined panel JavaScript.
|
||||
|
||||
This is a simulated authenticated DOM/status test, not a Rust runtime integration test.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
import json
|
||||
import re
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
router = (ROOT / 'src/admin/router.rs').read_text()
|
||||
|
||||
def bundle_for(section):
|
||||
match = re.search(r'fn ' + section + r'\([^)]*\)[^{]*\{', router)
|
||||
assert match, section
|
||||
text = router[match.end():]
|
||||
text = text[:re.search(r'\]\s*\.join\("\\n"\)', text).start()]
|
||||
result = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if 'include_str!("' in line:
|
||||
path = re.search(r'include_str!\("([^"]+)"\)', line).group(1)
|
||||
result.append((ROOT / 'src/admin' / path).read_text())
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
result.append('(() => {\n' + ''.join((ROOT / 'src/admin/web/vhosts' / f'{part}.js').read_text() + '\n'
|
||||
for part in ['core', 'protocol', 'health', 'rates', 'editor', 'render']) + '})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
result.append(json.loads(line[:-1]))
|
||||
return '\n'.join(result)
|
||||
|
||||
html = render_admin_index(ROOT)
|
||||
css = (ROOT / 'src/admin/web/style.css').read_text()
|
||||
js = bundle_for('admin_app_javascript')
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (375, 1440):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 900})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(html.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '').replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=css)
|
||||
page.add_script_tag(content=js)
|
||||
page.wait_for_timeout(100)
|
||||
report = page.evaluate('''() => {
|
||||
currentPanel = "overview";
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
const meter = document.getElementById('memory-meter');
|
||||
const fill = document.getElementById('memory-meter-fill');
|
||||
const snapshot = (used, total) => ({system: {source:'measured',cpu_percent:2.1,
|
||||
memory_used_bytes:used, memory_total_bytes:total},
|
||||
live_system:{cpu_percent:2.1}, cache:{}, metrics:{}, listeners:{}});
|
||||
const values = [];
|
||||
for (const [used, total] of [[250,1000],[800,1000],[0,1000],[null,null]]) {
|
||||
renderDiagnostics(snapshot(used,total), false, true);
|
||||
values.push({fill:fill.style.width, now:meter.getAttribute('aria-valuenow'),
|
||||
hidden:meter.hidden, text:meter.getAttribute('aria-valuetext')});
|
||||
}
|
||||
return { values, role:meter.getAttribute('role'), cssHeight:getComputedStyle(meter).height,
|
||||
scrollbar:document.documentElement.scrollWidth>window.innerWidth+2 };
|
||||
}''')
|
||||
assert report['role'] == 'progressbar' and report['cssHeight'] == '5px', report
|
||||
assert report['values'][0]['fill'] == '25%' and report['values'][0]['now'] == '25', report
|
||||
assert report['values'][1]['fill'] == '80%' and report['values'][1]['now'] == '80', report
|
||||
assert report['values'][2]['fill'] == '0%' and report['values'][2]['now'] == '0', report
|
||||
assert report['values'][3]['fill'] == '0%' and report['values'][3]['now'] is None and report['values'][3]['hidden'], report
|
||||
assert not report['scrollbar'] and not errors, (width, errors, report)
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS: System RAM meter live percentage and unknown/zero handling on 375/1440px in bundled panel.')
|
||||
@@ -1,137 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TCP Stream black-box test: use against a running RustProxy and mock backends.
|
||||
|
||||
The 'selftest' mode validates this harness only; it does NOT run RustProxy.
|
||||
All traffic uses loopback by default. Do not point it at a production service.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import secrets
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAX_LINE = 1024
|
||||
|
||||
|
||||
class TCPServer(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
request_queue_size = 128
|
||||
|
||||
|
||||
class TCPBackend(socketserver.StreamRequestHandler):
|
||||
def handle(self) -> None:
|
||||
self.request.settimeout(10)
|
||||
backend_id = self.server.backend_id.encode('ascii')
|
||||
try:
|
||||
while True:
|
||||
line = self.rfile.readline(MAX_LINE + 1)
|
||||
if not line or len(line) > MAX_LINE or not line.endswith(b'\n'):
|
||||
return
|
||||
self.wfile.write(backend_id + b'|' + line)
|
||||
self.wfile.flush()
|
||||
except (OSError, TimeoutError):
|
||||
return
|
||||
|
||||
|
||||
def run_server(host: str, port: int, backend_id: str) -> None:
|
||||
with TCPServer((host, port), TCPBackend) as server:
|
||||
server.backend_id = backend_id
|
||||
print(f'TCP mock {backend_id} listening on {server.server_address}', flush=True)
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
def check_one(index: int, host: str, port: int, messages: int, timeout: float, hold_ms: float, nonce: str) -> str:
|
||||
backend = None
|
||||
with socket.create_connection((host, port), timeout=timeout) as connection:
|
||||
connection.settimeout(timeout)
|
||||
with connection.makefile('rb') as reader:
|
||||
for seq in range(messages):
|
||||
payload = f'rp-tcp:{nonce}:{index}:{seq}\n'.encode('ascii')
|
||||
connection.sendall(payload)
|
||||
response = reader.readline(MAX_LINE + 1)
|
||||
try:
|
||||
observed, echoed = response.split(b'|', 1)
|
||||
except ValueError as exc:
|
||||
raise AssertionError(f'session {index}: missing mock backend response: {response!r}') from exc
|
||||
assert echoed == payload, f'session {index}: response mismatch: {response!r}'
|
||||
assert observed, f'session {index}: empty backend ID'
|
||||
if backend is None:
|
||||
backend = observed
|
||||
assert observed == backend, f'session {index}: TCP backend changed during connection'
|
||||
if hold_ms and seq != messages - 1:
|
||||
time.sleep(hold_ms / 1000)
|
||||
return backend.decode('ascii')
|
||||
|
||||
|
||||
def run_client(host: str, port: int, sessions: int, messages: int, parallel: int, timeout: float,
|
||||
hold_ms: float, min_backends: int) -> Counter:
|
||||
nonce = secrets.token_hex(8)
|
||||
started = time.monotonic()
|
||||
counts: Counter[str] = Counter()
|
||||
with ThreadPoolExecutor(max_workers=parallel) as pool:
|
||||
futures = [pool.submit(check_one, i, host, port, messages, timeout, hold_ms, nonce)
|
||||
for i in range(sessions)]
|
||||
for future in as_completed(futures):
|
||||
counts[future.result()] += 1
|
||||
assert len(counts) >= min_backends, f'observed {len(counts)} backend(s), required {min_backends}: {dict(counts)}'
|
||||
print(f'TCP PASS: {sessions} sessions, {sessions * messages} messages, '
|
||||
f'{len(counts)} backend(s), {time.monotonic() - started:.2f}s; assignments={dict(sorted(counts.items()))}')
|
||||
return counts
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='mode', required=True)
|
||||
server = sub.add_parser('server', help='run a local TCP mock backend')
|
||||
server.add_argument('--host', default='127.0.0.1')
|
||||
server.add_argument('--port', type=int, required=True)
|
||||
server.add_argument('--backend-id', required=True, help='unique ASCII marker (e.g. node-a)')
|
||||
client = sub.add_parser('client', help='test forwarding via an already-running RustProxy listener')
|
||||
client.add_argument('--host', default='127.0.0.1', help='RustProxy TCP listener host')
|
||||
client.add_argument('--port', type=int, required=True, help='RustProxy TCP listener port')
|
||||
client.add_argument('--sessions', type=int, default=24)
|
||||
client.add_argument('--messages', type=int, default=3)
|
||||
client.add_argument('--parallel', type=int, default=8)
|
||||
client.add_argument('--timeout', type=float, default=4.0)
|
||||
client.add_argument('--hold-ms', type=float, default=20.0, help='pause between messages to keep TCP sessions alive')
|
||||
client.add_argument('--min-backends', type=int, default=1, help='minimum distinct backend IDs observed')
|
||||
sub.add_parser('selftest', help='test mock peer + harness directly, WITHOUT RustProxy')
|
||||
args = parser.parse_args()
|
||||
if args.mode == 'server':
|
||||
if not args.backend_id.isascii() or not args.backend_id or '|' in args.backend_id or '\n' in args.backend_id:
|
||||
parser.error('--backend-id must be nonempty ASCII without | or newline')
|
||||
run_server(args.host, args.port, args.backend_id)
|
||||
elif args.mode == 'client':
|
||||
if min(args.port, args.sessions, args.messages, args.parallel, args.min_backends) < 1 or args.port > 65535 or args.timeout <= 0 or args.hold_ms < 0:
|
||||
parser.error('invalid port, count, timeout or hold delay')
|
||||
run_client(args.host, args.port, args.sessions, args.messages, args.parallel,
|
||||
args.timeout, args.hold_ms, args.min_backends)
|
||||
else:
|
||||
with TCPServer(('127.0.0.1', 0), TCPBackend) as server:
|
||||
server.backend_id = 'local-mock'
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
run_client('127.0.0.1', server.server_address[1], 8, 3, 4, 2.0, 5, 1)
|
||||
print('SELFTEST PASS: tested mock TCP peer, not RustProxy')
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (OSError, AssertionError, UnicodeError) as exc:
|
||||
print(f'TCP FAIL: {exc}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""UDP Stream black-box test: sends multiple packets from each stable client socket.
|
||||
|
||||
Start separately labeled mock backends and target an existing RustProxy UDP
|
||||
listener. 'selftest' only validates this Python harness, NOT RustProxy.
|
||||
By default, the server responds PONG to PING for an explicitly configured probe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import secrets
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAX_DATAGRAM = 1024
|
||||
|
||||
|
||||
class UDPServer(socketserver.UDPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
class UDPBackend(socketserver.BaseRequestHandler):
|
||||
def handle(self) -> None:
|
||||
data, connection = self.request
|
||||
if data == b'PING':
|
||||
reply = b'PONG'
|
||||
elif data and len(data) <= MAX_DATAGRAM:
|
||||
reply = self.server.backend_id.encode('ascii') + b'|' + data
|
||||
else:
|
||||
return
|
||||
connection.sendto(reply, self.client_address)
|
||||
|
||||
|
||||
def run_server(host: str, port: int, backend_id: str) -> None:
|
||||
with UDPServer((host, port), UDPBackend) as server:
|
||||
server.backend_id = backend_id
|
||||
print(f'UDP mock {backend_id} listening on {server.server_address}; health probe PING -> PONG', flush=True)
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
def check_one(index: int, host: str, port: int, messages: int, timeout: float, pause_ms: float, nonce: str) -> str:
|
||||
info = socket.getaddrinfo(host, port, type=socket.SOCK_DGRAM)[0]
|
||||
family, sock_type, proto, _, address = info
|
||||
backend = None
|
||||
# One socket = one stable client identity for the entire UDP Stream session.
|
||||
with socket.socket(family, sock_type, proto) as connection:
|
||||
connection.settimeout(timeout)
|
||||
for seq in range(messages):
|
||||
payload = f'rp-udp:{nonce}:{index}:{seq}'.encode('ascii')
|
||||
connection.sendto(payload, address)
|
||||
response, _ = connection.recvfrom(MAX_DATAGRAM + 128)
|
||||
try:
|
||||
observed, echoed = response.split(b'|', 1)
|
||||
except ValueError as exc:
|
||||
raise AssertionError(f'session {index}: missing mock backend response: {response!r}') from exc
|
||||
assert echoed == payload, f'session {index}: response mismatch {response!r}'
|
||||
assert observed, f'session {index}: empty backend ID'
|
||||
if backend is None:
|
||||
backend = observed
|
||||
assert observed == backend, f'session {index}: UDP backend changed for the same client socket'
|
||||
if pause_ms and seq != messages - 1:
|
||||
time.sleep(pause_ms / 1000)
|
||||
return backend.decode('ascii')
|
||||
|
||||
|
||||
def run_client(host: str, port: int, sessions: int, messages: int, parallel: int, timeout: float,
|
||||
pause_ms: float, min_backends: int) -> Counter:
|
||||
nonce = secrets.token_hex(8)
|
||||
started = time.monotonic()
|
||||
counts: Counter[str] = Counter()
|
||||
with ThreadPoolExecutor(max_workers=parallel) as pool:
|
||||
futures = [pool.submit(check_one, i, host, port, messages, timeout, pause_ms, nonce)
|
||||
for i in range(sessions)]
|
||||
for future in as_completed(futures):
|
||||
counts[future.result()] += 1
|
||||
assert len(counts) >= min_backends, f'observed {len(counts)} backend(s), required {min_backends}: {dict(counts)}'
|
||||
print(f'UDP PASS: {sessions} client sockets, {sessions * messages} datagrams, '
|
||||
f'{len(counts)} backend(s), {time.monotonic() - started:.2f}s; assignments={dict(sorted(counts.items()))}')
|
||||
return counts
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='mode', required=True)
|
||||
server = sub.add_parser('server', help='run local UDP echo and optional PING/PONG probe responder')
|
||||
server.add_argument('--host', default='127.0.0.1')
|
||||
server.add_argument('--port', type=int, required=True)
|
||||
server.add_argument('--backend-id', required=True, help='unique ASCII marker (e.g. node-a)')
|
||||
client = sub.add_parser('client', help='test forwarding via an already-running RustProxy UDP listener')
|
||||
client.add_argument('--host', default='127.0.0.1', help='RustProxy UDP listener host')
|
||||
client.add_argument('--port', type=int, required=True, help='RustProxy UDP listener port')
|
||||
client.add_argument('--sessions', type=int, default=24)
|
||||
client.add_argument('--messages', type=int, default=3)
|
||||
client.add_argument('--parallel', type=int, default=8)
|
||||
client.add_argument('--timeout', type=float, default=4.0)
|
||||
client.add_argument('--pause-ms', type=float, default=20.0, help='pause between datagrams on each stable client socket')
|
||||
client.add_argument('--min-backends', type=int, default=1, help='minimum distinct backend IDs observed')
|
||||
sub.add_parser('selftest', help='test mock peer + harness directly, WITHOUT RustProxy')
|
||||
args = parser.parse_args()
|
||||
if args.mode == 'server':
|
||||
if not args.backend_id.isascii() or not args.backend_id or '|' in args.backend_id or '\n' in args.backend_id:
|
||||
parser.error('--backend-id must be nonempty ASCII without | or newline')
|
||||
run_server(args.host, args.port, args.backend_id)
|
||||
elif args.mode == 'client':
|
||||
if min(args.port, args.sessions, args.messages, args.parallel, args.min_backends) < 1 or args.port > 65535 or args.timeout <= 0 or args.pause_ms < 0:
|
||||
parser.error('invalid port, count, timeout or pause delay')
|
||||
run_client(args.host, args.port, args.sessions, args.messages, args.parallel,
|
||||
args.timeout, args.pause_ms, args.min_backends)
|
||||
else:
|
||||
with UDPServer(('127.0.0.1', 0), UDPBackend) as server:
|
||||
server.backend_id = 'local-mock'
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
run_client('127.0.0.1', server.server_address[1], 8, 3, 4, 2.0, 5, 1)
|
||||
print('SELFTEST PASS: tested mock UDP peer, not RustProxy')
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (OSError, AssertionError, UnicodeError) as exc:
|
||||
print(f'UDP FAIL: {exc}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run source-upgrade preservation/refusal regressions in disposable directories."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from scripts.deploy.upgrade import upgrade
|
||||
|
||||
|
||||
def fixture(parent):
|
||||
target = parent / "installed"
|
||||
target.mkdir()
|
||||
(target / "Cargo.toml").write_text('[package]\nname = "rust-proxy"\nversion = "0.33.7"\n')
|
||||
(target / "src").mkdir()
|
||||
(target / "src" / "obsolete.rs").write_text("// old managed source\n")
|
||||
(target / "data").mkdir()
|
||||
with sqlite3.connect(target / "data" / "fixture.sqlite") as db:
|
||||
db.execute("CREATE TABLE preserved(value TEXT)")
|
||||
db.execute("INSERT INTO preserved VALUES ('configuration and preferences')")
|
||||
(target / ".env").write_text("LOCAL_FIXTURE=1\n")
|
||||
(target / "langs").mkdir()
|
||||
(target / "langs" / "custom.json").write_text('{"custom": true}\n')
|
||||
return target
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory(prefix="rustproxy-upgrade-test-", dir=ROOT.parent) as temporary:
|
||||
parent = Path(temporary)
|
||||
target = fixture(parent)
|
||||
try:
|
||||
upgrade(ROOT, target, stopped=False)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("upgrade must require stopped confirmation")
|
||||
assert (target / "src" / "obsolete.rs").is_file()
|
||||
print("PASS upgrade refuses missing --stopped without modifying source")
|
||||
|
||||
if sys.platform != "win32":
|
||||
import fcntl
|
||||
with (target / "data" / "process.lock").open("w+") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
try:
|
||||
upgrade(ROOT, target, stopped=True)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("upgrade must refuse a live process lock")
|
||||
print("PASS upgrade refuses a data directory locked by a live process")
|
||||
|
||||
backup = upgrade(ROOT, target, stopped=True)
|
||||
assert (target / ".env").read_text() == "LOCAL_FIXTURE=1\n"
|
||||
assert (target / "langs" / "custom.json").read_text() == '{"custom": true}\n'
|
||||
with sqlite3.connect(target / "data" / "fixture.sqlite") as db:
|
||||
assert db.execute("SELECT value FROM preserved").fetchone() == ("configuration and preferences",)
|
||||
assert not (target / "src" / "obsolete.rs").exists()
|
||||
assert (backup / "src" / "obsolete.rs").is_file()
|
||||
assert (target / "Cargo.toml").read_bytes() == (ROOT / "Cargo.toml").read_bytes()
|
||||
assert (target / "src" / "proxy" / "cache_plan.rs").is_file()
|
||||
print("PASS upgrade preserves SQLite, environment and custom language; backs up old source and replaces managed tree")
|
||||
|
||||
if sys.platform != "win32":
|
||||
shutil.rmtree(target / "src")
|
||||
(target / "src").symlink_to(backup / "src", target_is_directory=True)
|
||||
try:
|
||||
upgrade(ROOT, target, stopped=True)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("upgrade must refuse a managed symlink")
|
||||
assert (backup / "src" / "obsolete.rs").is_file()
|
||||
print("PASS upgrade refuses managed symlinks without changing their targets")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,268 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simulated Chromium Vhost backend quick-view, without a Rust server.
|
||||
|
||||
Verify both backend triggers, the shared click-to-open upstream H2 protocol
|
||||
popover without native title flicker, and the two Cache card margins.
|
||||
Exercise live updates, safe text, keyboard/outside dismissal and mobile sizing.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
from playwright.sync_api import sync_playwright
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text()
|
||||
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
|
||||
PACKS = {lang: {'id': lang, **json.loads((ROOT / 'langs' / f'{lang}.json').read_text())}
|
||||
for lang in ('en_US', 'pl_pl')}
|
||||
|
||||
|
||||
def app_bundle():
|
||||
found = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
|
||||
assert found
|
||||
code = ROUTER[found.end():]
|
||||
code = code[:re.search(r'\]\s*\.join\("\\n"\)', code).start()]
|
||||
fragments = []
|
||||
for line in code.splitlines():
|
||||
line = line.strip()
|
||||
include = re.search(r'include_str!\("([^"]+)"\)', line)
|
||||
if include:
|
||||
fragments.append((ROOT / 'src/admin' / include.group(1)).read_text())
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
fragments.append('(() => {\n' + '\n'.join(
|
||||
(ROOT / 'src/admin/web/vhosts' / f'{name}.js').read_text()
|
||||
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
fragments.append(json.loads(line[:-1]))
|
||||
return '\n'.join(fragments)
|
||||
|
||||
|
||||
CONFIG = {
|
||||
'vhosts': [{
|
||||
'id': 'api-one', 'enabled': True, 'source': {'type': 'reverse_proxy'},
|
||||
'frontend': {'hosts': ['api.example.test'], 'listener_ids': []},
|
||||
'backends': [
|
||||
{'id': 'origin-http', 'url': 'http://10.12.0.5:8080/api', 'protocol': 'http2', 'enabled': True},
|
||||
{'id': '<origin-dns>', 'url': 'https://hiddenuser:secret@backend.example.test:8443/path', 'protocol': 'auto', 'enabled': True},
|
||||
]
|
||||
}, {'id': 'static-only', 'enabled': True,
|
||||
'source': {'type': 'web_server', 'web_service_id': 'static-test'},
|
||||
'frontend': {'hosts': ['static.example.test'], 'listener_ids': []}, 'backends': []}],
|
||||
'listeners': {'endpoints': []}, 'web_services': [], 'acl_profiles': [],
|
||||
}
|
||||
|
||||
|
||||
def snapshot(first=200):
|
||||
return {'backend_pools': [{
|
||||
'id': 'api-one', 'health_mode': 'on',
|
||||
'members': [
|
||||
{'id': 'origin-http', 'status': 'up', 'last_status': first,
|
||||
'requests': 12, 'active': 0, 'errors': 0, 'bytes_in': 0, 'bytes_out': 0},
|
||||
{'id': '<origin-dns>', 'status': 'down', 'last_status': 0,
|
||||
'requests': 0, 'active': 0, 'errors': 0, 'bytes_in': 0, 'bytes_out': 0},
|
||||
]
|
||||
}], 'vhost_metrics': {}}
|
||||
|
||||
|
||||
def protocol_snapshot(h2_count):
|
||||
status = snapshot()
|
||||
status['backend_pools'][0]['members'][0].update({
|
||||
'upstream_protocol_observed': 'h2',
|
||||
'upstream_protocol_last_seen_ms': int(time.time() * 1000),
|
||||
'upstream_protocol_http1_total': 3,
|
||||
'upstream_protocol_http2_total': h2_count,
|
||||
})
|
||||
return status
|
||||
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (320, 375, 768, 1440):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 820})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.add_script_tag(content=app_bundle())
|
||||
page.evaluate('''([config, status, packs]) => {
|
||||
languagePacks = new Map(Object.entries(packs));
|
||||
defaultLanguage = 'en_US'; language = 'pl_pl';
|
||||
document.documentElement.lang = 'pl-PL';
|
||||
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||
document.getElementById('login').hidden = true;
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-vhosts').hidden = false;
|
||||
currentPanel = 'vhosts';
|
||||
envelope = {revision: 12, config};
|
||||
latestStatus = status;
|
||||
window.rustProxyVhosts.render();
|
||||
}''', [CONFIG, snapshot(), PACKS])
|
||||
vhost = page.locator('[data-vhost-card="api-one"]')
|
||||
backend = vhost.locator('.vhost-detail.vhost-backend-trigger')
|
||||
health = vhost.locator('.vhost-live-metric.vhost-backend-trigger')
|
||||
assert backend.count() == health.count() == 1, width
|
||||
assert backend.get_attribute('aria-expanded') == 'false'
|
||||
assert backend.get_attribute('aria-haspopup') == 'dialog'
|
||||
# Clickable backend/health fields must match their non-button peers,
|
||||
# including for user-selected interface corner styles.
|
||||
shapes = page.evaluate('''() => {
|
||||
const root = document.querySelector('[data-vhost-card="api-one"]');
|
||||
const peers = [
|
||||
[root.querySelector('.vhost-detail.vhost-backend-trigger'), root.querySelector('.vhost-detail:not(.vhost-backend-trigger)')],
|
||||
[root.querySelector('.vhost-live-metric.vhost-backend-trigger'), root.querySelector('.vhost-live-metric:not(.vhost-backend-trigger)')]
|
||||
];
|
||||
const checks = ['square', 'subtle', 'rounded', 'extra'].map(corner => {
|
||||
document.documentElement.dataset.cornerStyle = corner;
|
||||
return {corner, tiles: peers.map(([trigger, peer]) => {
|
||||
const left = getComputedStyle(trigger), right = getComputedStyle(peer);
|
||||
return {radius: left.borderRadius, peerRadius: right.borderRadius,
|
||||
padding: left.padding, peerPadding: right.padding,
|
||||
font: left.fontFamily, peerFont: right.fontFamily};
|
||||
})};
|
||||
});
|
||||
document.documentElement.removeAttribute('data-corner-style');
|
||||
return checks;
|
||||
}''')
|
||||
for shape in shapes:
|
||||
for tile in shape['tiles']:
|
||||
assert tile['radius'] == tile['peerRadius'] == '0px', (width, shape)
|
||||
assert tile['padding'] == tile['peerPadding'], (width, shape)
|
||||
assert tile['font'] == tile['peerFont'], (width, shape)
|
||||
backend.click()
|
||||
popup = page.locator('#vhost-backend-popup')
|
||||
assert popup.count() == 1, width
|
||||
assert backend.get_attribute('aria-expanded') == 'true'
|
||||
assert popup.get_attribute('role') == 'dialog'
|
||||
# The anchored popup is a surface, not a permanently rounded tooltip.
|
||||
# It must follow live interface corner preference changes without
|
||||
# dismissing/recreating the open backend (or shared H2) popup.
|
||||
corners = page.evaluate('''() => {
|
||||
const node = document.getElementById('vhost-backend-popup');
|
||||
const saved = document.documentElement.getAttribute('data-corner-style');
|
||||
const out = {};
|
||||
for (const style of ['default', 'square', 'subtle', 'rounded', 'extra']) {
|
||||
document.documentElement.dataset.cornerStyle = style;
|
||||
out[style] = {
|
||||
popup: getComputedStyle(node).borderRadius,
|
||||
row: getComputedStyle(node.querySelector('.vhost-backend-popup-row')).borderRadius,
|
||||
close: getComputedStyle(node.querySelector('.vhost-backend-popup-close')).borderRadius,
|
||||
reference: getComputedStyle(document.querySelector('[data-vhost-card="api-one"]')).borderRadius,
|
||||
sameNode: node === document.getElementById('vhost-backend-popup'),
|
||||
};
|
||||
}
|
||||
if (saved === null) document.documentElement.removeAttribute('data-corner-style');
|
||||
else document.documentElement.dataset.cornerStyle = saved;
|
||||
return out;
|
||||
}''')
|
||||
expected_radii = {'default': ('0px', '0px'), 'square': ('0px', '0px'),
|
||||
'subtle': ('5px', '4px'), 'rounded': ('12px', '8px'),
|
||||
'extra': ('18px', '12px')}
|
||||
for style, (surface, control) in expected_radii.items():
|
||||
actual = corners[style]
|
||||
assert actual == {'popup': surface, 'row': surface, 'close': control,
|
||||
'reference': surface, 'sameNode': True}, (width, style, actual)
|
||||
values = popup.evaluate('''node => ({
|
||||
cards: node.querySelectorAll('.vhost-backend-popup-row').length,
|
||||
text: node.textContent, html: node.innerHTML,
|
||||
box: (() => { const r = node.getBoundingClientRect();
|
||||
return {left: r.left, right: r.right, top:r.top, bottom:r.bottom}; })(),
|
||||
})''')
|
||||
assert values['cards'] == 2, (width, values)
|
||||
assert '10.12.0.5:8080' in values['text'] and 'HTTP 200' in values['text'], (width, values)
|
||||
assert 'backend.example.test:8443' in values['text'], width
|
||||
assert 'secret' not in values['text'], width
|
||||
assert '<origin-dns>' in values['text'] and '<origin-dns>' in values['html'], width
|
||||
assert 'Brak odpowiedzi HTTP' in values['text'], width
|
||||
assert 'HTTPS · AUTO' in values['text'], width
|
||||
assert values['box']['left'] >= -1 and values['box']['right'] <= width + 1, (width, values['box'])
|
||||
assert values['box']['top'] >= -1 and values['box']['bottom'] <= 821, (width, values['box'])
|
||||
assert page.evaluate('document.documentElement.scrollWidth - innerWidth') <= 2, width
|
||||
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', snapshot(503))
|
||||
assert 'HTTP 503' in popup.inner_text(), width
|
||||
assert popup.locator('.has-error').count() == 1, width
|
||||
health.click()
|
||||
assert popup.count() == 1 and health.get_attribute('aria-expanded') == 'true', width
|
||||
assert backend.get_attribute('aria-expanded') == 'false', width
|
||||
page.keyboard.press('Escape')
|
||||
assert popup.count() == 0 and health.get_attribute('aria-expanded') == 'false', width
|
||||
assert health.evaluate('(node) => document.activeElement === node'), width
|
||||
backend.click()
|
||||
page.locator('#vhost-search').click(force=True)
|
||||
assert popup.count() == 0, width
|
||||
# A static-only vhost presents a translated empty state, not stale pool values.
|
||||
page.locator('[data-vhost-card="static-only"] .vhost-detail.vhost-backend-trigger').click()
|
||||
assert 'Brak skonfigurowanych backendów.' in popup.inner_text(), width
|
||||
popup.locator('.vhost-backend-popup-close').click()
|
||||
assert popup.count() == 0, width
|
||||
backend.click()
|
||||
assert popup.count() == 1, width
|
||||
|
||||
# The existing backend popover is also used by the H2 policy badge.
|
||||
protocol = vhost.locator('[data-upstream-protocol-backend="origin-http"]')
|
||||
assert protocol.count() == 1 and protocol.evaluate('n => n.tagName') == 'BUTTON', width
|
||||
assert protocol.get_attribute('title') is None, width
|
||||
protocol.click()
|
||||
assert backend.get_attribute('aria-expanded') == 'false', width
|
||||
assert protocol.get_attribute('aria-expanded') == 'true', width
|
||||
assert 'Brak odpowiedzi backendu' in popup.inner_text(), width
|
||||
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', protocol_snapshot(8))
|
||||
assert 'origin-http · H2 compatible → H2' in protocol.inner_text(), width
|
||||
assert 'Ostatnia wersja HTTP' in popup.inner_text(), width
|
||||
assert popup.locator('.vhost-backend-popup-field > span:last-child').all_inner_texts() == ['3', '8'], width
|
||||
page.evaluate('''() => {
|
||||
window.__popupReference = document.getElementById('vhost-backend-popup');
|
||||
window.__headingReference = window.__popupReference.querySelector('h4');
|
||||
window.__popupRemoved = 0;
|
||||
window.__popupObserver = new MutationObserver(records => {
|
||||
for (const record of records) for (const item of record.removedNodes) {
|
||||
if (item === window.__popupReference) window.__popupRemoved++;
|
||||
}
|
||||
});
|
||||
window.__popupObserver.observe(document.body, {childList: true});
|
||||
}''')
|
||||
for count in range(9, 19):
|
||||
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', protocol_snapshot(count))
|
||||
unchanged = page.evaluate('''() => ({
|
||||
samePopup: document.getElementById('vhost-backend-popup') === window.__popupReference,
|
||||
sameHeading: window.__popupReference.querySelector('h4') === window.__headingReference,
|
||||
removed: window.__popupRemoved,
|
||||
expanded: document.querySelector('[data-upstream-protocol-backend="origin-http"]').getAttribute('aria-expanded'),
|
||||
})''')
|
||||
assert unchanged == {'samePopup': True, 'sameHeading': True, 'removed': 0, 'expanded': 'true'}, (width, unchanged)
|
||||
assert popup.locator('.vhost-backend-popup-field > span:last-child').all_inner_texts() == ['3', '18'], width
|
||||
page.evaluate('window.__popupObserver.disconnect()')
|
||||
assert protocol.get_attribute('title') is None, width
|
||||
# A stale observation should change the displayed information in
|
||||
# place, not dismiss/recreate the click popover.
|
||||
stale = protocol_snapshot(18)
|
||||
stale['backend_pools'][0]['members'][0]['upstream_protocol_last_seen_ms'] -= 600000
|
||||
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', stale)
|
||||
assert 'H2 compatible → ?' in protocol.inner_text(), width
|
||||
assert popup.locator('.vhost-backend-popup-list').is_hidden(), width
|
||||
assert page.evaluate('document.getElementById("vhost-backend-popup") === window.__popupReference'), width
|
||||
page.keyboard.press('Escape')
|
||||
assert popup.count() == 0 and protocol.get_attribute('aria-expanded') == 'false', width
|
||||
assert protocol.evaluate('node => document.activeElement === node'), width
|
||||
|
||||
# Cache Domains and Stored objects are the only directly adjacent
|
||||
# table cards and need a visible, small gap at any viewport width.
|
||||
cache_gap = page.evaluate('''() => {
|
||||
const panel = document.getElementById('panel-cache');
|
||||
panel.hidden = false;
|
||||
const [domains, stored] = panel.querySelectorAll(':scope > article.card');
|
||||
const gap = stored.getBoundingClientRect().top - domains.getBoundingClientRect().bottom;
|
||||
panel.hidden = true;
|
||||
return gap;
|
||||
}''')
|
||||
assert 9.5 <= cache_gap <= 10.5, (width, cache_gap)
|
||||
page.evaluate('window.rustProxyVhosts.reset()')
|
||||
assert popup.count() == 0, width
|
||||
assert not errors, (width, errors)
|
||||
print(f'PASS Vhosts shared backend/H2 popover and Cache gap at {width}px')
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS Vhost quick-view with simulated runtime status (not live Rust)')
|
||||
@@ -1,140 +0,0 @@
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
import re, json
|
||||
from playwright.sync_api import sync_playwright
|
||||
root=Path(__file__).resolve().parents[2]
|
||||
router=(root/'src/admin/router.rs').read_text()
|
||||
|
||||
def concatenate(section):
|
||||
pattern=r'fn '+section+r'\([^)]*\)[^{]*\{'
|
||||
m=re.search(pattern,router)
|
||||
assert m, section
|
||||
text=router[m.end():]
|
||||
# The array contents are bounded by ].join("\\n") before the next function.
|
||||
text=text[:re.search(r'\]\s*\.join\("\\n"\)', text).start()]
|
||||
result=[]
|
||||
for line in text.splitlines():
|
||||
line=line.strip()
|
||||
if 'include_str!("' in line:
|
||||
path=re.search(r'include_str!\("([^"]+)"\)',line).group(1)
|
||||
result.append((root/'src/admin'/path).read_text())
|
||||
elif line=='admin_vhosts_javascript(),':
|
||||
result.append('(() => {\n'+''.join((root/'src/admin/web/vhosts'/f'{part}.js').read_text()+'\n' for part in ['core','protocol','health','rates','editor','render'])+'})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
result.append(json.loads(line[:-1]))
|
||||
return '\n'.join(result)
|
||||
bundle=concatenate('admin_app_javascript')
|
||||
assert 'window.rustProxyOverviewSamples =' in bundle
|
||||
assert bundle.index('window.rustProxyOverviewSamples =') < bundle.index('window.rustProxyOverviewSamples.onChange(')
|
||||
css=(root/'src/admin/web/style.css').read_text()
|
||||
html=render_admin_index(root)
|
||||
with sync_playwright() as p:
|
||||
browser=p.chromium.launch(headless=True, executable_path='/usr/bin/chromium',args=['--no-sandbox','--allow-file-access-from-files'])
|
||||
for width in [320,375,800,1440]:
|
||||
page=browser.new_page(viewport={'width':width,'height':900})
|
||||
errors=[]
|
||||
page.on('pageerror',lambda e: errors.append(str(e)))
|
||||
# Execute in a fully populated DOM without disallowed filesystem/network navigation.
|
||||
page.set_content(html.replace('/assets/app___RUSTPROXY_ASSET_ID__.js','').replace('/assets/style___RUSTPROXY_ASSET_ID__.css',''))
|
||||
page.add_style_tag(content=css)
|
||||
page.add_script_tag(content=bundle)
|
||||
page.wait_for_timeout(550)
|
||||
state=page.evaluate('''() => ({ controller:typeof window.rustProxyOverviewSamples,
|
||||
handle:typeof window.rustProxyOverviewSamples?.onChange,
|
||||
web:typeof window.rustProxyWebOverview, services:typeof window.rustProxyWebServer,
|
||||
registry: document.querySelectorAll('[data-dashboard-widget^="www_"]').length,
|
||||
htmlWidth:document.documentElement.scrollWidth,windowWidth:window.innerWidth })''')
|
||||
print(width,state,'errors',errors)
|
||||
assert state['controller']=='object' and state['handle']=='function' and state['web']=='object'
|
||||
assert state['registry']==12
|
||||
assert not [e for e in errors if 'onChange' in e or 'ReferenceError' in e or 'SyntaxError' in e or 'TypeError' in e],errors
|
||||
assert state['htmlWidth'] <= state['windowWidth'] + 2
|
||||
# The selector belongs to the same header row as the core connection;
|
||||
# hiding it on other panels must not leave an empty dashboard row.
|
||||
layout = page.evaluate('''() => {
|
||||
document.getElementById('workspace').hidden = false;
|
||||
setPanel('overview', false);
|
||||
const status = document.getElementById('connection-label');
|
||||
const sample = document.getElementById('overview-samples-toolbar');
|
||||
const row = document.querySelector('.page-intro-meta-row');
|
||||
const rect = node => { const r = node.getBoundingClientRect();
|
||||
return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right}; };
|
||||
const overview = { status:rect(status),sample:rect(sample),row:rect(row),
|
||||
visible: !sample.hidden,inside: row.contains(sample) && row.contains(status),
|
||||
overflow: document.documentElement.scrollWidth > innerWidth + 2 };
|
||||
setPanel('web-server', false);
|
||||
const notOverview = sample.hidden && !status.hidden;
|
||||
setPanel('overview', false);
|
||||
return {overview,notOverview,visibleAgain:!sample.hidden};
|
||||
}''')
|
||||
assert layout['overview']['inside'] and layout['overview']['visible']
|
||||
assert layout['notOverview'] and layout['visibleAgain']
|
||||
assert not layout['overview']['overflow'], (width, layout)
|
||||
if width >= 800:
|
||||
assert abs(layout['overview']['sample']['y'] - layout['overview']['status']['y']) <= 20, (width, layout)
|
||||
assert layout['overview']['sample']['right'] <= layout['overview']['row']['right'] + 2, (width, layout)
|
||||
# A genuinely sampled WWW metrics payload (not a screenshot-only fixture).
|
||||
report = page.evaluate('''() => {
|
||||
Object.defineProperty(window.performance, "now", {configurable:true, value:()=>window.__clock});
|
||||
window.__clock = 1000;
|
||||
currentPanel = "overview";
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('login').hidden = true;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
for (const n of document.querySelectorAll('[data-dashboard-widget^="www_"]')) n.hidden = false;
|
||||
function snap(k) {
|
||||
return {revision:7, web_server_metrics:{
|
||||
"public-site": {web: {requests:100+k*30, status_2xx:86+k*24, status_3xx:3+k,
|
||||
status_4xx:8+k*2, status_5xx:3+k*3, response_headers_us:4000000+k*1300000,
|
||||
response_bytes:100000+k*20000, static_requests:70+k*20, php_requests:25+k*7,
|
||||
other_requests:5+k*3, methods:[90+k*25,8+k*4,0,0,2+k],
|
||||
selected_status:[80+k*18,2,2,1,5+k,0,0,0], active_headers:0},
|
||||
php: {metrics:{requests:25+k*7, response_headers_us:2000000+k*700000,
|
||||
response_bytes:30000+k*5000,failures:[1+k,1+k,1+k,0,0,0,0,0,0]},
|
||||
capacity_limit:32, capacity_used:8, capacity_available:24}}}
|
||||
};
|
||||
}
|
||||
const before = snap(0), after = snap(1);
|
||||
// Saved custom layout from before 0.33.0 must not enable new cards.
|
||||
const savedLayout = applyDashboardLayout(['requests', 'errors'],
|
||||
normalizeHiddenDashboardWidgets([], ['requests', 'errors']));
|
||||
const newCardsHidden = [...document.querySelectorAll('[data-dashboard-widget^="www_"]')]
|
||||
.every(card=>card.hidden);
|
||||
for (const n of document.querySelectorAll('[data-dashboard-widget^="www_"]')) n.hidden = false;
|
||||
window.rustProxyWebOverview.render(before, true);
|
||||
window.__clock = 4000;
|
||||
window.rustProxyWebOverview.render(after, true);
|
||||
let ids = ['www_requests-all-line', 'www_requests-php-line',
|
||||
'www_latency-web-line', 'www_traffic-php-line', 'www_status_trend-5xx-line'];
|
||||
const linePaths = ids.map(id=>document.getElementById(id)?.getAttribute('d'));
|
||||
const statusText = document.getElementById('www_status-value').textContent;
|
||||
const capacityText = document.getElementById('www_capacity-value').textContent;
|
||||
const rateText = document.getElementById('www_php_requests-value').textContent;
|
||||
const bars = document.querySelectorAll('#www_top_services-bars .www-top-row').length;
|
||||
window.rustProxyOverviewSamples.setLimit(120);
|
||||
const historyLimit = window.rustProxyOverviewSamples.getLimit();
|
||||
currentPanel = "web-server";
|
||||
envelope = {config:{web_services:[{id:'public-site', name:'Public site',document_root:'/srv/www/public'}],vhosts:[]}};
|
||||
window.rustProxyWebServer.render();
|
||||
const details = document.querySelector('.www-service-panel');
|
||||
if (!details || details.tagName !== 'SECTION') throw Error('Missing always-visible WWW metrics');
|
||||
window.__clock = 7000;
|
||||
window.rustProxyWebServer.renderLive(before, true);
|
||||
window.__clock = 10000;
|
||||
window.rustProxyWebServer.renderLive(after, true);
|
||||
const value = details.querySelector('[data-web-live-field="requests"]')?.textContent;
|
||||
const phpCapacity = details.querySelector('[data-web-live-field="php"]')?.textContent;
|
||||
return {linePaths,statusText,capacityText,rateText,bars,historyLimit,value,phpCapacity,newCardsHidden,
|
||||
width:document.documentElement.scrollWidth};
|
||||
}''')
|
||||
print('sampled WWW',width,report)
|
||||
assert all(report['linePaths']), report
|
||||
assert report['statusText'] != '-' and report['capacityText'] == '8 / 32', report
|
||||
assert report['rateText'] not in ('-', '0.0/s'), report
|
||||
assert report['bars'] == 1 and report['historyLimit'] == 120 and report['newCardsHidden'], report
|
||||
assert report['value'] and report['phpCapacity'] == '8 / 32', report
|
||||
assert report['width'] <= width + 2, report
|
||||
assert not [e for e in errors if any(x in e for x in ('ReferenceError', 'TypeError', 'SyntaxError'))], errors
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS: actual composed admin bundle starts without a JS TypeError')
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static contracts for 0.33.0 WWW observability (not Rust compilation)."""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
read = lambda file: (ROOT / file).read_text()
|
||||
manifest = tomllib.loads(read('Cargo.toml'))
|
||||
lock = tomllib.loads(read('Cargo.lock'))
|
||||
version = manifest['package']['version']
|
||||
assert tuple(map(int, version.split('.'))) >= (0, 33, 0)
|
||||
assert [p['version'] for p in lock['package'] if p['name'] == 'rust-proxy'] == [version]
|
||||
|
||||
registry = json.loads(read('src/admin/web/dashboard-widgets.json'))
|
||||
widgets = [w for w in registry if w['id'].startswith('www_')]
|
||||
assert len(widgets) == 12
|
||||
assert all(w['default_hidden'] is True for w in widgets)
|
||||
assert len({w['id'] for w in registry}) == len(registry)
|
||||
html = render_admin_index(ROOT)
|
||||
english = json.loads(read('langs/en_US.json'))['translations']
|
||||
polish = json.loads(read('langs/pl_pl.json'))['translations']
|
||||
for widget in widgets:
|
||||
assert f'data-dashboard-widget="{widget["id"]}" hidden' in html, widget['id']
|
||||
assert widget['label_key'] in english, widget['label_key']
|
||||
assert widget['label_key'] in polish, widget['label_key']
|
||||
if widget['description_key']:
|
||||
assert widget['description_key'] in english
|
||||
assert widget['description_key'] in polish
|
||||
|
||||
# Safely assemble the admin app before core.js: the earlier release crashed on
|
||||
# registering onChange callbacks before rustProxyOverviewSamples was defined.
|
||||
router = read('src/admin/router.rs')
|
||||
assert router.index('include_str!("web/app/overview-samples.js")') < router.index('include_str!("web/app/core.js")')
|
||||
assert '"web/app/web-overview.js"' in router
|
||||
assert '"web_server_metrics"' in read('src/admin/status_live.rs')
|
||||
assert 'web_server_metrics_payload(&snapshot)' in read('src/admin/status.rs')
|
||||
# record_web is only consumed by web_server::serve; expose no wider than the
|
||||
# ResponseKind enum argument. This is a static visibility contract, not rustc.
|
||||
metrics = read('src/web_server/metrics.rs')
|
||||
assert 'pub(super) enum ResponseKind' in metrics
|
||||
assert 'pub(super) fn record_web(' in metrics
|
||||
assert 'pub(crate) fn record_web(' not in metrics
|
||||
assert 'service.web_metrics.record_web(' in read('src/web_server/serve.rs')
|
||||
|
||||
families = read('src/prometheus/web_php.rs')
|
||||
required = {
|
||||
'rust_proxy_web_server_requests_total', 'rust_proxy_web_server_responses_total',
|
||||
'rust_proxy_web_server_request_kind_total', 'rust_proxy_web_server_methods_total',
|
||||
'rust_proxy_web_server_status_code_total', 'rust_proxy_web_server_response_header_duration_microseconds_total',
|
||||
'rust_proxy_web_server_response_body_bytes_total', 'rust_proxy_web_server_requests_active',
|
||||
'rust_proxy_web_server_compression_total', 'rust_proxy_php_requests_total',
|
||||
'rust_proxy_php_responses_total', 'rust_proxy_php_response_header_duration_microseconds_total',
|
||||
'rust_proxy_php_response_body_bytes_total', 'rust_proxy_php_concurrency_available',
|
||||
'rust_proxy_php_concurrency_limit', 'rust_proxy_php_concurrency_in_use', 'rust_proxy_php_failures_total'
|
||||
}
|
||||
assert all(f'"{name}"' in families for name in required), required
|
||||
assert 'PHP_FAILURE_NAMES' in families
|
||||
assert re.search(r'\["GET", "POST", "PUT", "DELETE", "other"\]', families)
|
||||
assert 'let wl = labels(&[("web_service", service.id.as_str())]);' in families
|
||||
|
||||
j = json.loads(read('grafana/rustproxy-overview.json'))
|
||||
assert j['title'].startswith('RustProxy 0.33.')
|
||||
ids = [p['id'] for p in j['panels']]
|
||||
assert len(ids) == len(set(ids))
|
||||
assert any(v.get('name') == 'web_service' and v.get('multi') and v.get('includeAll') for v in j['templating']['list'])
|
||||
for title in ('Web Server 0.33.0', 'PHP-FPM / FastCGI', 'Per Web Service', 'WWW / PHP correlation'):
|
||||
assert any(p['title'] == title and p['type'] == 'row' for p in j['panels'])
|
||||
extra = [p for p in j['panels'] if p['id'] >= 89]
|
||||
for i, p in enumerate(extra):
|
||||
a = p['gridPos']
|
||||
assert 0 <= a['x'] and a['w'] > 0 and a['x'] + a['w'] <= 24
|
||||
for q in extra[i + 1:]:
|
||||
b = q['gridPos']
|
||||
overlap = a['x'] < b['x'] + b['w'] and b['x'] < a['x'] + a['w'] and a['y'] < b['y'] + b['h'] and b['y'] < a['y'] + a['h']
|
||||
assert not overlap, (p['id'], q['id'])
|
||||
|
||||
production = [p for p in (ROOT / 'src').rglob('*.rs') if p.name != 'tests.rs' and 'tests' not in p.parts]
|
||||
large = [str(p.relative_to(ROOT)) for p in production if len(p.read_text().splitlines()) > 500]
|
||||
assert not large, large
|
||||
print('PASS RustProxy 0.33.0 source version, opt-in widgets, localizations, status API,')
|
||||
print(' exporter families, Grafana geometry/filtering and Rust module size (STATIC ONLY)')
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simulated Web Server telemetry against the production admin JS/CSS bundle.
|
||||
|
||||
Browser-only test: does not assert live RustProxy, PHP-FPM, or network behavior.
|
||||
Run: python3 scripts/tests/web_server_0330_ui_test.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
import re
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
router = (ROOT / 'src/admin/router.rs').read_text()
|
||||
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', router)
|
||||
assert match, 'admin_app_javascript definition missing'
|
||||
portion = router[match.end():]
|
||||
portion = portion[:re.search(r'\]\s*\.join\("\\n"\)', portion).start()]
|
||||
fragments = []
|
||||
for line in portion.splitlines():
|
||||
line = line.strip()
|
||||
if 'include_str!("' in line:
|
||||
path = re.search(r'include_str!\("([^"]+)"\)', line).group(1)
|
||||
fragments.append((ROOT / 'src/admin' / path).read_text())
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
fragments.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / f'{part}.js').read_text() for part in ['core', 'protocol', 'health', 'rates', 'editor', 'render']) + '\n})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
fragments.append(json.loads(line[:-1]))
|
||||
BUNDLE = '\n'.join(fragments)
|
||||
assert BUNDLE.index('window.rustProxyOverviewSamples =') < BUNDLE.index('window.rustProxyOverviewSamples.onChange(')
|
||||
|
||||
WEB = {
|
||||
'requests': 100, 'status_2xx': 90, 'status_3xx': 2, 'status_4xx': 5, 'status_5xx': 3,
|
||||
'response_headers_us': 2_500_000, 'response_bytes': 10_000, 'active_headers': 2,
|
||||
'static_requests': 70, 'php_requests': 25, 'other_requests': 5,
|
||||
'methods': [80, 10, 5, 2, 3], 'selected_status': [75, 5, 2, 0, 5, 1, 1, 1],
|
||||
'failures': [0] * 9, 'compression': [5, 3, 2, 1],
|
||||
}
|
||||
PHP = {
|
||||
'requests': 25, 'status_2xx': 22, 'status_3xx': 0, 'status_4xx': 1, 'status_5xx': 2,
|
||||
'response_headers_us': 1_000_000, 'response_bytes': 2_000,
|
||||
'failures': [1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
}
|
||||
|
||||
def observation(factor: int):
|
||||
web = {key: ([factor * v for v in value] if isinstance(value, list) else factor * value)
|
||||
for key, value in WEB.items()}
|
||||
php = {key: ([factor * v for v in value] if isinstance(value, list) else factor * value)
|
||||
for key, value in PHP.items()}
|
||||
web['active_headers'] = 2
|
||||
return {'revision': 10, 'web_server_metrics': {
|
||||
'site-A': {'web': web, 'php': {'metrics': php, 'capacity_limit': 32, 'capacity_used': 18}}
|
||||
}}
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (320, 375, 800, 1440):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 900})
|
||||
errors = []
|
||||
page.on('pageerror', lambda err: errors.append(str(err)))
|
||||
page.set_content(render_admin_index(ROOT))
|
||||
page.add_style_tag(content=(ROOT / 'src/admin/web/style.css').read_text())
|
||||
page.add_script_tag(content=BUNDLE)
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#workspace').hidden = false;
|
||||
document.querySelector('#panel-overview').hidden = false;
|
||||
document.querySelectorAll('[data-dashboard-widget^="www_"]').forEach(n => n.hidden = false);
|
||||
currentPanel = 'overview';
|
||||
window.__clock = 1000;
|
||||
Object.defineProperty(performance, 'now', { configurable: true, value: () => window.__clock });
|
||||
}""")
|
||||
first, second = observation(1), observation(2)
|
||||
state = page.evaluate("""([one, two]) => {
|
||||
const api = window.rustProxyWebOverview;
|
||||
api.render(one);
|
||||
window.__clock = 4000;
|
||||
api.render(two);
|
||||
return {
|
||||
rps: document.getElementById('www_requests-all-value').textContent,
|
||||
php: document.getElementById('www_requests-php-value').textContent,
|
||||
latency: document.getElementById('www_latency-web-value').textContent,
|
||||
capacity: document.getElementById('www_capacity-value').textContent,
|
||||
types: document.getElementById('www_kind-value').textContent,
|
||||
status: document.getElementById('www_status-value').textContent,
|
||||
bytes: document.getElementById('www_traffic-value').textContent,
|
||||
top: document.getElementById('www_top_services-value').textContent,
|
||||
layoutWidth: document.documentElement.scrollWidth,
|
||||
screenWidth: window.innerWidth,
|
||||
children: document.querySelectorAll('[data-dashboard-widget^="www_"]').length,
|
||||
sampleCount: document.getElementById('www_requests-all-line').getAttribute('d'),
|
||||
limit: window.rustProxyOverviewSamples.getLimit(),
|
||||
};
|
||||
}""", [first, second])
|
||||
assert state['children'] == 12, state
|
||||
assert state['rps'] != '-' and state['php'] != '-', state
|
||||
assert 'ms' in state['latency'] and '18' in state['capacity'], state
|
||||
assert state['types'].endswith('%') and state['status'].endswith('%'), state
|
||||
assert '/s' in state['top'], state
|
||||
assert state['layoutWidth'] <= state['screenWidth'] + 2, (width, state)
|
||||
assert not errors, (width, errors)
|
||||
# Exercise the always-visible per-service metrics with the same status,
|
||||
# without any real HTTP/PHP request or extra polling.
|
||||
details = page.evaluate("""(status) => {
|
||||
envelope = { revision: 10, config: {
|
||||
web_services: [{ id: 'site-A', name: 'Example', document_root: '/var/www/site-A',
|
||||
index: ['index.html'], php: { enabled: true } }],
|
||||
vhosts: [], php_fpm_profiles: []
|
||||
}};
|
||||
currentPanel = 'web-server';
|
||||
window.rustProxyWebServer.render();
|
||||
const detail = document.querySelector('#web-service-list .www-service-panel');
|
||||
if (!detail) return { missing: true };
|
||||
window.rustProxyWebServer.renderLive(status, false);
|
||||
return { text: detail.textContent, rows: detail.querySelectorAll('[data-web-live-field]').length,
|
||||
alwaysVisible: detail.tagName === 'SECTION' && !detail.querySelector('summary') };
|
||||
}""", second)
|
||||
assert not details.get('missing') and details['rows'] == 7 and details['alwaysVisible'], (width, details)
|
||||
assert '18 / 32' in details['text'], (width, details)
|
||||
# The first reading after an overdue browser interval must establish a
|
||||
# fresh baseline without adding a fake 0/s dip to the service chart.
|
||||
continuity = page.evaluate("""([first, second, paused, resumed]) => {
|
||||
const api = window.rustProxyWebServer;
|
||||
const chart = document.querySelector('#web-service-list [data-web-live-id="site-A"] polyline');
|
||||
const rate = document.querySelector('#web-service-list [data-web-live-field="rate"]');
|
||||
window.__clock = 5000; api.renderLive(first);
|
||||
window.__clock = 8000; api.renderLive(second);
|
||||
const before = chart.getAttribute('points');
|
||||
window.__clock = 100000; api.renderLive(paused);
|
||||
const during = chart.getAttribute('points'), unavailable = rate.textContent;
|
||||
window.__clock = 103000; api.renderLive(resumed);
|
||||
return { before, during, unavailable, after: chart.getAttribute('points'), resumedRate: rate.textContent };
|
||||
}""", [observation(2), observation(3), observation(10), observation(12)])
|
||||
assert continuity['before'] == continuity['during'], (width, continuity)
|
||||
assert continuity['unavailable'] == '-', (width, continuity)
|
||||
assert continuity['resumedRate'] != '-', (width, continuity)
|
||||
assert continuity['after'] != continuity['before'], (width, continuity)
|
||||
assert not errors, (width, errors)
|
||||
old = page.evaluate("""() => {
|
||||
currentPanel = 'overview';
|
||||
window.rustProxyWebOverview.render({ revision: 11, web_server_metrics: {} });
|
||||
return {
|
||||
rate: document.getElementById('www_requests-value').textContent,
|
||||
status: document.getElementById('www_status-value').textContent,
|
||||
top: document.getElementById('www_top_services-bars').childElementCount,
|
||||
};
|
||||
}""")
|
||||
assert old == {'rate': '-', 'status': '-', 'top': 0}, (width, old)
|
||||
assert not errors, (width, errors)
|
||||
print(f'PASS simulated Web Server cards and always-visible service metrics at {width}px: {state["rps"]}, {state["php"]}, layout {state["layoutWidth"]}px')
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS production JS bundle + all twelve visible WWW widgets (simulated data only)')
|
||||
@@ -1,155 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WWW/Vhost visual parity in production UI files, using simulated status.
|
||||
|
||||
Chromium exercises the real assembled admin JS/CSS and responsive layouts.
|
||||
It does not establish Rust, SSE or PHP-FPM runtime acceptance.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from panel_html import render_admin_index
|
||||
import json
|
||||
import re
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = render_admin_index(ROOT)
|
||||
CSS = (ROOT / 'src/admin/web/style.css').read_text()
|
||||
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
|
||||
|
||||
|
||||
def app_bundle():
|
||||
found = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
|
||||
assert found
|
||||
code = ROUTER[found.end():]
|
||||
code = code[:re.search(r'\]\s*\.join\("\\n"\)', code).start()]
|
||||
fragments = []
|
||||
for line in code.splitlines():
|
||||
line = line.strip()
|
||||
include = re.search(r'include_str!\("([^"]+)"\)', line)
|
||||
if include:
|
||||
fragments.append((ROOT / 'src/admin' / include.group(1)).read_text())
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
fragments.append('(() => {\n' + '\n'.join(
|
||||
(ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
|
||||
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
fragments.append(json.loads(line[:-1]))
|
||||
return '\n'.join(fragments)
|
||||
|
||||
|
||||
def status(step):
|
||||
return {'web_server_metrics': {
|
||||
'site-A': {'web': {
|
||||
'requests': step * 120, 'response_headers_us': step * 2_400_000,
|
||||
'status_5xx': step * 4, 'response_bytes': step * 20_480,
|
||||
'static_requests': step * 80, 'php_requests': step * 30,
|
||||
'other_requests': step * 10,
|
||||
}, 'php': {'capacity_used': 3, 'capacity_limit': 16}},
|
||||
'site-B': {'web': {
|
||||
'requests': step * 24, 'response_headers_us': step * 100_000,
|
||||
'status_5xx': 0, 'response_bytes': step * 8_192,
|
||||
'static_requests': step * 24, 'php_requests': 0,
|
||||
'other_requests': 0,
|
||||
}, 'php': None},
|
||||
}}
|
||||
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(
|
||||
headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (320, 375, 640, 800, 980, 1440, 1920):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 900})
|
||||
errors = []
|
||||
page.on('pageerror', lambda e: errors.append(str(e)))
|
||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
||||
page.add_style_tag(content=CSS)
|
||||
page.add_script_tag(content=app_bundle())
|
||||
initial = page.evaluate('''(status) => {
|
||||
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||
document.getElementById('login').hidden = true;
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('panel-web-server').hidden = false;
|
||||
currentPanel = 'web-server';
|
||||
envelope = {revision: 10, config: {web_services: [
|
||||
{id: 'site-A', name: 'Example service', document_root: '/var/www/a', index: ['index.html'], php: {enabled: true}},
|
||||
{id: 'site-B', name: 'Static service', document_root: '/var/www/b', index: ['index.html']}
|
||||
], vhosts: [], php_fpm_profiles: []}};
|
||||
window.__clock = 1000;
|
||||
Object.defineProperty(performance, 'now', {configurable: true, value: () => window.__clock});
|
||||
latestStatus = status;
|
||||
window.rustProxyWebServer.render();
|
||||
const list = document.querySelector('#web-service-list');
|
||||
const cards = [...list.querySelectorAll(':scope > .vhost-card')];
|
||||
const metricPanels = cards.map(card => card.querySelector('.www-service-panel'));
|
||||
const grid = metricPanels[0].querySelector('.vhost-live-grid');
|
||||
const chart = metricPanels[0].querySelector('.vhost-chart');
|
||||
const svg = chart.querySelector('svg');
|
||||
const svgBox = svg.getBoundingClientRect(), svgMatrix = svg.getScreenCTM();
|
||||
const rect = node => node.getBoundingClientRect();
|
||||
const expectedMetricLabels = ['webLiveRequests','webLiveRate','webLiveErrors',
|
||||
'webLiveLatency','webLivePhpCapacity','webLiveBytes'];
|
||||
return {
|
||||
cards: cards.length, sectionTags: metricPanels.map(panel => panel.tagName),
|
||||
collapsibles: list.querySelectorAll('details, summary').length,
|
||||
liveFields: metricPanels.map(panel => panel.querySelectorAll('[data-web-live-field]').length),
|
||||
labels: [...grid.children].map(tile => tile.querySelector('span')?.dataset.i18n),
|
||||
tileClasses: [...grid.children].map(tile => tile.className),
|
||||
values: [...grid.querySelectorAll('strong')].map(el => el.textContent),
|
||||
phpMissing: metricPanels[1].querySelector('[data-web-live-field="php"]').textContent,
|
||||
columns: getComputedStyle(grid).gridTemplateColumns.split(' ').length,
|
||||
chartWidth: rect(chart).width, gridWidth: rect(grid).width,
|
||||
cardWidth: rect(cards[0]).width, listWidth: rect(list).width,
|
||||
svgWidth: svgBox.width, svgContentWidth: svg.viewBox.baseVal.width * Math.hypot(svgMatrix.a, svgMatrix.b),
|
||||
preserveAspect: svg.getAttribute('preserveAspectRatio'),
|
||||
stroke: getComputedStyle(svg.querySelector('polyline')).vectorEffect,
|
||||
chartLabel: !!chart.querySelector('.vhost-chart-head .small-label'),
|
||||
rendered: getComputedStyle(metricPanels[0]).display !== 'none' && rect(chart).height > 60,
|
||||
fitsViewport: document.documentElement.scrollWidth <= innerWidth + 2,
|
||||
expectedMetricLabels,
|
||||
};
|
||||
}''', status(1))
|
||||
expected_cols = 2 if width <= 640 else 3 if width <= 980 else 6
|
||||
assert initial['cards'] == 2, (width, initial)
|
||||
assert initial['sectionTags'] == ['SECTION', 'SECTION'], (width, initial)
|
||||
assert initial['collapsibles'] == 0, (width, initial)
|
||||
assert initial['liveFields'] == [7, 7], (width, initial)
|
||||
assert initial['labels'] == initial['expectedMetricLabels'], (width, initial)
|
||||
assert initial['tileClasses'] == ['vhost-live-metric'] * 6, (width, initial)
|
||||
assert initial['values'][0] != '-' and initial['values'][4] == '3 / 16', (width, initial)
|
||||
assert initial['phpMissing'] != '-', (width, initial)
|
||||
assert initial['columns'] == expected_cols, (width, initial)
|
||||
assert initial['chartWidth'] > 0.95 * initial['gridWidth'], (width, initial)
|
||||
assert initial['listWidth'] - initial['cardWidth'] < 3, (width, initial)
|
||||
assert abs(initial['svgWidth'] - initial['svgContentWidth']) <= 2, (width, initial)
|
||||
assert initial['preserveAspect'] == 'none' and initial['stroke'] == 'non-scaling-stroke', (width, initial)
|
||||
assert initial['chartLabel'] and initial['rendered'] and initial['fitsViewport'], (width, initial)
|
||||
|
||||
sampled = page.evaluate('''([first, second, last]) => {
|
||||
const api = window.rustProxyWebServer;
|
||||
const panel = document.querySelector('[data-web-live-id="site-A"]');
|
||||
const rate = panel.querySelector('[data-web-live-field="rate"]');
|
||||
window.__clock = 4000; api.renderLive(first, true);
|
||||
window.__clock = 7000; api.renderLive(second, true);
|
||||
const before = {rate: rate.textContent, points: panel.querySelector('polyline').getAttribute('points')};
|
||||
// Service-list redraw must show the current status without opening anything.
|
||||
latestStatus = last;
|
||||
api.render();
|
||||
const updated = document.querySelector('[data-web-live-id="site-A"]');
|
||||
const total = updated.querySelector('[data-web-live-field="requests"]').textContent;
|
||||
const split = updated.querySelector('[data-web-live-field="split"]').textContent;
|
||||
return {before, total, split,
|
||||
pointsAfterRedraw: updated.querySelector('polyline').getAttribute('points'),
|
||||
metricsRemainVisible: !!updated.querySelector('.vhost-live-metric'),
|
||||
overflow: document.documentElement.scrollWidth - innerWidth};
|
||||
}''', [status(2), status(3), status(4)])
|
||||
assert sampled['before']['rate'] not in ('-', '0/s', '0.0/s'), (width, sampled)
|
||||
assert sampled['before']['points'].count(' ') >= 1, (width, sampled)
|
||||
assert sampled['total'] != initial['values'][0], (width, sampled)
|
||||
assert sampled['split'] != '-' and sampled['metricsRemainVisible'], (width, sampled)
|
||||
assert sampled['pointsAfterRedraw'] == sampled['before']['points'], (width, sampled)
|
||||
assert sampled['overflow'] <= 2, (width, sampled)
|
||||
assert not errors, (width, errors)
|
||||
print(f'PASS 0.33.4 WWW live metrics visual parity at {width}px; {expected_cols} columns')
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS always-visible full-width WWW/Vhost parity with simulated updates (not Rust runtime)')
|
||||
Reference in new issue
Block a user