This commit is contained in:
Mateusz Gruszczyński committed 2026-10-10 08:53:55 +02:00
1 parent 1431dd47f0
commit f3dd34dae4
42 files changed
+950 -133

No files matched your search

+8 -6
View File
@@ -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.');
+10 -10
View File
@@ -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.")
+3 -3
View File
@@ -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"]:
+80
View File
@@ -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()
+33
View File
@@ -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(
+3 -3
View File
@@ -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
+6 -6
View File
@@ -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__":
+1 -1
View File
@@ -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 -1
View File
@@ -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)
+183
View File
@@ -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()