v0.30.0
This commit is contained in:
1 parent
1431dd47f0
commit
f3dd34dae4
42 files changed
+950
-133
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
|
||||
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
|
||||
compatibility. Current source version: 0.29.7.
|
||||
compatibility. Current source version: 0.30.0.
|
||||
|
||||
## Delivery
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
> test-only changes and packaging implementation notes were moved to
|
||||
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
||||
|
||||
## 0.30.0
|
||||
|
||||
- Place per-backend health-check editing in the vhost **Health** tab with a selector containing only monitored backends. Keep independent edits when switching, and preserve overrides while health monitoring is disabled. Existing vhost JSON stays compatible.
|
||||
- Improve long-running memory behavior further: reclaim expired cache PURGE-generation metadata, avoid per-request allocations for repeated analytics labels, reuse JSON access-event serialization with Loki, and remove redundant buffers from local directory listings. Keep all outputs/configuration semantics unchanged. See [memory stress verification](docs/MEMORY_STRESS.md); Rust runtime/RSS acceptance is still required.
|
||||
- Share one JSONL serialization between file and Logstash sinks. Limit in-memory directory inventories while scanning historical per-vhost logs and gzip rotations, rather than collecting all matching files before truncation.
|
||||
- Keep SQLite schema 9 and Cold Cache format 3 unchanged. Rust build, long-duration RSS/heap measurements and real traffic tests remain mandatory before production acceptance.
|
||||
|
||||
## 0.29.7
|
||||
|
||||
- Introduce per-backend `health_check_enabled` (default true) and optional per-backend `health_check` override (default null): both active probes and passive health ejection can be switched off without disabling a path-pinned backend. Add UI, status and validation.
|
||||
|
||||
Generated
+1
-1
@@ -1688,7 +1688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.29.7"
|
||||
version = "0.30.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-proxy"
|
||||
version = "0.29.7"
|
||||
version = "0.30.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
description = "RustProxy - independent reverse proxy with local administration"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# RustProxy 0.29.7
|
||||
# RustProxy 0.30.0
|
||||
|
||||
Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration,
|
||||
streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools.
|
||||
|
||||
**0.29.7 release source.**
|
||||
**0.30.0 release source.**
|
||||
|
||||
## Layer-4 streams
|
||||
|
||||
@@ -11,7 +11,13 @@ Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, a
|
||||
|
||||
Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners.
|
||||
|
||||
## What changed in 0.29.7
|
||||
## What changed in 0.30.0
|
||||
|
||||
- Edit individual active health-check policies in **Vhosts → Health**. The backend selector lists only members with monitoring enabled in **Backends**. Choose a member and optionally customize its TCP/HTTP mode, path, Host, interval, timeout, thresholds and expected status range. Other backends retain their unsaved edits; disabling monitoring hides its editor but preserves the saved override for future re-enablement. No API or SQLite schema changes.
|
||||
- Improve sustained-memory handling: reclaim selective PURGE-generation bookkeeping, avoid unnecessary analytics-label allocations, reuse serialized access JSON across JSONL/Logstash/Loki, and avoid duplicate directory listing vectors/HTML buffers. Stored Logs discovery retains at most 256 per-vhost and 64 rotated-gzip candidates. See [memory stress verification](docs/MEMORY_STRESS.md) for the audit and real-world RSS/load test procedure; source-only checks cannot rule out runtime leaks.
|
||||
- The previous 0.29.7 improvements remain included. SQLite schema 9 and Cold Cache format 3 are unchanged.
|
||||
|
||||
## Previously in 0.29.7
|
||||
|
||||
- Configure active/passive monitoring per backend independently from backend enablement and balanced-pool eligibility. Optional per-member active TCP/HTTP health-check overrides inherit the vhost's HTTP Host when omitted. An unmonitored internal 404 backend remains routable but is no longer probed or passively ejected.
|
||||
- Add global and per-vhost **HIT-only cache diagnostics**. Suppress X-Cache, X-Cache-Hits, X-Cache-Reason and Cache-Status on MISS/BYPASS, while retaining diagnostic headers on actual HIT. The separately configured Via header still applies; internal metrics are unaffected.
|
||||
@@ -57,7 +63,7 @@ Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost
|
||||
- Add streaming **multipart byte-range** responses for requests such as `Range: bytes=0-99,200-299`, with a maximum of 16 ranges and identity representation for multipart responses.
|
||||
- Add opt-in PHP-FPM **PATH_INFO** splitting. With `path_info: true`, `/index.php/foo/bar` securely resolves the existing `index.php` script, keeps `SCRIPT_FILENAME` on that file and sends `/foo/bar` as `PATH_INFO`.
|
||||
|
||||
0.29.7 keeps SQLite schema 9 and Cold Cache format 3. Existing configurations remain loadable; new optional health and diagnostic fields receive backward-compatible defaults. Historical details are in [CHANGELOG.md](CHANGELOG.md).
|
||||
0.30.0 keeps SQLite schema 9 and Cold Cache format 3. Existing configurations remain loadable; new optional health and diagnostic fields receive backward-compatible defaults. Historical details are in [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
### Historical 0.26.7 release notes
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# RustProxy roadmap
|
||||
|
||||
## 0.30.0 - Per-backend Health editor and logging resource review
|
||||
|
||||
- [x] Provide an intuitive Health-tab backend selector restricted to monitored members, with independently editable TCP/HTTP policies.
|
||||
- [x] Deduplicate file/Logstash JSONL serialization; cap Stored Logs directory scan retention during enumeration; review long-lived queues and other memory-heavy facilities.
|
||||
- [x] Preserve vhost wire schema, SQLite schema 9 and Cold Cache format 3.
|
||||
- [ ] Compile and run Rust tests; perform long-duration memory profiling and real backend/protocol load tests before production release acceptance.
|
||||
|
||||
## 0.29.7 - Backend health scoping, PHP-FPM isolation and HIT-only cache diagnostics
|
||||
|
||||
- [x] Support per-backend active/passive health monitoring disablement and independent health-check settings.
|
||||
|
||||
+41
-35
@@ -1,18 +1,18 @@
|
||||
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||
ed172139a0cd60d99e85aee2fc7e475491c4ab90d799afce2aeaaed1aa6d2bfe AGENTS.md
|
||||
ce1a2e838ebbbd866104beb8dba475bbb431d657be5bddab132502dcd259e3df CHANGELOG.md
|
||||
0ae3665f323feb3b14eba4f642c01e997fe277aaa50c5c0bcd6e3edec9c57bcc Cargo.lock
|
||||
edb7ec2554d3247f04722b166a0cf268fa8a185325dffa3a9a2e607e965362eb Cargo.toml
|
||||
14cfdff48d3df448f246af54f80ddb5c4dc906f824fab7e6c219406243714cc3 AGENTS.md
|
||||
793c2a2ebd9168745f39559d2ac4658517d407d578beee54d9c70594cb75c3c9 CHANGELOG.md
|
||||
fc20847abf0b96bdcb54ddbfb1df4b134301f15fa2cbfd4ea11dd4d368296484 Cargo.lock
|
||||
d5d77528a9703ee0a0a0c736267f856a7caaf9400a9b9c6a411589feea7a18c3 Cargo.toml
|
||||
75b008f237b2226c62a7721b7901018ce83bfc2726f047a83a17c66fcb799f3b Dockerfile
|
||||
bcd955e12490a269efa26ecec3c00705f98a0cc7a4d73a9c7506a84139093187 README.md
|
||||
d44341887a6faa45886db572fe43922113abb1f2a223f7f6c71025cd9374ea71 ROADMAP.md
|
||||
39e7600cbf426c2f780b58b6d9c9f26726388b34146a38af815c7ba933d61dd7 README.md
|
||||
0a5d19aa357cb6645ee6b48926201155510fabf3c7da07c90446f9e8b405a82e ROADMAP.md
|
||||
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
||||
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
|
||||
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
|
||||
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
|
||||
ff46619bd0a8ecf5e4afbe7179385d6d2064a41d36d28d2b4d80a9a006be6074 dev.sh
|
||||
2661e66c34cdfcd6d6460b5c6e8194724d17d98b4fdb9e2c4020d9ff3087479c dev.sh
|
||||
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
|
||||
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
|
||||
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
|
||||
@@ -37,12 +37,13 @@ c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER
|
||||
befa50579e3475b22589f26380d85b3345b80e4fa947d49649a3b91a8dd8b8c2 docs/CUSTOM_LOCATIONS.md
|
||||
16d8f59908c66e595d6003495cbf5c068c68b10aa9495eb8612c38b2de0e4a7c docs/DOCKER_DISCOVERY.md
|
||||
5324b215075214a5bb9b388bc82d6491eacfc507df95856d1aecedd184e25b80 docs/ERROR_LOGGING.md
|
||||
7dacf2129f2575823d26104ce12fd48a951906c8b2cfd40a7981b8e14813b154 docs/FILE_LOGS.md
|
||||
35b7784afa9a2f7d805f7cd1bd188f93050fc0ebaea61b30152075fc7a2549bb docs/FILE_LOGS.md
|
||||
8dfc89a8b3512103b87864bd7c24a3dc3de4465edf8f9874e6bed1f4634dd930 docs/HTTP3.md
|
||||
a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md
|
||||
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
|
||||
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
|
||||
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
|
||||
633ed36201bf8c0c72d21596183aac9f28113c0ad2d9154abd532a8cb7c91543 docs/MEMORY_STRESS.md
|
||||
fea1ff4055d0930edeffb0c8501a6ba1ac55ec7a7064dcd0e300a43915e8432e docs/MIGRATION.md
|
||||
5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md
|
||||
1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md
|
||||
@@ -51,7 +52,7 @@ e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND
|
||||
e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md
|
||||
bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md
|
||||
572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md
|
||||
926fb145ba58b6c9e7af6474676da22c9e6231dd86c41703f343e6457c34bbc6 docs/README.md
|
||||
f0786a65c93ba00830604fcc52004566199e757941ccc9481eb7b7b4232472e1 docs/README.md
|
||||
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
|
||||
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
|
||||
d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md
|
||||
@@ -61,7 +62,7 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H
|
||||
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
|
||||
d5c5c59f6e63b8d83dad5d159657c4d4039dfeb4cc1d8af210169b576adbf7e2 docs/SETTINGS.md
|
||||
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
|
||||
91d505c03649a62644a08b4458ce2e65597d2c491a45659f52a29c69e62e1166 docs/TEST_REPORT.md
|
||||
b4c88af2c27c8dea853ea55bc451ca4cca2a59ec086b59e52ade1227794ac097 docs/TEST_REPORT.md
|
||||
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
|
||||
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
|
||||
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
|
||||
@@ -133,8 +134,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
|
||||
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
|
||||
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
|
||||
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
|
||||
3734133583c50573492e93cddc74e5c374330f04dfaa2488597b509ac4bc5bfa langs/en_US.json
|
||||
be73dbfc30ba30b31cc97debb6b35e8fd354756f1227311ed6c99662c2d5a7e7 langs/pl_pl.json
|
||||
400b61050ff437aa8476f05a9cd05be6f3b8b2853ddf563591712d118bc95199 langs/en_US.json
|
||||
2b4564cfdacfc0ad7e5ec1554bae3af09842a77a45cece7652044506787c8135 langs/pl_pl.json
|
||||
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
|
||||
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
|
||||
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
|
||||
@@ -176,22 +177,22 @@ c8941a929dfef9e8875adf534394ca01a1532e567d5099fefe3657826886ee25 src/acme/runti
|
||||
bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/access.rs
|
||||
019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs
|
||||
1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs
|
||||
db77101b3c48292538f41694f087c498933d22a14e303445540187c92fcada0c src/admin/documentation/documents.rs
|
||||
f84cdf7dc40553b6c5086446bafd5c700496f048230ebd95a01839858adff071 src/admin/documentation/documents.rs
|
||||
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
|
||||
002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs
|
||||
e9f72e65c9ceb255248f460cd7a003fff33e4b258160e248353c3e1fb4e952fb src/admin/file_logs.rs
|
||||
1596561bb57976268b02e36e8ace426091045ee9d3f8832ce775eccdbc30bc81 src/admin/file_logs_io.rs
|
||||
2ebaf24e9d0d87383d2eea073ee2d3ac37962eeb503a0f1ee2c2de4f3f6d1b7a src/admin/file_logs.rs
|
||||
d7aa68a78a4cb5a61342d92c1f3209af230d0f9ca3fea1115f67078372e6f37c src/admin/file_logs_io.rs
|
||||
b3e2c11dbc11bb8930117afdac64779111acc2ca7b2857f6e359a5108fc9ced8 src/admin/logging_api.rs
|
||||
58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs
|
||||
ddaa17ded3686ea0e0947b3968787bdbb161b32da586643485edae948667d03e src/admin/openapi.json
|
||||
13aa83f3a10b19205c63349c964b2e86322c1d0486c2d1d52429080ac9d9d401 src/admin/openapi.json
|
||||
6c2b9a0ecb2f4172f110a363c22a6be32895dbcff9605fe7aa94efe91e0a536a src/admin/operations.rs
|
||||
5287fff6b3ad436ac695e9b66feb1b0b5705b744a9f00cec7ea6a3e951e5eebf src/admin/preferences.rs
|
||||
31bd90ee5f5ec2984920225ff6b57e07db3e0758092fb55d7c230e3590c1b015 src/admin/public.rs
|
||||
fa6982ae8bbff1b7373634263493ee12b20b49d27b4aecd6268eb6502b7b8ee5 src/admin/router.rs
|
||||
9edf51cfbeb51ec5062fa24803247f6117a45dfe9ac731215470c3d034296020 src/admin/router.rs
|
||||
8708427263cd4caf8454b9115e89acb09172483a090c6b0bd43811dd9e52c7f6 src/admin/session.rs
|
||||
bebf42cd1808a99a0e669d483951a441ffa22c8d5a0a289c64491adf13c8841a src/admin/status.rs
|
||||
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
||||
59ea2bb773c86c3d8240ccc212a8bf7f670e17e360def9e773906469ef135ac5 src/admin/tests.rs
|
||||
5a8e2c4d0065c98f4686105c6f00362dd515f70f1ed662187f943ba4b76c54ad src/admin/tests.rs
|
||||
5df98246c46135a176b4b8b705c92fd328719e82eb7342f088256abb976d5ae7 src/admin/tests/part_01.rs
|
||||
6f8c1e30babb2cc4cb7accf4bfa28338651a469b16341f6e7eaec881dd9abf2b src/admin/tests/part_02.rs
|
||||
34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs
|
||||
@@ -199,6 +200,7 @@ cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail
|
||||
5f0062abc42a3b81c79088dc0824ab4c81a22c6ea765e77d2527d07d13bffd58 src/admin/tests/part_05.rs
|
||||
311c4ad16b3ad5b295a339241afd4ca8ea0d3c7f54f609c27518b4de1b4a4b71 src/admin/tests/part_06.rs
|
||||
5fa006413fd93529ac9aaa4ffb28bf6b0b88d0a55a0bf0c49576db4a06fe9fc3 src/admin/tests/part_07.rs
|
||||
54ed2130e85239ffa4576417dd68fbb9349e396210b75f5da862897137d7e876 src/admin/tests/part_08.rs
|
||||
2ae5bc857dd62882f16d1cfcee9691c9ba8afc149f4c0435addd48b564487517 src/admin/web/acme.js
|
||||
446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js
|
||||
ed063b8a414cf3b5e539c900e8722aa82d84c561a5cd54d875650793a25529e2 src/admin/web/app/core.js
|
||||
@@ -230,12 +232,13 @@ af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/
|
||||
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
|
||||
7fc4c3661524f11448bc43ebe8f494ed44b8b0a414e63ce5bb8717f314c33808 src/admin/web/settings.js
|
||||
bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js
|
||||
0798eb842c2413acf20001cbe750452c7697a3c8376630af45cf7d2b19bf6175 src/admin/web/style.css
|
||||
db0ea03125967ea35c4fa6a4d9284fa5dcc5782cd655fcdbc4254ce3e24b7133 src/admin/web/style.css
|
||||
a8a73a4fa849ae49391fcd6143e900c515c8b577f71b55cbae9701fa9f5bbe47 src/admin/web/traffic-panel.js
|
||||
e686d68fd6a368110a85dc4c5dc64e8093950d20d63e9aa44790c03dea6fc193 src/admin/web/transform-panel.js
|
||||
71ebb6c4e963a62b0ee7564ab82eace665aec7016bcb8bd656225d53f6744143 src/admin/web/vhosts/core.js
|
||||
6f4ec05a59bbc5cce890394247cd56ba5da38a250849952e68e001745e337bb7 src/admin/web/vhosts/editor.js
|
||||
d75e2de243b52ede09977675f585e29abf5a39e0707c323b28290667fded265e src/admin/web/vhosts/render.js
|
||||
57a2b975716bdab32c33ec1bf9427b25998550e81ce191440ee66ffb7e2a249e src/admin/web/vhosts/core.js
|
||||
7e0dc397ce6b2f90dfadf0f724688cb952b89183d06042c0e28d716301e5e7ea src/admin/web/vhosts/editor.js
|
||||
30a6344e76de77b60cd7247a33c68f1cb5d972aebfde56469f2ebd44440c7bf9 src/admin/web/vhosts/health.js
|
||||
f2689283cfe43d6aae911b0df778a69335089be230fbfc6de5dcce1801c38427 src/admin/web/vhosts/render.js
|
||||
91e3417fba7e8f59023cd64813befccc8341741cd4aa53dfa1ec68a03c7f4632 src/admin/web/web-server-profiles.js
|
||||
de3326d4fae254eb0b8c190f02e762f4094dcba86bfdcb24c5fb472676ebcc4b src/admin/web/web-server.js
|
||||
98c00d99822f9be54fc58afb6d4b577901712effd2c5eddce014312681864625 src/auth/files.rs
|
||||
@@ -271,7 +274,7 @@ d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/coll
|
||||
bbd6de8568fea79cc4a11d33b905af75713e609c8f37ec943c82f0d180e66dcc src/cache/fill.rs
|
||||
675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs
|
||||
c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs
|
||||
7ed64ac876e3be828401e493f08a8d54f28ea75d2c2ff156987848f9dc6097a9 src/cache/invalidation/invalidate.rs
|
||||
0cc31d8e70fb3774c4ce3713b94ed79c184a8d8f0d38ce66a97724410733e17e src/cache/invalidation/invalidate.rs
|
||||
b74945c7460b76ed7afcb02a45ede09d76f08e0703c47920b9b9d794d4b74980 src/cache/lifecycle.rs
|
||||
bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs
|
||||
88f6210c03d7a8e7a35be1f123c9c15675af6e6a599c50db927d99866851b634 src/cache/lookup/hot.rs
|
||||
@@ -302,7 +305,7 @@ f4c56db79cf939a58c25253ce9abb18faeb90fe1454bfafec7c1f2e3385f78ce src/cache/test
|
||||
cdc41a7241739b89297494323f29e0230fa06abbd74e219fedfda11df1db4334 src/cache/tests/tests/part_04.rs
|
||||
541a62b749440aa1099cf0fc8a2bd31eac0c082ccef35df22c1e049e7928e216 src/cache/tests/v022_cache_tests.rs
|
||||
15e85357c3ddf4fc2283d621f5f4cd8ca309275e06677020299d4105f0475949 src/cache/tests/v022_cache_tests/part_01.rs
|
||||
7ee652a781081c0b53d39dff213c2bda38e405a18a3d0b908f97cd3ac51d556f src/cache/tests/v022_cache_tests/part_02.rs
|
||||
f262c4e946421b06198ab73ff6e36e6826848f53ce8396eada666c64935c2e70 src/cache/tests/v022_cache_tests/part_02.rs
|
||||
a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tickets.rs
|
||||
3fc79b09474fe23b5b6fc98e4a376469afb02a41d6cc2dbbba59dc6091c790c8 src/compression.rs
|
||||
e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compression/tests.rs
|
||||
@@ -347,7 +350,7 @@ d0cd8589321595309834690fa9ddb98488886bc98f34ea45aa0ccb3035412fb5 src/http3.rs
|
||||
3849a09a7d7402dc4d867b646cda153c574327ddd3c707578ddb4dc6f95b1563 src/logging/error_runtime.rs
|
||||
a16613114273c54b6d2a16a94fa8748550e91468585469642622bd2c86087baa src/logging/format.rs
|
||||
f5815d778cc1785723faed4abb15f9880f22b6524b5289fe1e7158905aac0986 src/logging/model.rs
|
||||
7c0d8e8f46f96cbaf3a5cb5fd47953f9185d7558dd71e599676bc63578b7ca99 src/logging/runtime.rs
|
||||
42fef80af50735cd566c6801dad86ba81be98cf4a8ec7d45c2ea26f53f556a22 src/logging/runtime.rs
|
||||
03c50bd14fecd421b0901a5f0af891dc277e35420e32068781e3a6d78c88c24b src/logging/tests.rs
|
||||
3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs
|
||||
2f6b5a9517453541bf43580a9d963e64e36338575ee9ad295ca3718803a59ed6 src/main/admin_runtime.rs
|
||||
@@ -437,7 +440,7 @@ d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_sta
|
||||
e0ab65406911d4b95cb26594df65779a88d9011d5b11adb22c1a28831cda3826 src/tls/tests/acme.rs
|
||||
cfab6b61f0d121a15d6f78943091c770cb5a2a023d68d66be3c0228f9c8adf9c src/tls/tests/external_paths.rs
|
||||
10c4a187e3aeb0c72a8c8968ea0cc8bfae4e3cb2f3d834ebd3da1056c4171ec5 src/tls/types.rs
|
||||
438506b1743daddcbefe0ebec5a6050d5f9555710446a1578c0a3d326d4b68ac src/traffic.rs
|
||||
c2f802d95da4332fb8480e83f0c7502334457b3408141c1c4516c9ba320a0463 src/traffic.rs
|
||||
36301bcbd20b557dd3d58e77a9175f3581947f577c0bb0b972899cec6786b03f src/traffic/tests.rs
|
||||
d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs
|
||||
87f8af5261d79072f8215f1fb8a90715a58e7626544fd8176ce840e3111bdb92 src/transform/compile.rs
|
||||
@@ -485,7 +488,7 @@ c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server
|
||||
20509f9302b79d3a7be76dda6edaf5711826d3ddf47d19ebe07cb289427ca328 src/web_server/response/headers.rs
|
||||
9b58d6bb3ff1520060fc5aa86a3594f99192b1a8f8183255be1fb3ff35b4bb35 src/web_server/routing.rs
|
||||
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
|
||||
d61ebdab046b6d2f8e34bb3058c5eb3f2d0766be573810387f48dd3116dc1a6d src/web_server/serve.rs
|
||||
518856a221161430cb0ab3e411fa81cab07e1a6e52edd470f026d25c5307943e src/web_server/serve.rs
|
||||
63d71a336187779e9cbc1cc6afa065c5fa9472835a8c2cc15dbf4527279d0c90 src/web_server/tests.rs
|
||||
9426ad19fd6234e944d6e122a995d2cf8ebe4177d27e42fd157f64d05abd1e23 src/web_server/tests/part_01.rs
|
||||
6f0b68d732fa0d1385744ef7ac1b36d558f9532f9ef1dea44d3dcc47d149840a src/web_server/tests/part_02.rs
|
||||
@@ -498,25 +501,28 @@ eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies
|
||||
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
|
||||
b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md
|
||||
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
|
||||
e5e352a10f0bdc748f583ee8036c7be1055ecf08228920774b171d248b9342d7 tools/checks/backend_cache_0297_ui_test.mjs
|
||||
591a69193c303d401d2fe85c828748ca64e1ee2357d56b1924f8ef03dfdbc367 tools/checks/check_source.py
|
||||
01af9e83643e431bb977de7d85fd257b425f029b19a31de5826122b8536b6120 tools/checks/docs_check.py
|
||||
412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_ui_test.mjs
|
||||
2a493dbce2991a23d807c01952f58608a364f541560836b4cb385e75e108e9e7 tools/checks/backend_health_0300_ui_test.mjs
|
||||
e36b2774f9e2510c760d885cb116bda1e9a14a13f477c96c250f3532258add8f tools/checks/check_source.py
|
||||
297dc71c84b5ead2b6e7cef6469596854f5d5e21a83d6b9b3861ea45cf23f672 tools/checks/docs_check.py
|
||||
e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
|
||||
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
|
||||
cf762780c4085791c72c3c5f93054d7f2a1107403bee0616a1f4113bbcbb99f9 tools/checks/memory_contracts.py
|
||||
23552af0cd0e3fd1312d379af4b524be95ee79aa78528ad506ae9b23ab21ad26 tools/checks/preflight.py
|
||||
bb590f1a6d6111e5be36dc4b4b272594c9620a636a76174f27adff52962609bd tools/checks/release_smoke.py
|
||||
f8f87ba891338b565249d02c3cf5f02cec44e606b4205de99fcbea1eb2c24d57 tools/checks/memory_contracts.py
|
||||
456f92623672ec6a7e891e4a642cc6f0b75c38f930e698d9401e3fd906cb3bc6 tools/checks/preflight.py
|
||||
2478e8dbb056830ddc6b39abc4bdf97978b6024973c59f21fa933992dfd8b86b tools/checks/release_smoke.py
|
||||
f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py
|
||||
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
|
||||
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
|
||||
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
|
||||
2309c41e769e797c838d4f40d50bb3c6b9ad9ad0f9ed07fb93e07f95d66563f9 tools/checks/source_module_contracts.py
|
||||
2e125a2a807553ac81d04428f337f5042eed968cb807244487114fdd8ea6b5d9 tools/checks/source_module_contracts.py
|
||||
987b4c593b51a7c340d8f01f9a2eea1387043cdd997eeb932c8341bbb8c0bd64 tools/checks/version_contracts.py
|
||||
2020e171be1a044f7ca2ab83af7604a014c36fe4ff04791d0620ccbf1bf8e000 tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
||||
0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs
|
||||
0aaa80453e25aae7ea953fadb91b3ae3eaff627977c81c9ab5a2779f9a93582f tools/checks/web_assets.py
|
||||
934dce837a13c8e9eee8e161aeb8b63dc771e2197c61639cf59e33b5ceaca833 tools/checks/web_assets.py
|
||||
0398892be30fe416a00578915c588f360fd93244e3af77ece834e560e547d1c8 tools/checks/web_server_contracts.py
|
||||
35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs
|
||||
adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs
|
||||
eb4e9a72bdc8e134ee67712a656b6e42c4bbc1f1602a1039052977055cfc6a64 tools/dev/memory_load.py
|
||||
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
|
||||
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
|
||||
538b5f6cfa738355a729940ee3d92e7ab3dc25ca69ae330ddaa08f831775b8ba tools/dev/test_compression.py
|
||||
|
||||
@@ -95,12 +95,14 @@ if [[ "$mode" == "test" ]]; then
|
||||
python3 tools/checks/check_source.py
|
||||
python3 tools/checks/release_smoke.py
|
||||
python3 tools/checks/memory_contracts.py
|
||||
python3 tools/checks/high_traffic_memory_model.py
|
||||
python3 tools/checks/web_server_contracts.py
|
||||
python3 tools/checks/security_contracts.py
|
||||
python3 tools/checks/security_dependency_contracts.py
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node tools/checks/web_service_ui_test.mjs
|
||||
node tools/checks/backend_cache_0297_ui_test.mjs
|
||||
node tools/checks/backend_health_0300_ui_test.mjs
|
||||
node tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
||||
node tools/checks/www_redirect_ui_test.mjs
|
||||
node tools/checks/vhost_www_ui_test.mjs
|
||||
|
||||
@@ -67,6 +67,8 @@ Logrotate-style gzip rotations are discovered next to configured combined access
|
||||
|
||||
Gzip reading is bounded before and after decompression. A compressed source larger than 8 MiB is not decompressed for a query, decompressed output is capped at 64 MiB, and only the bounded tail is parsed/searched. These limits prevent a gzip rotation from becoming an unbounded memory/decompression workload.
|
||||
|
||||
Per-vhost directory discovery now retains only the newest **256** matching JSONL/gzip candidates while scanning, and a combined log’s gzip-rotation scan retains only the newest **64** matches. Both caps apply before sorting, so directory size no longer causes a proportional memory allocation for the inventory. The usual 32-source query budget still applies. Filesystem enumeration still takes time proportional to the number of directory entries.
|
||||
|
||||
The reader remains bounded:
|
||||
|
||||
- up to 2 MiB of retained tail data from one source;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Long-running memory verification (RustProxy 0.30.0)
|
||||
|
||||
This procedure supplements static source checks. A synthetic Python model and a
|
||||
local Python HTTP peer **do not execute RustProxy** and cannot establish its
|
||||
production RSS, heap growth or absence of leaks.
|
||||
|
||||
## Audit coverage
|
||||
|
||||
| Area | Source-level finding | Action in the same 0.30.0 version |
|
||||
| --- | --- | --- |
|
||||
| Selective cache PURGE | Per-key generation metadata could retain every distinct purged key indefinitely after its object was removed | Reset stale per-key metadata at the existing global generation barrier; drop an abnormally large map allocation; regression for 512 different purges |
|
||||
| HOT/COLD cache | Body/entry limits, bounded transient leases, limited cold queue, revalidation slots and background cleanup permits | Kept existing behavior |
|
||||
| Log output (JSONL/file/Logstash/Loki) | Serialized the same entry again when Loki and a JSONL sink ran simultaneously; sink queues and per-vhost open files were already bounded | Reuse serialized JSONL content for Loki; keep redaction and sink-specific formatting |
|
||||
| Historical log discovery | Prior audit already capped directory inventory while scanning, at 256 per-vhost and 64 gzip entries | Retained existing 0.30.0 limits |
|
||||
| Traffic analytics | Top lists limited to 32 entries per dimension, at most 64 vhosts and a 2048-item input queue, but repeated labels incurred transient `String` allocations | Borrow existing labels, allocate only when admitting/replacing a top-list entry |
|
||||
| Local directory listing | Two simultaneously live vectors for directory metadata and a copied HTML response | Filter directory metadata in place; move output bytes into response; keep all filenames and HEAD Content-Length behavior |
|
||||
| TCP/UDP streams and HTTP/H2/H3 | Connection/session permits, finite queues, idle deadlines and completed task draining | No change |
|
||||
| Upstream clients, DNS, health probes | DNS worker limit, timeouts, pool recycling, bounded health checks | No change |
|
||||
| Compression, transforms, PHP-FPM | Streaming response paths, transform memory and concurrency budgets, FastCGI streaming | No change |
|
||||
| Rate limits, ACME, Docker, background tasks | Limited client buckets with expiry and shrink, cancelled/limited background activities | No change |
|
||||
|
||||
This is **not** a proof that every runtime allocation is released. The directory
|
||||
listing feature intentionally generates the complete visible directory as HTML;
|
||||
very large directories may still need substantial temporary memory. A high RSS
|
||||
after a load spike may also be allocator reuse instead of an active leak. Verify
|
||||
with real-runtime measurements rather than assuming either explanation.
|
||||
|
||||
## Source/synthetic verification
|
||||
|
||||
```sh
|
||||
python3 tools/checks/memory_contracts.py
|
||||
python3 tools/checks/high_traffic_memory_model.py --http-requests 10000
|
||||
./dev.sh check
|
||||
./dev.sh test
|
||||
```
|
||||
|
||||
The offline test replays 300,000 distinct PURGE identities over six periods.
|
||||
It verifies the old cumulative model retains 50k/100k/150k/200k/250k/300k
|
||||
metadata keys whereas the revised epoch-based model holds one key after each
|
||||
operation. This is a **cardinality model**, not a Rust heap measurement. It also
|
||||
sends 10,000 HTTP GETs across 24 concurrent persistent connections and 1,000
|
||||
paths to a **Python test peer** to validate the load driver (the default quick
|
||||
`./dev.sh test` run sends 2,000 of those requests). The Rust regression
|
||||
for 512 distinct purges is executed only if `cargo test` runs successfully.
|
||||
|
||||
## Real RustProxy staging traffic and RSS
|
||||
|
||||
On a Linux staging machine with Rust/Cargo installed, configure a vhost and a
|
||||
known reachable backend/local Web Service. Enable whichever logging targets you
|
||||
need to test (JSONL file, Logstash, Loki) in the normal dashboard. Start the
|
||||
proxy and determine the **RustProxy** PID, not the load generator's PID.
|
||||
|
||||
```sh
|
||||
./dev.sh check && ./dev.sh test
|
||||
# In another terminal: run the configured instance, e.g. ./dev.sh run
|
||||
pid=$(pgrep -n -x rust-proxy)
|
||||
|
||||
# Warm / repeated URL: cache HITs, persistent clients, normal JSON access logs.
|
||||
python3 tools/dev/memory_load.py \
|
||||
--url http://127.0.0.1:8080/ \
|
||||
--host-header example.test \
|
||||
--pid "$pid" --requests 400000 --concurrency 128 \
|
||||
--cooldown 120 --output /tmp/rustproxy-memory-repeat.json
|
||||
|
||||
# High cardinality: many URL paths, cache misses, large analytics churn.
|
||||
# Pick a staging path prefix accepted by your origin; status codes will be
|
||||
# recorded even when the requested URLs return 404.
|
||||
python3 tools/dev/memory_load.py \
|
||||
--url http://127.0.0.1:8080/ \
|
||||
--host-header example.test \
|
||||
--pid "$pid" --requests 400000 --concurrency 128 \
|
||||
--unique-paths 100000 --cooldown 120 \
|
||||
--output /tmp/rustproxy-memory-distinct.json
|
||||
```
|
||||
|
||||
Run at least three **successive** traffic/cooldown cycles on the same process.
|
||||
Test separately with access JSONL only, JSONL+Logstash and JSONL+Loki using
|
||||
reachable staging services; add streaming large response bodies, directory
|
||||
listings, TCP/UDP and HTTP/3 using appropriate specialized clients. Send
|
||||
controlled cache PURGE requests through your authenticated cache invalidation
|
||||
API and ensure previously purged cache entries never reappear.
|
||||
|
||||
The tool samples `/proc/<pid>/status` (RSS, high-water mark, threads) and
|
||||
`/proc/<pid>/fd` at a configurable interval. Output includes response codes,
|
||||
requests/second, received bytes, time series and start/peak/end RSS. Check
|
||||
steady-state RSS after each cooldown, open file descriptors, timeouts, dropped
|
||||
logs, HIT/MISS behavior, and whether growth **continues** across periods of
|
||||
similar load after caches and connections have stabilized. In addition, inspect
|
||||
cache/transformation memory accounting and capture a Rust heap/allocation
|
||||
profile on the actual instance if RSS rises repeatedly.
|
||||
|
||||
Do not run these load tests against an active production service without a
|
||||
maintenance or capacity plan. The load driver does not create vhosts, backend
|
||||
services, log collectors or a RustProxy executable, and it cannot reproduce
|
||||
all TCP, UDP, H2 and H3 protocol behavior. None of these real-instance
|
||||
acceptance steps were performed in the source-only audit environment.
|
||||
@@ -65,6 +65,7 @@ This is the canonical index for the current project documentation. The authentic
|
||||
|
||||
- [MOCK_SERVER](MOCK_SERVER.md) - local Python mock origin used by development/integration checks.
|
||||
- [MOCK_AUTH](MOCK_AUTH.md) - local fake Tinyauth peer for development only.
|
||||
- [MEMORY_STRESS](MEMORY_STRESS.md) - sustained-memory audit, offline load simulations, real RustProxy staging stress and RSS measurement procedure.
|
||||
|
||||
## Technical documentation
|
||||
|
||||
|
||||
+17
-1
@@ -1,4 +1,20 @@
|
||||
# Verification report - RustProxy 0.29.7
|
||||
# Verification report - RustProxy 0.30.0
|
||||
|
||||
## 0.30.0 same-version sustained-memory audit (2026-10-10)
|
||||
|
||||
- Reviewed cache metadata, HOT/COLD admission and cleanup, file/remote JSON sinks, live log history, analytics, local directory listings, HTTP/2/HTTP/3, TCP/UDP sessions, DNS/health tasks, PHP-FPM streaming, transforms, rate-limit buckets, and periodic tasks. See [memory verification](MEMORY_STRESS.md) for audited invariants and the real-runtime acceptance procedure.
|
||||
- Confirmed a long-run retention issue: the selective PURGE path incremented `Store.key_generations` for affected base keys and never reclaimed those identities until an all-cache purge. Its existing global `generation` barrier already invalidates all older fills/tickets, so obsolete per-key metadata can be reset per selective PURGE. Also release large HashMap capacity after mass invalidations. Added a 512-distinct-PURGE Rust regression (not executed without Cargo).
|
||||
- Removed unnecessary repeated analytics-label allocations using borrowed map lookups and allocation on insertion only. Filtered local directory listings in place rather than building a second metadata vector and moved generated HTML into the response instead of cloning it. Reused the existing redacted JSONL serialization when sending the same access event to Loki (while preserving the Loki-only path).
|
||||
- Offline test: **300,000 simulated distinct PURGE identities** over six intervals. Legacy bookkeeping model retained **50k → 300k** keys, whereas the corrected epoch model retained **1** key after each operation. The load driver sent **10,000 real HTTP requests** using **24 persistent clients** and **1,000 distinct paths** to a **Python test peer**, with expected status/body counts. These tests validate model behavior and driver operation only: **they do not measure RustProxy RSS, heap, or leak freedom**.
|
||||
- `./dev.sh test`: all Rust-independent source, security, SQL/upgrade, Node/browser UI, documentation/translation and memory-replay checks passed (including its 2,000-request peer smoke); **exit 127 only at the missing Cargo toolchain guard**. `./dev.sh check` also exits 127 for missing Cargo. `go test ./...` in `docker-agent` passed.
|
||||
- Rust/Cargo unavailable in this environment. Actual `cargo check`, Rust unit tests, RustProxy GET/PURGE under load, production-like JSON sinks, HTTP/2/3 and TCP/UDP traffic, and long-duration RSS/heap plateau measurements **remain pending**. No API, SQLite schema, Cold Cache format or version changes.
|
||||
|
||||
## 0.30.0 source verification (2026-10-10)
|
||||
|
||||
- Moved per-backend health-check overrides into the **Health** tab: a monitored-only backend selector reveals one persistent editor at a time, without changing the JSON configuration schema. Newly enabled custom policies start from the current shared policy; previously customized backends keep their configured values. Disabling monitor hides the editor but retains the override. Preserving draft switching, validation routing, rename and removal is covered by the simulated DOM regression.
|
||||
- Memory audit: access-file JSONL and Logstash now share one serialization for each event. Existing bounded file/error sink queues, live history, open files, traffic analytics and cache/transform budgets were checked in source. Removed two avoidable directory-inventory memory spikes: per-vhost log scanning now retains at most 256 candidates while enumerating and gzip-rotation scanning at most 64. Added a source contract and a Rust unit regression for 10,000 candidate filenames and deterministic newest-first ties.
|
||||
- The Node/UI, source, security, upgrade, SQL migration, translation and documentation checks in `./dev.sh test` are run in this environment; the Rust suite and full `./dev.sh check` cannot complete because `cargo` and `rustc` are unavailable (exit 127). `go test ./...` in `docker-agent` is run separately. The Rust unit regression is **included but not executed**. No real request-load test or long-running RSS/heap profile was performed, so the review cannot prove complete absence of runtime leaks.
|
||||
- No SQLite schema migration or Cold Cache format change; root Cargo package/lock and OpenAPI version set to `0.30.0`. Use a Rust toolchain for `./dev.sh check` and `./dev.sh test`, and measure RSS during sustained file/JSON logging, before deploying.
|
||||
|
||||
## Same-version 0.29.7 PHP profile UI, Rust warning and error interception review (2026-10-09)
|
||||
|
||||
|
||||
@@ -2255,6 +2255,10 @@
|
||||
"phpProfileDuplicate": "Duplicate PHP-FPM profile ID",
|
||||
"phpProfileShared": "shared profile",
|
||||
"backendHealthEnabled": "Monitor backend health (active and passive)",
|
||||
"backendHealthPerBackend": "Individual backend health checks",
|
||||
"backendHealthSelect": "Backend to configure",
|
||||
"backendHealthSelectionHelp": "Choose a backend with monitoring enabled in Backends. Each backend keeps its own unsaved edits when you switch. Turn on Custom health check to override the shared settings above.",
|
||||
"backendHealthNoEnabled": "No monitored backends. Enable “Monitor backend health” on at least one backend in the Backends tab.",
|
||||
"backendHealthOverride": "Custom health check for this backend",
|
||||
"backendHealthOverridesHelp": "When unchecked, inherit the vhost health policy. Disabling monitoring keeps the backend available for explicitly pinned locations, without active probes or passive ejection. Use a custom check for an individual backend.",
|
||||
"cacheOnlyHits": "Expose cache diagnostics only on HIT (hide MISS and BYPASS)",
|
||||
|
||||
@@ -2255,6 +2255,10 @@
|
||||
"phpProfileDuplicate": "ID profilu PHP-FPM już istnieje",
|
||||
"phpProfileShared": "profil wspólny",
|
||||
"backendHealthEnabled": "Monitoruj stan backendu (aktywnie i pasywnie)",
|
||||
"backendHealthPerBackend": "Indywidualne testy stanu backendów",
|
||||
"backendHealthSelect": "Wybierz backend do konfiguracji",
|
||||
"backendHealthSelectionHelp": "Wybierz backend z włączonym monitorowaniem w zakładce Backendy. Zmiana wyboru nie usuwa niezapisanych ustawień. Włącz osobny health check, aby nadpisać ustawienia wspólne powyżej.",
|
||||
"backendHealthNoEnabled": "Brak monitorowanych backendów. Włącz monitorowanie stanu przynajmniej jednego backendu w zakładce Backendy.",
|
||||
"backendHealthOverride": "Osobny health check dla tego backendu",
|
||||
"backendHealthOverridesHelp": "Bez nadpisania backend dziedziczy ustawienia vhosta. Wylaczenie monitorowania nie blokuje lokalizacji przypisanych do backendu, ale wylacza probe aktywna i pasywne wykluczanie.",
|
||||
"cacheOnlyHits": "Pokazuj naglowki diagnostyki cache tylko dla HIT (ukryj MISS i BYPASS)",
|
||||
|
||||
@@ -137,6 +137,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
"historical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MANAGEMENT.md")),
|
||||
),
|
||||
reader_doc!("docs/MEMORY_STRESS.md", "technical"),
|
||||
(
|
||||
"docs/MIGRATION.md",
|
||||
"guides",
|
||||
|
||||
@@ -84,7 +84,7 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
|
||||
.ok()
|
||||
.is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
|
||||
if directory_safe {
|
||||
let mut entries = std::fs::read_dir(&directory)
|
||||
let entries = newest_log_files(std::fs::read_dir(&directory)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
|
||||
@@ -102,10 +102,7 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
|
||||
}
|
||||
let meta = entry.metadata().ok()?;
|
||||
Some((name, path, file_modified_ms(&meta)))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
|
||||
entries.truncate(256);
|
||||
}), 256);
|
||||
for (name, path, _) in entries {
|
||||
let id = if name.ends_with(".jsonl") {
|
||||
format!("vhost:{}", name.trim_end_matches(".jsonl"))
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
// Bound candidate retention while scanning potentially enormous rotation folders.
|
||||
// The heap's top is the least desirable result: oldest timestamp, then
|
||||
// lexicographically last name. Final output preserves newest/name order.
|
||||
fn newest_log_files(
|
||||
candidates: impl Iterator<Item = (String, std::path::PathBuf, u64)>,
|
||||
limit: usize,
|
||||
) -> Vec<(String, std::path::PathBuf, u64)> {
|
||||
use std::{cmp::Reverse, collections::BinaryHeap};
|
||||
if limit == 0 { return Vec::new(); }
|
||||
let mut bounded = BinaryHeap::with_capacity(limit);
|
||||
for (name, path, modified_ms) in candidates {
|
||||
let item = (Reverse(modified_ms), name, path);
|
||||
if bounded.len() < limit {
|
||||
bounded.push(item);
|
||||
} else if bounded.peek().is_some_and(|worst| item.cmp(worst).is_lt()) {
|
||||
bounded.pop();
|
||||
bounded.push(item);
|
||||
}
|
||||
}
|
||||
let mut result = bounded.into_iter()
|
||||
.map(|(Reverse(modified), name, path)| (name, path, modified))
|
||||
.collect::<Vec<_>>();
|
||||
result.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
|
||||
result
|
||||
}
|
||||
|
||||
// Bounded file-log inventory helpers and gzip tail reader.
|
||||
fn rotated_gzip_sources(
|
||||
active: &std::path::Path,
|
||||
@@ -12,16 +38,14 @@ fn rotated_gzip_sources(
|
||||
let safe = std::fs::symlink_metadata(parent).ok().is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
|
||||
if !safe { return Vec::new(); }
|
||||
let prefix = format!("{base}.");
|
||||
let mut entries = std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| {
|
||||
let entries = newest_log_files(std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") { return None; }
|
||||
let file_type = entry.file_type().ok()?;
|
||||
if !file_type.is_file() || file_type.is_symlink() { return None; }
|
||||
let meta = entry.metadata().ok()?;
|
||||
Some((name, entry.path(), file_modified_ms(&meta)))
|
||||
}).collect::<Vec<_>>();
|
||||
entries.sort_by(|a,b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
|
||||
entries.truncate(64);
|
||||
}), 64);
|
||||
entries.into_iter().map(|(name,path,_)| source_from_path(
|
||||
format!("{id_prefix}:gz:{name}"),
|
||||
format!("{label_prefix} · {name}"),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.29.7",
|
||||
"version": "0.30.0",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -48,6 +48,7 @@ fn admin_vhosts_javascript() -> &'static str {
|
||||
[
|
||||
"(() => {",
|
||||
include_str!("web/vhosts/core.js"),
|
||||
include_str!("web/vhosts/health.js"),
|
||||
include_str!("web/vhosts/editor.js"),
|
||||
include_str!("web/vhosts/render.js"),
|
||||
"})();",
|
||||
|
||||
@@ -6,4 +6,5 @@ mod tests {
|
||||
include!("tests/part_05.rs");
|
||||
include!("tests/part_06.rs");
|
||||
include!("tests/part_07.rs");
|
||||
include!("tests/part_08.rs");
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#[test]
|
||||
fn directory_inventory_retains_newest_with_deterministic_ties() {
|
||||
let candidates = (0..10_000).map(|index| {
|
||||
let name = format!("{index:05}.jsonl.gz");
|
||||
(name.clone(), std::path::PathBuf::from(name), (index / 10) as u64)
|
||||
});
|
||||
let files = newest_log_files(candidates, 5);
|
||||
assert_eq!(files.len(), 5);
|
||||
let names: Vec<_> = files.iter().map(|entry| entry.0.as_str()).collect();
|
||||
assert_eq!(names, ["09990.jsonl.gz", "09991.jsonl.gz", "09992.jsonl.gz", "09993.jsonl.gz", "09994.jsonl.gz"]);
|
||||
assert!(newest_log_files(std::iter::once(("x".into(), "x".into(), 1)), 0).is_empty());
|
||||
}
|
||||
@@ -5788,6 +5788,42 @@ dialog .window-heading {
|
||||
padding: 4px 0 8px
|
||||
}
|
||||
|
||||
.vh-backend-health-editor {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 11px;
|
||||
background: var(--surface, transparent)
|
||||
}
|
||||
|
||||
.vh-backend-health-editor h3 {
|
||||
margin: 0;
|
||||
font-size: 14px
|
||||
}
|
||||
|
||||
.vh-backend-health-editor .fine {
|
||||
margin: 0
|
||||
}
|
||||
|
||||
.vh-backend-health-editor>.vh-field {
|
||||
max-width: 620px
|
||||
}
|
||||
|
||||
.vh-backend-health-editor select {
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.vh-backend-health-panel {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 12px
|
||||
}
|
||||
|
||||
.vh-backend-health-fields {
|
||||
margin-top: 4px
|
||||
}
|
||||
|
||||
.vh-backend-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
// Vhost UI: language content comes from langs/*.json, never a locale switch.
|
||||
// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS.
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0;
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
|
||||
const chartHistory = new Map();
|
||||
const backendTrafficHistory = new Map();
|
||||
const vhostTrafficHistory = new Map();
|
||||
@@ -137,7 +137,7 @@
|
||||
const state = {
|
||||
editing, revision, label: $("vhost-editing").textContent,
|
||||
fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")),
|
||||
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, pagesEditor,
|
||||
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, pagesEditor, backendHealthEditorControl,
|
||||
stagedCertificates, uploading
|
||||
};
|
||||
const title = read("id")?.value.trim() || editing || t("draftUntitled");
|
||||
@@ -147,7 +147,7 @@
|
||||
function restoreDraft(state) {
|
||||
draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs);
|
||||
editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl;
|
||||
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; pagesEditor = state.pagesEditor;
|
||||
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null;
|
||||
stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
|
||||
if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
@@ -194,7 +194,7 @@
|
||||
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
|
||||
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
|
||||
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
|
||||
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); changed(); }); head.append(remove); row.append(head);
|
||||
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); backendHealthEditorControl?.refresh(); changed(); }); head.append(remove); row.append(head);
|
||||
|
||||
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
|
||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
@@ -222,9 +222,12 @@
|
||||
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
|
||||
note(extra, "vhBackendCapacityHelp");
|
||||
const customHealth = field(extra, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
|
||||
// The per-member settings live in the Health tab. Keep one persistent form
|
||||
// per backend, so switching the selection never discards unsaved edits.
|
||||
const healthPanel = el("section", undefined, "vh-grid vh-backend-health-panel");
|
||||
const customHealth = field(healthPanel, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
|
||||
const health = value.health_check || {};
|
||||
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); extra.append(healthFields);
|
||||
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); healthPanel.append(healthFields);
|
||||
field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 });
|
||||
field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 });
|
||||
@@ -236,11 +239,19 @@
|
||||
field(healthFields, "member-health-min", "vhStatusMin", health.expected_status_min ?? 200, "number", { min: 100, max: 599, required: true });
|
||||
field(healthFields, "member-health-max", "vhStatusMax", health.expected_status_max ?? 399, "number", { min: 100, max: 599, required: true });
|
||||
const syncHealth = () => {
|
||||
const editable = controls["member-health-enabled"].checked && customHealth.checked;
|
||||
healthFields.hidden = !customHealth.checked;
|
||||
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !customHealth.checked; });
|
||||
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !editable; });
|
||||
};
|
||||
customHealth.addEventListener("change", syncHealth); syncHealth();
|
||||
note(extra, "backendHealthOverridesHelp");
|
||||
let customInitialized = value.health_check != null;
|
||||
customHealth.addEventListener("change", () => {
|
||||
if (customHealth.checked && !customInitialized) { inheritBackendHealthInputs(healthPanel); customInitialized = true; }
|
||||
syncHealth();
|
||||
});
|
||||
controls["member-health-enabled"].addEventListener("change", () => { syncHealth(); backendHealthEditorControl?.refresh(); });
|
||||
row.memberHealthPanel = healthPanel;
|
||||
row.memberHealthEnabled = controls["member-health-enabled"];
|
||||
syncHealth();
|
||||
row.append(advanced);
|
||||
|
||||
const actions = el("div", undefined, "vh-backend-actions");
|
||||
@@ -258,10 +269,11 @@
|
||||
riskGate.hidden = show !== true;
|
||||
if (clearWhenHidden && show === false) localRisk.checked = false;
|
||||
};
|
||||
id.addEventListener("input", syncIdentity); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); }); url.addEventListener("change", () => syncRisk(true));
|
||||
id.addEventListener("input", () => { syncIdentity(); backendHealthEditorControl?.refresh(); }); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); backendHealthEditorControl?.refresh(); }); url.addEventListener("change", () => syncRisk(true));
|
||||
row.syncLocalRisk = () => syncRisk(false);
|
||||
syncIdentity(); syncRisk(false); parent.append(row);
|
||||
syncIdentity(); syncRisk(false); parent.append(row); backendHealthEditorControl?.refresh();
|
||||
}
|
||||
|
||||
function countryRateEditor(parent, values = []) {
|
||||
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
function load(value = defaults(), migrationIds = [], show = true) {
|
||||
draftEpoch++; stagedCertificates = []; uploading = false;
|
||||
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
|
||||
@@ -205,6 +205,8 @@
|
||||
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
|
||||
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
|
||||
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
|
||||
backendHealthEditorControl = backendHealthEditor(g, members);
|
||||
note(g, "backendHealthOverridesHelp");
|
||||
const passive = settings.passive_health || {};
|
||||
g = group("vhPassiveHealth");
|
||||
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
|
||||
@@ -391,11 +393,11 @@
|
||||
id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row),
|
||||
pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row),
|
||||
health_check_enabled: yes("member-health-enabled", row),
|
||||
health_check: yes("member-health-custom", row) ? {
|
||||
mode: str("member-health-mode", row), path: str("member-health-path", row), host: nullable("member-health-host", row),
|
||||
interval_ms: num("member-health-interval", row), timeout_ms: num("member-health-timeout", row), slow_response_ms: num("member-health-slow", row),
|
||||
fall: num("member-health-fall", row), rise: num("member-health-rise", row),
|
||||
expected_status_min: num("member-health-min", row), expected_status_max: num("member-health-max", row)
|
||||
health_check: yes("member-health-custom", row.memberHealthPanel) ? {
|
||||
mode: str("member-health-mode", row.memberHealthPanel), path: str("member-health-path", row.memberHealthPanel), host: nullable("member-health-host", row.memberHealthPanel),
|
||||
interval_ms: num("member-health-interval", row.memberHealthPanel), timeout_ms: num("member-health-timeout", row.memberHealthPanel), slow_response_ms: num("member-health-slow", row.memberHealthPanel),
|
||||
fall: num("member-health-fall", row.memberHealthPanel), rise: num("member-health-rise", row.memberHealthPanel),
|
||||
expected_status_min: num("member-health-min", row.memberHealthPanel), expected_status_max: num("member-health-max", row.memberHealthPanel)
|
||||
} : null,
|
||||
allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row),
|
||||
preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
// Per-member health editor shares the vhost form state from core.js.
|
||||
// Starting a custom override should copy the CURRENT shared policy once,
|
||||
// rather than surprising the operator with unrelated hardcoded defaults.
|
||||
function inheritBackendHealthInputs(panel) {
|
||||
for (const [local, shared] of [
|
||||
["member-health-mode", "health-mode"], ["member-health-path", "health-path"],
|
||||
["member-health-host", "health-host"], ["member-health-interval", "interval"],
|
||||
["member-health-timeout", "timeout"], ["member-health-slow", "slow"],
|
||||
["member-health-fall", "fall"], ["member-health-rise", "rise"],
|
||||
["member-health-min", "status-min"], ["member-health-max", "status-max"]
|
||||
]) {
|
||||
const source = read(shared), target = read(local, panel);
|
||||
if (source && target) target.value = source.value;
|
||||
}
|
||||
}
|
||||
function backendHealthEditor(parent, members) {
|
||||
const section = el("section", undefined, "vh-wide vh-backend-health-editor");
|
||||
section.append(text("h3", "backendHealthPerBackend"));
|
||||
note(section, "backendHealthSelectionHelp");
|
||||
const selection = field(section, "health-backend-select", "backendHealthSelect", "", "select");
|
||||
const empty = note(section, "backendHealthNoEnabled");
|
||||
const panels = el("div", undefined, "vh-health-member-panels"); section.append(panels); parent.append(section);
|
||||
let active = null, eligible = [];
|
||||
const refresh = () => {
|
||||
eligible = [...members.children].filter(row => row.memberHealthEnabled.checked);
|
||||
if (!eligible.includes(active)) active = eligible[0] || null;
|
||||
selection.replaceChildren(...eligible.map((row, index) => {
|
||||
const id = str("member-id", row) || t("backend");
|
||||
const address = str("member-url", row);
|
||||
const option = el("option", address ? `${id} — ${address}` : id);
|
||||
option.value = String(index); return option;
|
||||
}));
|
||||
selection.value = active ? String(eligible.indexOf(active)) : "";
|
||||
selection.disabled = eligible.length === 0;
|
||||
empty.hidden = eligible.length !== 0;
|
||||
panels.replaceChildren(...[...members.children].map(row => row.memberHealthPanel));
|
||||
for (const row of members.children) row.memberHealthPanel.hidden = row !== active;
|
||||
};
|
||||
selection.addEventListener("change", () => { active = eligible[Number(selection.value)] || null; refresh(); });
|
||||
refresh();
|
||||
return { refresh, reveal(row) { if (eligible.includes(row)) { active = row; refresh(); } } };
|
||||
}
|
||||
@@ -340,7 +340,13 @@
|
||||
form.addEventListener("input", changed); form.addEventListener("change", changed);
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault(); if (!envelope || busy || uploading) return;
|
||||
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); const details = invalid.closest("details"); if (details) details.open = true; invalid.reportValidity(); return; }
|
||||
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) {
|
||||
const healthPanel = invalid.closest(".vh-backend-health-panel");
|
||||
if (healthPanel) backendHealthEditorControl?.reveal([...form.querySelector(".vh-members").children].find(row => row.memberHealthPanel === healthPanel));
|
||||
activateTab(invalid.closest(".vh-group")?.dataset.tab || "details");
|
||||
const details = invalid.closest("details"); if (details) details.open = true;
|
||||
invalid.reportValidity(); return;
|
||||
}
|
||||
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
|
||||
try {
|
||||
const epoch = draftEpoch; uploading = true;
|
||||
@@ -370,5 +376,5 @@
|
||||
initPoolTableTools();
|
||||
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
|
||||
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
|
||||
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
|
||||
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
Vendored
+14
@@ -103,6 +103,20 @@ impl Cache {
|
||||
{
|
||||
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
|
||||
store.generation = store.generation.wrapping_add(1);
|
||||
// This global publication barrier invalidates every older
|
||||
// ticket and pending COLD demotion. Per-key generations only
|
||||
// need to cover the current purge, not every key ever
|
||||
// purged over the lifetime of the process. Without clearing
|
||||
// them, repeated PURGEs of distinct URLs grow this map
|
||||
// indefinitely even after the objects have been evicted.
|
||||
// Release an unusually large index's backing allocation
|
||||
// after mass invalidations instead of retaining its peak
|
||||
// bucket capacity for the rest of the process lifetime.
|
||||
if store.key_generations.capacity() > 256 {
|
||||
store.key_generations = Default::default();
|
||||
} else {
|
||||
store.key_generations.clear();
|
||||
}
|
||||
}
|
||||
let affected = self.purge_matcher(&matcher);
|
||||
self.counters.purges.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
+74
@@ -395,3 +395,77 @@
|
||||
confirmed_prefix.confirm_wide = true;
|
||||
assert!(cache.invalidate(confirmed_prefix).unwrap().ban_id.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn many_distinct_selective_purges_do_not_retain_tombstones_forever() {
|
||||
let cache = Cache::new(22);
|
||||
store(
|
||||
&cache,
|
||||
"app",
|
||||
"memory.example",
|
||||
"/stable",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"unchanged",
|
||||
)
|
||||
.await;
|
||||
// A production cache may see an unbounded stream of PURGEs for URLs
|
||||
// that will never be cached again. The invalidated-key index must
|
||||
// not remember all of those removed objects indefinitely.
|
||||
for index in 0..512 {
|
||||
let target = format!("/unique-{index}");
|
||||
let old = store(
|
||||
&cache,
|
||||
"app",
|
||||
"memory.example",
|
||||
&target,
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"x",
|
||||
)
|
||||
.await;
|
||||
assert!(cache.ticket_current(&old));
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Exact,
|
||||
&target,
|
||||
Some("memory.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 1);
|
||||
assert!(!cache.ticket_current(&old), "pre-purge ticket must remain stale");
|
||||
let state = cache.store.lock().unwrap_or_else(|e| e.into_inner());
|
||||
assert!(
|
||||
state.key_generations.len() <= 1,
|
||||
"key generations must not accumulate for already purged URLs"
|
||||
);
|
||||
}
|
||||
// A new ticket after the invalidation barrier is valid and can fill
|
||||
// the same URL again, preserving ordinary cache behavior.
|
||||
let new = store(
|
||||
&cache,
|
||||
"app",
|
||||
"memory.example",
|
||||
"/unique-0",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"new",
|
||||
)
|
||||
.await;
|
||||
assert!(cache.ticket_current(&new));
|
||||
assert!(cache.get(&new).is_some());
|
||||
let stable = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
"app",
|
||||
"http",
|
||||
"memory.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/stable",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cache.get(&stable).is_some(), "unrelated entries must survive");
|
||||
}
|
||||
+20
-8
@@ -122,12 +122,17 @@ async fn sink_loop(
|
||||
let config = settings.load_full();
|
||||
let mut payload = format_entry(&entry, &config.format);
|
||||
redact_value(&mut payload);
|
||||
// File and Logstash consume the same JSONL record. Serialize once and
|
||||
// keep no JSONL buffer at all for Loki-only configurations.
|
||||
let mut jsonl_line = Vec::new();
|
||||
if config.file.enabled || config.logstash.enabled {
|
||||
jsonl_line = serde_json::to_vec(&payload).unwrap_or_default();
|
||||
jsonl_line.push(b'\n');
|
||||
}
|
||||
let mut attempted = false;
|
||||
let mut failed = false;
|
||||
if config.file.enabled {
|
||||
attempted = true;
|
||||
let mut line = serde_json::to_vec(&payload).unwrap_or_default();
|
||||
line.push(b'\n');
|
||||
if config.file.per_vhost {
|
||||
file = None;
|
||||
let directory = PathBuf::from(&config.file.directory);
|
||||
@@ -157,7 +162,7 @@ async fn sink_loop(
|
||||
}
|
||||
}
|
||||
let write_failed = match vhost_files.get_mut(&path) {
|
||||
Some(opened) => opened.write_all(&line).await.is_err(),
|
||||
Some(opened) => opened.write_all(&jsonl_line).await.is_err(),
|
||||
None => true,
|
||||
};
|
||||
if write_failed {
|
||||
@@ -182,7 +187,7 @@ async fn sink_loop(
|
||||
};
|
||||
}
|
||||
if let Some(state) = file.as_mut() {
|
||||
if state.file.write_all(&line).await.is_err() {
|
||||
if state.file.write_all(&jsonl_line).await.is_err() {
|
||||
failed = true;
|
||||
file = None;
|
||||
}
|
||||
@@ -211,11 +216,9 @@ async fn sink_loop(
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let mut line = serde_json::to_vec(&payload).unwrap_or_default();
|
||||
line.push(b'\n');
|
||||
if let Some(state) = logstash.as_mut() {
|
||||
if !matches!(
|
||||
tokio::time::timeout(SINK_TIMEOUT, state.stream.write_all(&line)).await,
|
||||
tokio::time::timeout(SINK_TIMEOUT, state.stream.write_all(&jsonl_line)).await,
|
||||
Ok(Ok(()))
|
||||
) {
|
||||
failed = true;
|
||||
@@ -234,7 +237,16 @@ async fn sink_loop(
|
||||
} else {
|
||||
config.loki.labels.clone()
|
||||
};
|
||||
let body = json!({"streams":[{"stream":labels,"values":[[(entry.time_ms as u128 * 1_000_000).to_string(), payload.to_string()]]}]});
|
||||
// Loki needs the same redacted JSON event as the file/Logstash
|
||||
// sinks, only without the JSONL terminator. Reuse the already
|
||||
// serialized record when another sink is active; avoid a second
|
||||
// full serde_json pass for every high-volume access event.
|
||||
let event_json = if jsonl_line.is_empty() {
|
||||
payload.to_string()
|
||||
} else {
|
||||
String::from_utf8_lossy(&jsonl_line[..jsonl_line.len() - 1]).into_owned()
|
||||
};
|
||||
let body = json!({"streams":[{"stream":labels,"values":[[(entry.time_ms as u128 * 1_000_000).to_string(), event_json]]}]});
|
||||
let result = async {
|
||||
let uri: Uri = config.loki.url.parse()?;
|
||||
let mut request = Request::post(uri)
|
||||
|
||||
+9
-6
@@ -51,13 +51,16 @@ impl TopTable {
|
||||
if raw.is_empty() || weight == 0 {
|
||||
return;
|
||||
}
|
||||
// Probe with the borrowed label. Under production traffic most methods,
|
||||
// statuses, routes and user agents repeat; allocating a fresh String
|
||||
// for every hit creates avoidable allocator churn.
|
||||
let value = truncate(raw, MAX_LABEL_BYTES);
|
||||
if let Some(count) = self.values.get_mut(&value) {
|
||||
if let Some(count) = self.values.get_mut(value) {
|
||||
*count = count.saturating_add(weight);
|
||||
return;
|
||||
}
|
||||
if self.values.len() < TOP_CAPACITY {
|
||||
self.values.insert(value, weight);
|
||||
self.values.insert(value.to_owned(), weight);
|
||||
return;
|
||||
}
|
||||
let Some((smallest, minimum)) = self
|
||||
@@ -69,7 +72,7 @@ impl TopTable {
|
||||
return;
|
||||
};
|
||||
self.values.remove(&smallest);
|
||||
self.values.insert(value, minimum.saturating_add(weight));
|
||||
self.values.insert(value.to_owned(), minimum.saturating_add(weight));
|
||||
}
|
||||
fn merge(&mut self, other: &Self) {
|
||||
for (value, count) in &other.values {
|
||||
@@ -337,15 +340,15 @@ fn user_agent_family(value: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(value: &str, max_bytes: usize) -> String {
|
||||
fn truncate(value: &str, max_bytes: usize) -> &str {
|
||||
if value.len() <= max_bytes {
|
||||
return value.to_owned();
|
||||
return value;
|
||||
}
|
||||
let mut end = max_bytes;
|
||||
while !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
value[..end].to_owned()
|
||||
&value[..end]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+19
-17
@@ -321,7 +321,7 @@ async fn serve_inner(
|
||||
}
|
||||
let service_for_list = service.clone();
|
||||
let components_for_list = components.clone();
|
||||
let entries = match tokio::task::spawn_blocking(move || {
|
||||
let mut entries = match tokio::task::spawn_blocking(move || {
|
||||
list_directory_sync(&service_for_list, &components_for_list)
|
||||
})
|
||||
.await
|
||||
@@ -335,25 +335,27 @@ async fn serve_inner(
|
||||
)
|
||||
}
|
||||
};
|
||||
let entries: Vec<_> = entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
if htaccess::protected_component(service.htaccess.as_ref(), &entry.name) {
|
||||
return false;
|
||||
}
|
||||
let child_path = if effective_path.ends_with('/') {
|
||||
format!("{effective_path}{}", entry.name)
|
||||
} else {
|
||||
format!("{effective_path}/{}", entry.name)
|
||||
};
|
||||
!hardening_denied_path(service, &child_path)
|
||||
})
|
||||
.collect();
|
||||
// Filtering in place avoids a second Vec allocation for large directories
|
||||
// while retaining exactly the same visibility and hardening checks.
|
||||
entries.retain(|entry| {
|
||||
if htaccess::protected_component(service.htaccess.as_ref(), &entry.name) {
|
||||
return false;
|
||||
}
|
||||
let child_path = if effective_path.ends_with('/') {
|
||||
format!("{effective_path}{}", entry.name)
|
||||
} else {
|
||||
format!("{effective_path}/{}", entry.name)
|
||||
};
|
||||
!hardening_denied_path(service, &child_path)
|
||||
});
|
||||
let listing = directory_listing(&effective_path, &entries);
|
||||
let listing_bytes = listing.len();
|
||||
let mut response = Response::new(if method == Method::HEAD {
|
||||
Body::empty()
|
||||
} else {
|
||||
Body::from(listing.clone())
|
||||
// Move the generated listing into the response. Copying it doubles
|
||||
// peak HTML allocation for large directory listings under load.
|
||||
Body::from(listing)
|
||||
});
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
@@ -361,7 +363,7 @@ async fn serve_inner(
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&listing.len().to_string()).unwrap(),
|
||||
HeaderValue::from_str(&listing_bytes.to_string()).unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
|
||||
@@ -64,13 +64,13 @@ const value = {
|
||||
vm.runInNewContext(`${core.slice(start, end)}\nmemberRow(parent, value);`, {
|
||||
parent, value, el, text, field, button, note,
|
||||
t: key => key, toast: () => {}, changed: () => {},
|
||||
appConfirm: async () => true, adminListenerBackendCandidate: () => false
|
||||
appConfirm: async () => true, adminListenerBackendCandidate: () => false, backendHealthEditorControl: null
|
||||
});
|
||||
const row = parent.children[0];
|
||||
const input = name => row.querySelectorAll().find(item => item.name === name);
|
||||
const input = name => (name.startsWith('member-health-') && name !== 'member-health-enabled' ? row.memberHealthPanel : row).querySelectorAll().find(item => item.name === name);
|
||||
const fields = [];
|
||||
const visit = node => { if (node.classes.has('vh-backend-health-fields')) fields.push(node); for (const child of node.children) visit(child); };
|
||||
visit(row);
|
||||
visit(row.memberHealthPanel);
|
||||
check(fields.length === 1, 'Custom backend health panel not created');
|
||||
check(input('member-pool').checked === false, 'Internal 404 backend accidentally joined balanced pool');
|
||||
check(input('member-enabled').checked === true, 'Backend was disabled when monitoring was disabled');
|
||||
@@ -79,19 +79,21 @@ check(input('member-health-custom').checked === true, 'Custom health not restore
|
||||
check(input('member-health-mode').value === 'http', 'Member health mode lost');
|
||||
check(input('member-health-path').value === '/ready', 'Member health path lost');
|
||||
check(input('member-health-interval').value === '10000', 'Member check interval lost');
|
||||
check(fields[0].hidden === false, 'Custom health fields hidden despite override');
|
||||
check(fields[0].hidden === false && input('member-health-path').disabled, 'Unmonitored backend settings should be retained but disabled');
|
||||
input('member-health-custom').checked = false;
|
||||
input('member-health-custom').dispatch('change');
|
||||
check(fields[0].hidden && input('member-health-path').disabled, 'Inherit switch did not hide/disable overrides');
|
||||
input('member-health-custom').checked = true;
|
||||
input('member-health-custom').dispatch('change');
|
||||
check(!fields[0].hidden && !input('member-health-path').disabled, 'Custom fields failed to re-enable');
|
||||
check(!fields[0].hidden && input('member-health-path').disabled, 'Unmonitored backend unexpectedly became editable');
|
||||
input('member-health-enabled').checked = true;
|
||||
input('member-health-enabled').dispatch('change');
|
||||
check(!input('member-health-path').disabled, 'Monitored custom fields failed to re-enable');
|
||||
check(input('member-pool').checked === false, 'Monitoring switch changed balanced-pool eligibility');
|
||||
|
||||
for (const part of [
|
||||
'health_check_enabled: yes("member-health-enabled", row)',
|
||||
'health_check: yes("member-health-custom", row)',
|
||||
'health_check: yes("member-health-custom", row.memberHealthPanel)',
|
||||
'expose_only_hits: tri("vh-cache-only-hits")',
|
||||
]) check((editor + core).includes(part), `Missing saved field: ${part}`);
|
||||
check(editor.includes('tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits)'), 'Vhost cache override does not roundtrip');
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
// Simulated vhost DOM: selector eligibility, independent edits and lifecycle.
|
||||
// This is not a browser rendering test or a Rust health-probe test.
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const core = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8');
|
||||
const health = fs.readFileSync('src/admin/web/vhosts/health.js', 'utf8');
|
||||
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
|
||||
const render = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8');
|
||||
const first = core.indexOf(' function memberRow(');
|
||||
const last = core.indexOf(' function countryRateEditor(', first);
|
||||
const assert = (condition, message) => { if (!condition) throw new Error(message); };
|
||||
assert(first >= 0 && last > first, 'Vhost member editor missing');
|
||||
class Element {
|
||||
constructor(tag = 'div', value = '', css = '') {
|
||||
this.tag = tag; this.value = String(value ?? ''); this.textContent = this.value;
|
||||
this.children = []; this.parent = null; this.name = ''; this.checked = false;
|
||||
this.disabled = false; this.hidden = false; this.listeners = new Map();
|
||||
this.classes = new Set(css.split(/\s+/).filter(Boolean));
|
||||
}
|
||||
append(...children) {
|
||||
for (const child of children) {
|
||||
if (child.parent) child.remove();
|
||||
this.children.push(child); child.parent = this;
|
||||
}
|
||||
}
|
||||
replaceChildren(...children) { for (const child of [...this.children]) child.remove(); this.append(...children); }
|
||||
remove() {
|
||||
if (!this.parent) return;
|
||||
this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null;
|
||||
}
|
||||
addEventListener(type, callback) { this.listeners.set(type, callback); }
|
||||
dispatch(type) { this.listeners.get(type)?.(); }
|
||||
querySelectorAll(selector) {
|
||||
const result = [];
|
||||
const walk = parent => { for (const child of parent.children) {
|
||||
if ((selector === 'input,select' && ['input', 'select'].includes(child.tag)) ||
|
||||
(selector === '*' || selector === undefined)) result.push(child);
|
||||
walk(child);
|
||||
}};
|
||||
walk(this); return result;
|
||||
}
|
||||
querySelector(selector) {
|
||||
const match = /^\[name="([^"]+)"\]$/.exec(selector);
|
||||
return match ? this.querySelectorAll('*').find(node => node.name === match[1]) : null;
|
||||
}
|
||||
}
|
||||
const el = (tag, value, css) => new Element(tag, value, css);
|
||||
const text = (tag, key, css) => el(tag, key, css);
|
||||
const field = (parent, name, key, value, type = 'text') => {
|
||||
const wrapper = el('label');
|
||||
const input = el(type === 'select' ? 'select' : 'input', value); input.name = name;
|
||||
input.checked = type === 'checkbox' && !!value;
|
||||
wrapper.append(input); parent.append(wrapper); return input;
|
||||
};
|
||||
const button = (key, callback) => { const node = el('button', key); node.addEventListener('click', callback); return node; };
|
||||
const note = (parent, key) => { const node = text('p', key); parent.append(node); return node; };
|
||||
const context = {
|
||||
el, text, field, button, note, t: key => key,
|
||||
str: (name, scope) => scope.querySelector(`[name="${name}"]`).value.trim(),
|
||||
toast: () => { throw Error('Unexpected error toast'); }, changed: () => {},
|
||||
appConfirm: async () => true, adminListenerBackendCandidate: () => false,
|
||||
backendHealthEditorControl: null,
|
||||
read: (name, scope) => scope ? scope.querySelector(`[name="${name}"]`) :
|
||||
({ "health-mode": "http", "health-path": "/shared", "health-host": "shared.example",
|
||||
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
|
||||
"status-min": "201", "status-max": "398" }[name] != null ?
|
||||
{ value: ({ "health-mode": "http", "health-path": "/shared", "health-host": "shared.example",
|
||||
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
|
||||
"status-min": "201", "status-max": "398" })[name] } : null),
|
||||
};
|
||||
const exports = vm.runInNewContext(`${core.slice(first, last)}\n${health}\n({ memberRow, backendHealthEditor })`, context);
|
||||
const members = el('div');
|
||||
const probe = (mode, path) => ({ mode, path, host: null, interval_ms: 5000, timeout_ms: 1000, slow_response_ms: 500, fall: 2, rise: 1, expected_status_min: 200, expected_status_max: 399 });
|
||||
const a = { id: 'alpha', url: 'http://192.0.2.1:9000', health_check_enabled: true, health_check: probe('http', '/a') };
|
||||
const b = { id: 'disabled', url: 'http://192.0.2.2:9000', health_check_enabled: false, health_check: probe('http', '/b') };
|
||||
const c = { id: 'charlie', url: 'http://192.0.2.3:9000', health_check_enabled: true, health_check: null };
|
||||
for (const backend of [a, b, c]) exports.memberRow(members, { weight: 1, ...backend });
|
||||
const [rowA, rowB, rowC] = members.children;
|
||||
const wrapper = el('div'); context.backendHealthEditorControl = exports.backendHealthEditor(wrapper, members);
|
||||
const select = wrapper.querySelector('[name="health-backend-select"]');
|
||||
const control = (row, name) => row.memberHealthPanel.querySelector(`[name="${name}"]`);
|
||||
assert(select.children.length === 2 && select.children[0].textContent.includes('alpha') && select.children[1].textContent.includes('charlie'), 'Unmonitored backend listed in selector');
|
||||
assert(!rowA.memberHealthPanel.hidden && rowB.memberHealthPanel.hidden && rowC.memberHealthPanel.hidden, 'Selected panel visibility incorrect');
|
||||
assert(control(rowB, 'member-health-path').disabled, 'Unmonitored override must be non-editable');
|
||||
assert(!control(rowA, 'member-health-path').disabled, 'Monitored override unexpectedly disabled');
|
||||
assert(control(rowA, 'member-health-path').value === '/a', 'Existing per-member override not loaded');
|
||||
|
||||
select.value = '1'; select.dispatch('change');
|
||||
assert(rowA.memberHealthPanel.hidden && !rowC.memberHealthPanel.hidden, 'Switch did not change selected panel');
|
||||
const customC = control(rowC, 'member-health-custom');
|
||||
customC.checked = true; customC.dispatch('change');
|
||||
assert(control(rowC, 'member-health-mode').value === 'http' &&
|
||||
control(rowC, 'member-health-interval').value === '8500' &&
|
||||
control(rowC, 'member-health-path').value === '/shared', 'First override did not inherit shared settings');
|
||||
const pathC = control(rowC, 'member-health-path'); pathC.value = '/charlie-ready';
|
||||
customC.checked = false; customC.dispatch('change');
|
||||
customC.checked = true; customC.dispatch('change');
|
||||
assert(pathC.value === '/charlie-ready', 'Toggling custom override discarded edits');
|
||||
assert(!pathC.disabled, 'New custom override should become editable');
|
||||
select.value = '0'; select.dispatch('change');
|
||||
assert(control(rowA, 'member-health-path').value === '/a' && pathC.value === '/charlie-ready', 'Switch discarded unsaved member edits');
|
||||
select.value = '1'; select.dispatch('change');
|
||||
assert(pathC.value === '/charlie-ready' && !rowC.memberHealthPanel.hidden, 'Returning to backend lost its edits');
|
||||
|
||||
rowC.querySelector('[name="member-id"]').value = 'renamed';
|
||||
rowC.querySelector('[name="member-id"]').dispatch('input');
|
||||
assert(select.children[1].textContent.includes('renamed'), 'Backend rename not reflected in selector');
|
||||
const monitorA = rowA.querySelector('[name="member-health-enabled"]');
|
||||
monitorA.checked = false; monitorA.dispatch('change');
|
||||
assert(select.children.length === 1 && select.children[0].textContent.includes('renamed'), 'Disabled monitor remains in selector');
|
||||
assert(control(rowA, 'member-health-path').value === '/a' && control(rowA, 'member-health-path').disabled, 'Disabling monitor lost override or left active controls');
|
||||
monitorA.checked = true; monitorA.dispatch('change');
|
||||
assert(select.children.length === 2 && pathC.value === '/charlie-ready', 'Re-enabling monitor did not restore choice/edits');
|
||||
const removeC = rowC.children[0].children[1]; // backend-card header's Remove button
|
||||
removeC.dispatch('click');
|
||||
assert(members.children.length === 2 && select.children.length === 1 && !rowA.memberHealthPanel.hidden, 'Deleting selected backend did not refresh selector');
|
||||
assert(wrapper.querySelectorAll('*').includes(rowA.memberHealthPanel) && !wrapper.querySelectorAll('*').includes(rowC.memberHealthPanel), 'Removed backend panel remained retained in DOM');
|
||||
monitorA.checked = false; monitorA.dispatch('change');
|
||||
assert(select.disabled && select.children.length === 0 && rowB.memberHealthPanel.hidden, 'Empty monitored backend list not handled');
|
||||
|
||||
assert(editor.includes('health_check: yes("member-health-custom", row.memberHealthPanel)'), 'Per-member health settings not collected from selected panels');
|
||||
assert(editor.includes('backendHealthEditorControl = backendHealthEditor(g, members)'), 'Selector not wired to Health tab');
|
||||
assert(render.includes('backendHealthEditorControl?.reveal('), 'Invalid hidden member settings cannot be revealed');
|
||||
assert(core.includes('backendHealthEditorControl = state.backendHealthEditorControl || null;') && core.includes('pagesEditor, backendHealthEditorControl,'), 'Draft switching lost selected backend editor');
|
||||
assert(fs.readFileSync('src/admin/router.rs','utf8').includes('include_str!("web/vhosts/health.js")'), 'Bundled health editor missing');
|
||||
console.log('PASS: 0.30.0 monitored-only backend selector, independent unsaved edits, monitor toggle, rename/removal, empty list and validation routing.');
|
||||
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
|
||||
|
||||
preflight(root)
|
||||
manifest = tomllib.loads((root / "Cargo.toml").read_text())
|
||||
assert manifest["package"]["version"] == "0.29.7"
|
||||
assert manifest["package"]["version"] == "0.30.0"
|
||||
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
|
||||
h3dep = manifest["dependencies"]["h3"]
|
||||
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"]
|
||||
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
|
||||
lock_path = root / "Cargo.lock"
|
||||
if lock_path.exists():
|
||||
lock = lock_path.read_text()
|
||||
assert 'name = "rust-proxy"\nversion = "0.29.7"' in lock
|
||||
assert 'name = "rust-proxy"\nversion = "0.30.0"' in lock
|
||||
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
|
||||
for release_doc, marker in {
|
||||
"README.md": "# RustProxy 0.29.7",
|
||||
"CHANGELOG.md": "## 0.29.7",
|
||||
"ROADMAP.md": "## 0.29.7",
|
||||
"AGENTS.md": "Current source version: 0.29.7.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.29.7",
|
||||
"README.md": "# RustProxy 0.30.0",
|
||||
"CHANGELOG.md": "## 0.30.0",
|
||||
"ROADMAP.md": "## 0.30.0",
|
||||
"AGENTS.md": "Current source version: 0.30.0.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.30.0",
|
||||
}.items():
|
||||
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
|
||||
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
|
||||
@@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
|
||||
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
|
||||
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
|
||||
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
|
||||
for marker in ['"version": "0.29.7"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
for marker in ['"version": "0.30.0"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
assert marker in openapi, marker
|
||||
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
@@ -1396,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
|
||||
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
|
||||
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
|
||||
assert 'location.pathname === "/logging"' in settings_js_0241
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.29.7"' in openapi
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.30.0"' in openapi
|
||||
|
||||
# 0.24.1 compiler and hostname-cardinality regressions.
|
||||
tls_0241 = rust_source(root, "src/tls.rs")
|
||||
@@ -1635,5 +1635,5 @@ for marker_0282 in [
|
||||
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text()
|
||||
assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/V0.28.2_IMPLEMENTATION.md').read_text()
|
||||
|
||||
print("PASS: 0.29.7 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
|
||||
print("PASS: 0.30.0 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
|
||||
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.29.7 release."""
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.30.0 release."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
from rust_source import rust_source
|
||||
@@ -64,11 +64,11 @@ assert '"/docs/content.json"' in admin
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert readme.startswith("# RustProxy 0.29.7")
|
||||
assert readme.startswith("# RustProxy 0.30.0")
|
||||
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
|
||||
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
|
||||
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
|
||||
assert roadmap.startswith("# RustProxy roadmap") and "## 0.29.7" and "## 0.29.1" in roadmap and "## 0.29.0" in roadmap and "## 0.28.2" in roadmap and "## 0.28.1" in roadmap and "## 0.27.3" in roadmap and "## 0.27.2" in roadmap and "## 0.27.1" in roadmap and "## 0.27.0" in roadmap and "## 0.26.7" in roadmap and "## 0.26.6" in roadmap and "## 0.26.5" in roadmap and "## 0.26.4" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap
|
||||
assert roadmap.startswith("# RustProxy roadmap") and "## 0.30.0" and "## 0.29.1" in roadmap and "## 0.29.0" in roadmap and "## 0.28.2" in roadmap and "## 0.28.1" in roadmap and "## 0.27.3" in roadmap and "## 0.27.2" in roadmap and "## 0.27.1" in roadmap and "## 0.27.0" in roadmap and "## 0.26.7" in roadmap and "## 0.26.6" in roadmap and "## 0.26.5" in roadmap and "## 0.26.4" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap
|
||||
assert "## 0.24." not in roadmap and "## Web Server follow-up" in roadmap and "docs/ROADMAP_HISTORY.md" in roadmap
|
||||
assert changelog.startswith("# Changelog\n")
|
||||
for release in ["0.29.1", "0.29.0", "0.28.2", "0.28.1", "0.28.0", "0.27.3", "0.27.2", "0.27.1", "0.27.0", "0.26.7", "0.26.6", "0.26.5", "0.26.4", "0.26.1", "0.26.0", "0.25.6", "0.25.5", "0.25.4", "0.25.3"]:
|
||||
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline high-cardinality invalidation replay + HTTP load-driver smoke test.
|
||||
|
||||
The replay models bookkeeping cardinality, not a running RustProxy heap. The HTTP
|
||||
requests target a Python test peer solely to validate the staging load harness.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
|
||||
from memory_load import run_load # noqa: E402
|
||||
|
||||
|
||||
def purge_replay(operations: int = 300_000, windows: int = 6) -> list[tuple[int, int]]:
|
||||
old_generations = {}
|
||||
new_generations = {}
|
||||
counts = []
|
||||
assert operations % windows == 0
|
||||
for i in range(operations):
|
||||
key = i.to_bytes(32, "big")
|
||||
# Legacy: each already-removed key keeps a generation slot forever.
|
||||
old_generations[key] = 1
|
||||
# Same-version fix: the global generation barrier cancels all older
|
||||
# tickets, so only the current purge's per-key metadata is needed.
|
||||
new_generations.clear()
|
||||
new_generations[key] = 1
|
||||
if (i + 1) % (operations // windows) == 0:
|
||||
counts.append((len(old_generations), len(new_generations)))
|
||||
assert counts == [(operations // windows * i, 1) for i in range(1, windows + 1)]
|
||||
return counts
|
||||
|
||||
|
||||
class TestPeer(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
def do_GET(self) -> None:
|
||||
body = b"HTTP-load-driver-ok"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--http-requests", type=int, default=2_000, help="HTTP requests against the local Python test peer (default: 2000)")
|
||||
args = parser.parse_args()
|
||||
if not 1 <= args.http_requests <= 1_000_000:
|
||||
parser.error("--http-requests must be 1..1000000")
|
||||
checkpoints = purge_replay()
|
||||
print("PASS: 300,000 simulated distinct PURGEs; cumulative vs reset per-key generation slots:", checkpoints)
|
||||
peer = ThreadingHTTPServer(("127.0.0.1", 0), TestPeer)
|
||||
runner = threading.Thread(target=peer.serve_forever, daemon=True)
|
||||
runner.start()
|
||||
try:
|
||||
url = f"http://127.0.0.1:{peer.server_port}/"
|
||||
report = run_load(url=url, requests=args.http_requests, concurrency=24, unique_paths=min(1000, args.http_requests),
|
||||
timeout=5, pid=os.getpid(), sample_interval=0.05, cooldown=0.15)
|
||||
assert report["requests_sent"] == args.http_requests, report
|
||||
assert report["statuses"] == {"200": args.http_requests}, report
|
||||
assert report["response_bytes_read"] == args.http_requests * len(b"HTTP-load-driver-ok"), report
|
||||
assert report["rss_peak_mib"] is not None, report
|
||||
print(f"PASS: load tool generated {args.http_requests:,} real HTTP requests with 24 connections and {min(1000, args.http_requests):,} paths against a Python test peer")
|
||||
print("NOTE: peer/load-tool RSS is NOT RustProxy RSS; live RustProxy profiling remains mandatory")
|
||||
finally:
|
||||
peer.shutdown()
|
||||
peer.server_close()
|
||||
runner.join(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -144,6 +144,39 @@ require(
|
||||
"Compression::new(service)",
|
||||
)
|
||||
|
||||
# One JSONL buffer is reused across local file and Logstash sends (Loki-only
|
||||
# sinks should not allocate the JSONL line). Both output queues remain bounded.
|
||||
logging_sink = read("src/logging/runtime.rs")
|
||||
assert logging_sink.count("serde_json::to_vec(&payload)") == 1
|
||||
assert "if config.file.enabled || config.logstash.enabled" in logging_sink
|
||||
assert "opened.write_all(&jsonl_line)" in logging_sink
|
||||
assert "state.stream.write_all(&jsonl_line)" in logging_sink
|
||||
assert "PER_VHOST_OPEN_FILES: usize = 64" in logging_sink
|
||||
|
||||
# Stored Logs file discovery must also be cardinality bounded before sort:
|
||||
# rotated files and per-vhost directories may contain arbitrary historical files.
|
||||
require("src/admin/file_logs.rs", "newest_log_files(std::fs::read_dir(&directory)", ", 256);")
|
||||
require("src/admin/file_logs_io.rs", "BinaryHeap::with_capacity(limit)", "newest_log_files(std::fs::read_dir(parent)", "), 64);")
|
||||
|
||||
# A per-key invalidation generation is useful only inside the current PURGE
|
||||
# publication epoch; retaining each purged URL after eviction is an unbounded
|
||||
# lifetime-memory leak on high-cardinality cache invalidation workloads.
|
||||
invalidation = read("src/cache/invalidation/invalidate.rs")
|
||||
start = invalidation.index("InvalidationOperation::Purge =>")
|
||||
barrier = invalidation.index("store.generation = store.generation.wrapping_add(1);", start)
|
||||
reset = invalidation.index("store.key_generations.clear();", barrier)
|
||||
assert "store.key_generations.capacity() > 256" in invalidation[barrier:reset]
|
||||
matcher = invalidation.index("let affected = self.purge_matcher(&matcher);", reset)
|
||||
assert start < barrier < reset < matcher
|
||||
require("src/cache/tests/v022_cache_tests/part_02.rs", "many_distinct_selective_purges_do_not_retain_tombstones_forever")
|
||||
|
||||
# Repeated hot-path analytics labels should use borrowed lookups instead of
|
||||
# allocating a String for every request in an already populated top table.
|
||||
require("src/traffic.rs", "fn truncate(value: &str, max_bytes: usize) -> &str", "self.values.get_mut(value)", "self.values.insert(value.to_owned(), weight)")
|
||||
forbid("src/web_server/serve.rs", "Body::from(listing.clone())")
|
||||
require("src/web_server/serve.rs", "entries.retain(|entry|", "let listing_bytes = listing.len();", "Body::from(listing)")
|
||||
require("src/logging/runtime.rs", "String::from_utf8_lossy(&jsonl_line[..jsonl_line.len() - 1])")
|
||||
|
||||
# Other long-lived/background facilities: queue/task/worker cardinality is bounded
|
||||
# or owned by a cancellation-aware supervisor/TaskTracker.
|
||||
require(
|
||||
|
||||
@@ -73,12 +73,12 @@ 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/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/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/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "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/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/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/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "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.29.7 source tree: missing {path}")
|
||||
raise ValueError(f"Incomplete RustProxy 0.30.0 source tree: missing {path}")
|
||||
text = (root / "src/lib.rs").read_text(encoding="utf-8")
|
||||
if '#[path = "auth/mod.rs"]' not in text:
|
||||
raise ValueError("src/lib.rs is not the 0.29.7 entry point; extract the complete package, not individual files.")
|
||||
raise ValueError("src/lib.rs is not the 0.30.0 entry point; extract the complete package, not individual files.")
|
||||
return messages
|
||||
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert backup.is_dir()
|
||||
assert (backup / "src/obsolete.rs").is_file()
|
||||
assert not (target / "src/obsolete.rs").exists()
|
||||
assert 'version = "0.29.7"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "0.30.0"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
|
||||
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
|
||||
assert (target / "langs/site.json").is_file()
|
||||
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert (target / "docker-agent/certificate.go").is_file()
|
||||
assert (target / "docker-agent/server.go").is_file()
|
||||
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
|
||||
assert manifest["to_version"] == "0.29.7"
|
||||
assert manifest["to_version"] == "0.30.0"
|
||||
assert manifest["removed_obsolete_runtime_state"] is True
|
||||
assert not legacy.exists()
|
||||
assert not (backup / "legacy-data").exists()
|
||||
@@ -619,7 +619,7 @@ def source_contract_smoke() -> None:
|
||||
assert marker in admin, marker
|
||||
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
|
||||
assert '"/api/status"' not in anonymous_block
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.29.7"' in openapi_0241
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.30.0"' in openapi_0241
|
||||
assert 'id="panel-logging"' not in html
|
||||
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
|
||||
assert marker in html, marker
|
||||
@@ -1109,7 +1109,7 @@ def source_contract_smoke() -> None:
|
||||
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
|
||||
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
|
||||
assert '"/api/config/history/restore"' in openapi
|
||||
for marker in ['"version": "0.29.7"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
for marker in ['"version": "0.30.0"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
assert '"software_update"' in openapi and '"update_available"' in openapi
|
||||
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
|
||||
@@ -1237,7 +1237,7 @@ def reproducible_archive_smoke(tmp: Path) -> None:
|
||||
assert f"RustProxy/src/web_server/{module}" in names
|
||||
assert "RustProxy/src/web_server.rs" not in names
|
||||
assert "RustProxy/src/admin/web/web-server.js" in names
|
||||
for module in ["app/core.js", "app/preferences.js", "app/config-history.js", "app/runtime.js", "app/settings.js", "drafts.js", "vhosts/core.js", "vhosts/editor.js", "vhosts/render.js"]:
|
||||
for module in ["app/core.js", "app/preferences.js", "app/config-history.js", "app/runtime.js", "app/settings.js", "drafts.js", "vhosts/core.js", "vhosts/health.js", "vhosts/editor.js", "vhosts/render.js"]:
|
||||
assert f"RustProxy/src/admin/web/{module}" in names
|
||||
assert "RustProxy/src/admin/web/app.js" not in names and "RustProxy/src/admin/web/vhosts.js" not in names
|
||||
for module in ["model.go", "docker.go", "network.go", "certificate.go", "server.go", "main.go", "main_test.go"]:
|
||||
@@ -1314,7 +1314,7 @@ def main() -> None:
|
||||
source_contract_smoke()
|
||||
container_contract_smoke()
|
||||
grafana_contract_smoke()
|
||||
print("PASS: 0.29.7 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
print("PASS: 0.30.0 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -36,7 +36,7 @@ for old in (ROOT / "src/admin/web/app.js", ROOT / "src/admin/web/vhosts.js"):
|
||||
assert not old.exists(), f"oversized browser monolith returned: {old.relative_to(ROOT)}"
|
||||
for required in (
|
||||
"src/admin/web/app/core.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/vhosts/core.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js",
|
||||
"src/admin/web/vhosts/core.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js",
|
||||
"docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go",
|
||||
"docker-agent/certificate.go", "docker-agent/server.go",
|
||||
"scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
APP_PARTS = ("core.js", "preferences.js", "config-history.js", "runtime.js", "settings.js")
|
||||
VHOST_PARTS = ("core.js", "editor.js", "render.js")
|
||||
VHOST_PARTS = ("core.js", "health.js", "editor.js", "render.js")
|
||||
|
||||
def _read(root: Path, folder: str, parts: tuple[str, ...]) -> str:
|
||||
return "\n".join((root / "src/admin/web" / folder / part).read_text(encoding="utf-8") for part in parts)
|
||||
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Concurrent persistent-connection HTTP load + optional Linux RustProxy RSS sampler.
|
||||
|
||||
This script issues REAL HTTP requests only when pointed at a RUNNING service.
|
||||
A Python test origin can verify the load generator but cannot validate RustProxy
|
||||
memory. Use an isolated staging instance, not a production endpoint by default.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def proc_sample(pid: int) -> dict | None:
|
||||
path = Path(f"/proc/{pid}")
|
||||
try:
|
||||
status = (path / "status").read_text(encoding="utf-8")
|
||||
counters: dict[str, int] = {}
|
||||
for row in status.splitlines():
|
||||
if row.startswith(("VmRSS:", "VmHWM:", "Threads:")):
|
||||
name, value = row.split(":", 1)
|
||||
counters[name.lower()] = int(value.split()[0]) * (1024 if name != "Threads" else 1)
|
||||
counters["open_fds"] = len(list((path / "fd").iterdir()))
|
||||
return counters
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def run_load(
|
||||
*, url: str, requests: int, concurrency: int, unique_paths: int,
|
||||
timeout: float, host_header: str | None = None, pid: int | None = None,
|
||||
sample_interval: float = 1.0, cooldown: float = 0.0, insecure: bool = False,
|
||||
) -> dict:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||
raise ValueError("URL must be http(s)://host[:port]/path without credentials")
|
||||
if requests < 1 or not (1 <= concurrency <= 2048) or unique_paths < 0:
|
||||
raise ValueError("requests >= 1, concurrency 1..2048 and unique_paths >= 0 are required")
|
||||
if not (0 < timeout <= 120) or sample_interval <= 0 or cooldown < 0:
|
||||
raise ValueError("invalid timeout/sampling/cooldown")
|
||||
base_target = parsed.path or "/"
|
||||
if parsed.query:
|
||||
base_target += "?" + parsed.query
|
||||
if unique_paths and parsed.query:
|
||||
raise ValueError("unique-path mode expects a URL without query parameters")
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
tls = ssl._create_unverified_context() if insecure else ssl.create_default_context()
|
||||
connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
||||
headers = {"user-agent": "RustProxy-Memory-Stress/0.30.0", "accept": "*/*"}
|
||||
if host_header:
|
||||
headers["host"] = host_header
|
||||
|
||||
sampler_stop = threading.Event()
|
||||
trace: list[dict] = []
|
||||
started = time.monotonic()
|
||||
def sampler() -> None:
|
||||
while not sampler_stop.is_set():
|
||||
point = proc_sample(pid) if pid is not None else None
|
||||
if point is not None:
|
||||
trace.append({"elapsed_seconds": round(time.monotonic() - started, 2), **point})
|
||||
sampler_stop.wait(sample_interval)
|
||||
|
||||
sampler_thread = threading.Thread(target=sampler, daemon=True)
|
||||
if pid is not None:
|
||||
sampler_thread.start()
|
||||
|
||||
index_lock = threading.Lock()
|
||||
cursor = 0
|
||||
def next_index() -> int | None:
|
||||
nonlocal cursor
|
||||
with index_lock:
|
||||
if cursor >= requests:
|
||||
return None
|
||||
index = cursor
|
||||
cursor += 1
|
||||
return index
|
||||
|
||||
def worker() -> tuple[Counter, int]:
|
||||
statuses: Counter = Counter()
|
||||
total_bytes = 0
|
||||
connection = None
|
||||
while True:
|
||||
index = next_index()
|
||||
if index is None:
|
||||
break
|
||||
if unique_paths:
|
||||
target = base_target.rstrip("/") + f"/memory-{index % unique_paths}"
|
||||
else:
|
||||
target = base_target
|
||||
try:
|
||||
if connection is None:
|
||||
options = {"timeout": timeout}
|
||||
if parsed.scheme == "https":
|
||||
options["context"] = tls
|
||||
connection = connection_type(parsed.hostname, port, **options)
|
||||
connection.request("GET", target, headers=headers)
|
||||
response = connection.getresponse()
|
||||
statuses[str(response.status)] += 1
|
||||
while chunk := response.read(64 * 1024):
|
||||
total_bytes += len(chunk)
|
||||
if response.will_close:
|
||||
connection.close()
|
||||
connection = None
|
||||
except (OSError, TimeoutError, http.client.HTTPException) as error:
|
||||
statuses["error:" + type(error).__name__] += 1
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
connection = None
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
return statuses, total_bytes
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
||||
results = list(executor.map(lambda _: worker(), range(concurrency)))
|
||||
traffic_end = time.monotonic()
|
||||
if cooldown:
|
||||
time.sleep(cooldown)
|
||||
end = time.monotonic()
|
||||
finally:
|
||||
sampler_stop.set()
|
||||
if pid is not None:
|
||||
sampler_thread.join(timeout=sample_interval + 1)
|
||||
|
||||
statuses: Counter = Counter()
|
||||
received = 0
|
||||
for codes, response_bytes in results:
|
||||
statuses.update(codes)
|
||||
received += response_bytes
|
||||
report = {
|
||||
"target": f"{parsed.scheme}://{parsed.hostname}:{port}{base_target}",
|
||||
"requests_sent": sum(statuses.values()),
|
||||
"concurrency": concurrency,
|
||||
"unique_paths": unique_paths,
|
||||
"statuses": dict(sorted(statuses.items())),
|
||||
"response_bytes_read": received,
|
||||
"traffic_seconds": round(traffic_end - started, 3),
|
||||
"requests_per_second": round(requests / max(0.001, traffic_end - started), 1),
|
||||
"wall_seconds": round(end - started, 3),
|
||||
"sampler_pid": pid,
|
||||
"rss_start_mib": round(trace[0]["vmrss"] / (1024 * 1024), 2) if trace else None,
|
||||
"rss_peak_mib": round(max(t["vmrss"] for t in trace) / (1024 * 1024), 2) if trace else None,
|
||||
"rss_end_mib": round(trace[-1]["vmrss"] / (1024 * 1024), 2) if trace else None,
|
||||
"open_fds_peak": max((t["open_fds"] for t in trace), default=None),
|
||||
"threads_peak": max((t.get("threads", 0) for t in trace), default=None),
|
||||
"rss_samples": trace,
|
||||
"note": "Process RSS values apply only to --pid. No PID means no RustProxy memory measurement.",
|
||||
}
|
||||
return report
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--url", required=True, help="Staging URL (use your configured vhost route)")
|
||||
parser.add_argument("--host-header", help="HTTP Host for a named vhost")
|
||||
parser.add_argument("--pid", type=int, help="Linux PID of the RustProxy process to sample from /proc")
|
||||
parser.add_argument("--requests", type=int, default=100_000)
|
||||
parser.add_argument("--concurrency", type=int, default=64)
|
||||
parser.add_argument("--unique-paths", type=int, default=0, help="Cycle this many distinct URL paths; 0 repeats the same path")
|
||||
parser.add_argument("--timeout", type=float, default=8.0)
|
||||
parser.add_argument("--sample-interval", type=float, default=1.0)
|
||||
parser.add_argument("--cooldown", type=float, default=30.0, help="Observe RSS after traffic stops")
|
||||
parser.add_argument("--insecure", action="store_true", help="Ignore TLS verification for self-signed STAGING only")
|
||||
parser.add_argument("--output", type=Path, help="Optional machine-readable JSON report path")
|
||||
args = parser.parse_args()
|
||||
report = run_load(url=args.url, host_header=args.host_header, pid=args.pid, requests=args.requests,
|
||||
concurrency=args.concurrency, unique_paths=args.unique_paths, timeout=args.timeout,
|
||||
sample_interval=args.sample_interval, cooldown=args.cooldown, insecure=args.insecure)
|
||||
print(json.dumps({k: v for k, v in report.items() if k != "rss_samples"}, indent=2))
|
||||
if args.output:
|
||||
args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user