This commit is contained in:
Mateusz Gruszczyński committed 2026-10-03 10:05:46 +02:00
1 parent 5e23e9af02
commit 3883a21bc4
56 files changed
+1640 -403

No files matched your search

+49 -1
View File
@@ -1246,9 +1246,32 @@ fn status_payload(state: &App) -> Value {
"locked_listen": state.startup_locks.administration.map(|value| value.to_string())
});
payload["features"] = Value::Object(features);
payload["cache"] = json!(state.cache.view());
let cache_view = state.cache.view();
let transform_resources = crate::transform::resource_usage(&snapshot.envelope.config.transform);
payload["cache"] = json!(&cache_view);
payload["transform_resources"] = json!(&transform_resources);
payload["geoip"] = json!(state.geoip.view());
let system = state.diagnostics.view();
let cache_ram_bytes = (cache_view.hot_allocated_bytes as u64)
.saturating_add(cache_view.transient_bytes);
let non_cache_rss_bytes = system
.process_rss_bytes
.map(|rss| rss.saturating_sub(cache_ram_bytes));
payload["memory_accounting"] = json!({
"process_rss_bytes": system.process_rss_bytes,
"cache_hot_bytes": cache_view.hot_allocated_bytes,
"cache_hot_limit_bytes": cache_view.hot_limit_bytes,
"cache_transient_bytes": cache_view.transient_bytes,
"cache_transient_limit_bytes": cache_view.transient_limit_bytes,
"cache_total_bytes": cache_ram_bytes,
"transform_reserved_bytes": transform_resources.memory_reserved_bytes,
"transform_budget_bytes": transform_resources.memory_budget_bytes,
"transform_inflight": transform_resources.inflight,
"transform_max_inflight": transform_resources.max_inflight,
"non_cache_rss_bytes": non_cache_rss_bytes,
"non_cache_rss_includes_transform": true,
"method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate"
});
payload["runtime"] = json!({
"platform": system.platform,
"statistics_source": system.source,
@@ -2057,6 +2080,30 @@ mod tests {
);
}
#[tokio::test]
async fn status_exposes_runtime_memory_accounting_and_cold_limits() {
let (router, store, _dir, secret, _) = fixture().await;
let response = router
.oneshot(
request("/api/status")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
assert_eq!(body["transform_resources"]["memory_budget_bytes"], 128 * 1024 * 1024);
assert_eq!(body["transform_resources"]["max_inflight"], 32);
assert_eq!(body["memory_accounting"]["transform_budget_bytes"], 128 * 1024 * 1024);
assert_eq!(body["memory_accounting"]["cache_hot_limit_bytes"], 256 * 1024 * 1024);
assert_eq!(body["cache"]["cold_limit_bytes"], 4_u64 * 1024 * 1024 * 1024);
assert_eq!(body["cache"]["cold_limit_entries"], 50_000);
assert_eq!(body["memory_accounting"]["non_cache_rss_includes_transform"], true);
store.close().await;
}
#[tokio::test]
async fn rejects_legacy_token_rebinding_and_cross_origin() {
let (router, store, _dir, secret, _) = fixture().await;
@@ -3418,6 +3465,7 @@ async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
.collect();
Json(json!({
"settings": global,
"resources": crate::transform::resource_usage(global),
"metrics": state.transform_metrics.view(),
"vhosts": vhosts
}))
+91 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.24.5",
"version": "0.24.7",
"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": [
@@ -913,6 +913,35 @@
"properties": {},
"additionalProperties": true
},
"transform_resources": {
"type": "object",
"properties": {
"memory_reserved_bytes": { "type": "integer", "format": "int64" },
"memory_budget_bytes": { "type": "integer", "format": "int64" },
"inflight": { "type": "integer", "format": "int64" },
"max_inflight": { "type": "integer", "format": "int64" }
},
"additionalProperties": false
},
"memory_accounting": {
"type": "object",
"properties": {
"process_rss_bytes": { "type": "integer", "format": "int64", "nullable": true },
"cache_hot_bytes": { "type": "integer", "format": "int64" },
"cache_hot_limit_bytes": { "type": "integer", "format": "int64" },
"cache_transient_bytes": { "type": "integer", "format": "int64" },
"cache_transient_limit_bytes": { "type": "integer", "format": "int64" },
"cache_total_bytes": { "type": "integer", "format": "int64" },
"transform_reserved_bytes": { "type": "integer", "format": "int64" },
"transform_budget_bytes": { "type": "integer", "format": "int64" },
"transform_inflight": { "type": "integer", "format": "int64" },
"transform_max_inflight": { "type": "integer", "format": "int64" },
"non_cache_rss_bytes": { "type": "integer", "format": "int64", "nullable": true },
"non_cache_rss_includes_transform": { "type": "boolean" },
"method": { "type": "string" }
},
"additionalProperties": false
},
"cache": {
"type": "object",
"properties": {},
@@ -1265,6 +1294,16 @@
"type": "boolean",
"default": true
},
"memory_preset": {
"type": "string",
"nullable": true,
"enum": [
"conservative",
"recommended",
"performance"
],
"description": "Last cache memory sizing preset explicitly selected in Settings. Numeric limits remain authoritative."
},
"max_bytes": {
"type": "integer",
"format": "int64",
@@ -1309,6 +1348,14 @@
"type": "boolean",
"default": false
},
"cold_preset": {
"type": "string",
"nullable": true,
"enum": [
"recommended"
],
"description": "Last COLD policy preset explicitly selected in Settings."
},
"cold_path": {
"type": "string",
"default": "cache/cold"
@@ -2702,6 +2749,17 @@
"type": "boolean",
"default": false
},
"preset": {
"type": "string",
"nullable": true,
"enum": [
"conservative",
"recommended",
"performance",
"uber"
],
"description": "Last resource preset selected in the administration UI. Numeric limits remain authoritative."
},
"javascript": {
"type": "boolean",
"default": true
@@ -2745,6 +2803,20 @@
"minimum": 1024,
"maximum": 33554432,
"default": 2097152
},
"memory_budget_bytes": {
"type": "integer",
"minimum": 1048576,
"maximum": 2147483648,
"default": 134217728,
"description": "Process-wide ceiling for estimated temporary transform buffers. The budget is not pre-allocated."
},
"max_inflight": {
"type": "integer",
"minimum": 1,
"maximum": 1024,
"default": 32,
"description": "Process-wide maximum number of active transformation attempts. Excess work fails open unchanged."
}
}
},
@@ -3163,6 +3235,22 @@
"settings": {
"$ref": "#/components/schemas/TransformSettings"
},
"resources": {
"type": "object",
"additionalProperties": false,
"properties": {
"memory_reserved_bytes": { "type": "integer", "format": "int64" },
"memory_budget_bytes": { "type": "integer", "format": "int64" },
"inflight": { "type": "integer", "format": "int64" },
"max_inflight": { "type": "integer", "format": "int64" }
},
"required": [
"memory_reserved_bytes",
"memory_budget_bytes",
"inflight",
"max_inflight"
]
},
"metrics": {
"type": "object",
"additionalProperties": false,
@@ -3210,6 +3298,7 @@
},
"required": [
"settings",
"resources",
"metrics",
"vhosts"
]
@@ -4243,7 +4332,7 @@
}
}
},
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7–30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7\u201330 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"security": [],
"parameters": [
{
+116 -4
View File
@@ -70,6 +70,7 @@ async function loadLanguages() {
$("language").value = language;
}
let latestStatus = null, certificateInventory = new Map();
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
function translate() {
clearTimeout(toastTimer); hideToast();
document.documentElement.lang = locale(); document.documentElement.dir = activePack()?.meta.direction || "ltr";
@@ -633,6 +634,11 @@ function renderDiagnostics(status, appendSample = true) {
$("cache-objects").textContent = `${fmt(c.entries)} / ${fmtBytes(c.resident_bytes)}`;
$("cache-memory-breakdown").textContent = `${fmtBytes(c.resident_bytes)} / ${fmtBytes(c.transient_bytes)}`;
$("cache-transient-breakdown").textContent = `${fmtBytes(c.fill_bytes_active)} / ${fmtBytes(c.cold_queue_bytes)} / ${fmtBytes((Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0))}`;
const coldStorage = knownPair(c.cold_bytes, c.cold_limit_bytes, value => fmtBytes(value));
const coldObjects = knownPair(c.cold_entries, c.cold_limit_entries, value => fmt(value));
$("cache-cold-storage").textContent = envelope?.config.cache?.cold_enabled === true
? `${coldStorage ?? "-"}${coldObjects == null ? "" : ` · ${coldObjects} ${t("cacheColdStorageObjects")}`}`
: t("notEnabled");
$("cache-evictions").textContent = `${fmt(c.evictions)} / ${fmt(c.expired)}`;
if (appendSample) {
if (c.resident_bytes != null) { cacheMemorySamples.push(Number(c.resident_bytes)); cacheMemorySamples = cacheMemorySamples.slice(-60); }
@@ -664,6 +670,16 @@ function renderDiagnostics(status, appendSample = true) {
setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : new Date(Number(d.process_start_time_seconds) * 1000).toLocaleString(locale()));
setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds));
setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes));
const memory = status.memory_accounting || {};
const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value));
const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value));
const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value));
const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value));
setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory);
setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory);
setOptionalMetric("memory-transform", transformMemory != null, transformMemory);
setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight);
setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes));
setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes));
const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value));
setOptionalMetric("process-rss-file", rssFile != null, rssFile);
@@ -736,7 +752,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.5";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.7";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
@@ -757,12 +773,15 @@ function renderSettings() {
$("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n");
$("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n");
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age;
setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null);
setCacheColdPresetSelection(c.cold_preset === "recommended" ? "recommended" : null);
syncColdCachePolicy();
syncAdvancedCachePolicy();
updateCacheMemorySizing();
}
if (!settingsDirty.has("transform-form")) {
const tr = config.transform || {};
setTransformPresetSelection(["conservative", "recommended", "performance", "uber"].includes(tr.preset) ? tr.preset : null);
$("transform-enabled").checked = !!tr.enabled;
$("transform-javascript").checked = tr.javascript !== false;
$("transform-css").checked = tr.css !== false;
@@ -771,6 +790,9 @@ function renderSettings() {
$("transform-json").checked = !!tr.json;
$("transform-extensions").value = (tr.extensions || ["js", "mjs", "css", "html", "htm", "svg", "json"]).join("\n");
$("transform-max-body").value = (tr.max_body_bytes ?? 2097152) / 1024;
$("transform-memory-budget").value = (tr.memory_budget_bytes ?? 134217728) / 1048576;
$("transform-max-inflight").value = tr.max_inflight ?? 32;
updateTransformResourceSizing();
}
if (!settingsDirty.has("logging-form")) {
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {};
@@ -814,6 +836,29 @@ function cachePresetValues(kind, bytes) {
const entries = Math.max(1024, Math.min(16384, Math.round(hot * 16)));
return { hot, transient, queue, objectKiB: objectMiB * 1024, entries, eviction: profile.eviction };
}
function cacheMemoryPresetName(kind) {
return kind ? t({ conservative: "cachePresetConservative", recommended: "cachePresetRecommended", performance: "cachePresetPerformance" }[kind]) : t("cachePresetCustom");
}
function setCacheMemoryPresetSelection(kind) {
selectedCacheMemoryPreset = ["conservative", "recommended", "performance"].includes(kind) ? kind : null;
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => {
const active = button.dataset.cacheMemoryPreset === selectedCacheMemoryPreset;
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
button.setAttribute("aria-pressed", String(active));
});
const badge = $("cache-memory-preset-selected");
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${cacheMemoryPresetName(selectedCacheMemoryPreset)}`;
}
function setCacheColdPresetSelection(kind) {
selectedCacheColdPreset = kind === "recommended" ? "recommended" : null;
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => {
const active = button.dataset.cacheColdPreset === selectedCacheColdPreset;
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
button.setAttribute("aria-pressed", String(active));
});
const badge = $("cache-cold-preset-selected");
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${selectedCacheColdPreset ? t("cachePresetRecommended") : t("cachePresetCustom")}`;
}
function updateCacheMemorySizing() {
const detected = effectiveCacheMemory(), badge = $("cache-memory-detected"), warning = $("cache-memory-warning");
if (!badge || !warning) return;
@@ -854,8 +899,65 @@ function applyCacheMemoryPreset(kind) {
$("cache-memory").value = preset.hot; $("cache-transient-memory").value = preset.transient;
$("cache-object").value = preset.objectKiB; $("cache-entries").value = preset.entries;
$("cache-eviction-limit").value = preset.eviction; $("cache-cold-queue-bytes").value = preset.queue;
setCacheMemoryPresetSelection(kind);
setSettingsDirty("cache-form"); updateCacheMemorySizing();
}
function applyRecommendedColdCachePreset() {
const transient = Number($("cache-transient-memory").value);
const queue = Number.isFinite(transient) && transient > 0 ? Math.max(0.0625, Math.floor((transient * .5) / 8) * 8 || Math.min(transient, 8)) : 32;
$("cache-cold-enabled").checked = true;
if (!$("cache-cold-path").value.trim()) $("cache-cold-path").value = "cache/cold";
$("cache-cold-on-idle").checked = true; $("cache-cold-on-age").checked = false;
$("cache-cold-on-lru").checked = true; $("cache-cold-on-shutdown").checked = true;
$("cache-cold-after").value = 300; $("cache-cold-max-hot").value = 3600;
$("cache-cold-gc-interval").value = 60; $("cache-cold-gc-max-delete").value = 256;
$("cache-cold-queue-bytes").value = Math.min(queue, transient || queue);
setCacheColdPresetSelection("recommended");
syncColdCachePolicy(); setSettingsDirty("cache-form"); updateCacheMemorySizing();
}
function transformPresetValues(kind) {
return {
conservative: { memoryMiB: 32, bodyKiB: 1024, inflight: 8 },
recommended: { memoryMiB: 128, bodyKiB: 2048, inflight: 32 },
performance: { memoryMiB: 512, bodyKiB: 8192, inflight: 64 },
uber: { memoryMiB: 1024, bodyKiB: 32768, inflight: 128 }
}[kind] || { memoryMiB: 128, bodyKiB: 2048, inflight: 32 };
}
function transformPresetName(kind) {
return kind ? t({ conservative: "transformPresetConservative", recommended: "transformPresetRecommended", performance: "transformPresetPerformance", uber: "transformPresetUber" }[kind]) : t("cachePresetCustom");
}
function setTransformPresetSelection(kind) {
selectedTransformPreset = ["conservative", "recommended", "performance", "uber"].includes(kind) ? kind : null;
document.querySelectorAll("[data-transform-preset]").forEach(button => {
const active = button.dataset.transformPreset === selectedTransformPreset;
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
button.setAttribute("aria-pressed", String(active));
});
const badge = $("transform-preset-selected");
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${transformPresetName(selectedTransformPreset)}`;
}
function updateTransformResourceSizing() {
const warning = $("transform-memory-warning"); if (!warning) return;
const memoryMiB = Number($("transform-memory-budget")?.value), bodyKiB = Number($("transform-max-body")?.value), inflight = Number($("transform-max-inflight")?.value);
const detected = effectiveCacheMemory();
const budgetBytes = memoryMiB * 1048576, bodyBytes = bodyKiB * 1024;
const worstEncodedEstimate = bodyBytes * 3;
const budgetCannotFitMax = Number.isFinite(budgetBytes) && Number.isFinite(worstEncodedEstimate) && budgetBytes < worstEncodedEstimate;
const highHostShare = detected && Number.isFinite(budgetBytes) && budgetBytes > detected.bytes * .5;
warning.hidden = !(budgetCannotFitMax || highHostShare);
if (budgetCannotFitMax) warning.textContent = t("transformBudgetTooSmallWarning");
else if (highHostShare) warning.textContent = `${t("transformBudgetHighWarning")} ${fmtBytes(budgetBytes)} / ${fmtBytes(detected.bytes)}.`;
const summary = $("transform-preset-summary");
if (summary) summary.textContent = `${t("transformResourceCurrent")}: ${Number.isFinite(memoryMiB) ? memoryMiB : "-"} MiB · ${Number.isFinite(bodyKiB) ? bodyKiB : "-"} KiB · ${t("transformMaxInflightShort")} ${Number.isFinite(inflight) ? inflight : "-"}`;
}
function applyTransformPreset(kind) {
const preset = transformPresetValues(kind);
$("transform-memory-budget").value = preset.memoryMiB;
$("transform-max-body").value = preset.bodyKiB;
$("transform-max-inflight").value = preset.inflight;
setTransformPresetSelection(kind);
setSettingsDirty("transform-form"); updateTransformResourceSizing();
}
function syncServerMode() { $("server-value").disabled = $("server-mode").value !== "custom"; $("server-value").required = !$("server-value").disabled; }
function syncLoggingFileMode() {
const enabled = $("log-file-enabled").checked, perVhost = $("log-file-per-vhost").checked;
@@ -903,18 +1005,21 @@ bindSettingsForm("response-form", next => {
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
bindSettingsForm("cache-form", next => {
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), cold_enabled: $("cache-cold-enabled").checked, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
}, "cache");
bindSettingsForm("transform-form", next => {
next.transform = {
enabled: $("transform-enabled").checked,
preset: selectedTransformPreset,
javascript: $("transform-javascript").checked,
css: $("transform-css").checked,
html: $("transform-html").checked,
svg: $("transform-svg").checked,
json: $("transform-json").checked,
extensions: $("transform-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean),
max_body_bytes: Math.round(Number($("transform-max-body").value) * 1024)
max_body_bytes: Math.round(Number($("transform-max-body").value) * 1024),
memory_budget_bytes: Math.round(Number($("transform-memory-budget").value) * 1048576),
max_inflight: Number($("transform-max-inflight").value)
};
}, "transform");
const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
@@ -995,7 +1100,14 @@ $("log-file-enabled").addEventListener("change", syncLoggingFileMode);
$("log-file-per-vhost").addEventListener("change", syncLoggingFileMode);
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age"]) $(id).addEventListener("change", syncColdCachePolicy);
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => button.addEventListener("click", () => applyCacheMemoryPreset(button.dataset.cacheMemoryPreset)));
for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-cold-queue-bytes"]) $(id).addEventListener("input", updateCacheMemorySizing);
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => button.addEventListener("click", applyRecommendedColdCachePreset));
document.querySelectorAll("[data-transform-preset]").forEach(button => button.addEventListener("click", () => applyTransformPreset(button.dataset.transformPreset)));
for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-entries", "cache-eviction-limit"]) $(id).addEventListener("input", () => { setCacheMemoryPresetSelection(null); updateCacheMemorySizing(); });
$("cache-cold-queue-bytes").addEventListener("input", () => { setCacheMemoryPresetSelection(null); setCacheColdPresetSelection(null); updateCacheMemorySizing(); });
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown", "cache-cold-after", "cache-cold-max-hot", "cache-cold-gc-interval", "cache-cold-gc-max-delete"]) {
for (const event of ["input", "change"]) $(id).addEventListener(event, () => setCacheColdPresetSelection(null));
}
for (const id of ["transform-memory-budget", "transform-max-body", "transform-max-inflight"]) $(id).addEventListener("input", () => { setTransformPresetSelection(null); updateTransformResourceSizing(); });
for (const id of ["cache-collapse-enabled", "cache-proactive-enabled"]) $(id).addEventListener("change", syncAdvancedCachePolicy);
syncColdCachePolicy();
syncAdvancedCachePolicy();
+78 -10
View File
@@ -265,6 +265,10 @@
<dt data-i18n="cacheTransientBreakdown">Fill / cold queue / cold I/O</dt>
<dd id="cache-transient-breakdown">-</dd>
</div>
<div>
<dt data-i18n="cacheColdStorageUsage">COLD storage / budget</dt>
<dd id="cache-cold-storage">-</dd>
</div>
<div>
<dt data-i18n="cacheEvictions">Evicted / expired</dt>
<dd id="cache-evictions">-</dd>
@@ -339,6 +343,26 @@
<dt data-i18n="processRssTotal">RSS total</dt>
<dd id="process-rss-total">-</dd>
</div>
<div>
<dt data-i18n="memoryCacheHot">HOT cache RAM / limit</dt>
<dd id="memory-cache-hot">-</dd>
</div>
<div>
<dt data-i18n="memoryCacheTransient">Cache transient RAM / limit</dt>
<dd id="memory-cache-transient">-</dd>
</div>
<div>
<dt data-i18n="memoryTransform">Transform reserved RAM / budget</dt>
<dd id="memory-transform">-</dd>
</div>
<div>
<dt data-i18n="memoryTransformInflight">Active transforms / limit</dt>
<dd id="memory-transform-inflight">-</dd>
</div>
<div>
<dt data-i18n="memoryNonCacheRss">Non-cache process RSS</dt>
<dd id="memory-non-cache-rss">-</dd>
</div>
<div>
<dt data-i18n="processRssAnon">Anonymous RSS</dt>
<dd id="process-rss-anon">-</dd>
@@ -356,8 +380,7 @@
<dd id="cgroup-memory">-</dd>
</div>
</dl>
<p class="fine" data-i18n="memoryAccountingHint">OS RSS categories and RustProxy cache
accounting are overlapping views; do not add them together.</p>
<p class="fine" data-i18n="memoryAccountingHint">RSS is the real process footprint. HOT/transient cache values are RustProxy-accounted allocations; transform memory is a conservative active reservation. Non-cache RSS is RSS minus accounted cache and includes transforms, connections, TLS, logging, Tokio/runtime state, allocator overhead and other buffers, so these rows are not all additive.</p>
<div class="mini-live-chart-stack">
<div class="mini-live-chart">
<div class="mini-live-chart-head"><span data-i18n="processCpuHistory">Process CPU
@@ -1099,6 +1122,10 @@
id="tp-skipped">-</strong><small id="tp-skip-top">-</small></article>
<article class="stat"><span data-i18n="transformErrors">Body/transport errors</span><strong
id="tp-errors">-</strong><small id="tp-duration">-</small></article>
<article class="stat"><span data-i18n="transformMemoryUsage">Transform RAM reservation</span><strong
id="tp-memory">-</strong><small id="tp-memory-detail">-</small></article>
<article class="stat"><span data-i18n="transformConcurrencyUsage">Active transform slots</span><strong
id="tp-inflight">-</strong><small id="tp-inflight-detail">-</small></article>
</div>
<div class="diagnostics-grid transform-diagnostics">
<article class="card">
@@ -1326,7 +1353,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.24.5"
id="server-value" maxlength="256" placeholder="RustProxy/v0.24.7"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -1377,8 +1404,9 @@
multipart/unsupported ranges pass through.</p>
<div class="card soft-card cache-memory-sizing">
<div class="card-heading">
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3><span class="pill"
id="cache-memory-detected">-</span>
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3>
<span class="pill" id="cache-memory-preset-selected">-</span>
<span class="pill" id="cache-memory-detected">-</span>
</div>
<p class="fine" data-i18n="cacheMemorySizingHelp">Presets use the effective
memory available to RustProxy (host RAM capped by cgroup when present). They
@@ -1387,7 +1415,7 @@
<div class="form-actions">
<button class="outline" data-cache-memory-preset="conservative"
data-i18n="cachePresetConservative" type="button">Conservative</button>
<button class="primary" data-cache-memory-preset="recommended"
<button class="outline" data-cache-memory-preset="recommended"
data-i18n="cachePresetRecommended" type="button">Recommended</button>
<button class="outline" data-cache-memory-preset="performance"
data-i18n="cachePresetPerformance" type="button">Performance</button>
@@ -1480,6 +1508,17 @@
deterministic sampling and a global concurrency cap to avoid refresh storms.</p>
</div>
<div data-cache-settings-page="cold" hidden>
<div class="card soft-card cache-memory-sizing">
<div class="card-heading">
<h3 data-i18n="cacheColdPresetTitle">COLD policy preset</h3>
<span class="pill" id="cache-cold-preset-selected">-</span>
</div>
<p class="fine" data-i18n="cacheColdPresetHelp">The recommended policy enables idle/LRU/shutdown demotion, uses a queue sized from transient RAM and conservative GC timings. Disk path, disk budget and object limit are left unchanged.</p>
<div class="form-actions">
<button class="outline" data-cache-cold-preset="recommended"
data-i18n="cachePresetRecommended" type="button">Recommended</button>
</div>
</div>
<label class="check"><input id="cache-cold-enabled" type="checkbox" /><span
data-i18n="cacheColdEnable">Enable persistent disk tier</span></label>
<p class="fine" data-i18n="cacheColdHelp">Idle and LRU hot objects are demoted to
@@ -1569,6 +1608,38 @@
<p class="fine" data-i18n="transformHelp">Transformation happens after upstream decoding
and before cache storage and client compression. Each vhost must opt in separately.
</p>
<div class="card soft-card cache-memory-sizing">
<div class="card-heading">
<h3 data-i18n="transformResourcePresetTitle">Resource preset</h3>
<span class="pill" id="transform-preset-selected">-</span>
</div>
<p class="fine" data-i18n="transformResourcePresetHelp">Presets tune the temporary
memory ceiling, maximum transformable response size and global concurrent
transformation limit. Content types and extension allow-lists are left unchanged.</p>
<div class="form-actions">
<button class="outline" data-i18n="transformPresetConservative"
data-transform-preset="conservative" type="button">RAM saver</button>
<button class="outline" data-i18n="transformPresetRecommended"
data-transform-preset="recommended" type="button">Recommended</button>
<button class="outline" data-i18n="transformPresetPerformance"
data-transform-preset="performance" type="button">Performance</button>
<button class="outline" data-i18n="transformPresetUber"
data-transform-preset="uber" type="button">Uber performance</button>
</div>
<p class="fine" id="transform-preset-summary">-</p>
</div>
<p class="fine" id="transform-memory-warning" hidden></p>
<div class="field-grid">
<div><label data-i18n="transformMemoryBudget" for="transform-memory-budget">Temporary
transform memory budget (MiB)</label><input id="transform-memory-budget"
max="2048" min="1" required="" step="1" type="number" /></div>
<div><label data-i18n="transformMaxBody" for="transform-max-body">Maximum transformable
response (KiB)</label><input id="transform-max-body" min="1" max="32768"
required="" type="number" /></div>
<div><label data-i18n="transformMaxInflight" for="transform-max-inflight">Maximum
concurrent transformations</label><input id="transform-max-inflight" min="1"
max="1024" required="" type="number" /></div>
</div>
<div class="switch-grid">
<label class="check"><input id="transform-javascript" type="checkbox" /><span
data-i18n="transformJavascript">JavaScript minification</span></label>
@@ -1584,9 +1655,6 @@
<label data-i18n="transformExtensions" for="transform-extensions">Allowed file
extensions, one per line</label>
<textarea id="transform-extensions" rows="6" spellcheck="false"></textarea>
<label data-i18n="transformMaxBody" for="transform-max-body">Maximum transformable
response (KiB)</label>
<input id="transform-max-body" min="1" max="65536" required="" type="number" />
<p class="fine" data-i18n="transformSafety">Cache-Control: no-transform is always
respected. Transformed representations receive a new ETag and are cached before
gzip, Brotli or zstd encoding.</p>
@@ -1992,7 +2060,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.24.5</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.24.7</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
+7
View File
@@ -134,6 +134,13 @@
$("tp-skipped").textContent = number(counters.skipped);
$("tp-errors").textContent = number(counters.errors);
$("tp-duration").textContent = `${t("transformAvgTime")}: ${averageMs(counters)}`;
const resources = data?.resources || {};
const reserved = Number(resources.memory_reserved_bytes), budget = Number(resources.memory_budget_bytes);
const inflight = Number(resources.inflight), maxInflight = Number(resources.max_inflight);
$("tp-memory").textContent = Number.isFinite(reserved) ? fmtBytes(reserved) : "-";
$("tp-memory-detail").textContent = Number.isFinite(budget) ? `${t("transformBudgetShort")}: ${fmtBytes(budget)}${budget > 0 && Number.isFinite(reserved) ? ` · ${fmt(reserved / budget * 100, 1)}%` : ""}` : "-";
$("tp-inflight").textContent = Number.isFinite(inflight) ? number(inflight) : "-";
$("tp-inflight-detail").textContent = Number.isFinite(maxInflight) ? `${t("cacheLimitShort")}: ${number(maxInflight)}` : "-";
renderTypes(counters); renderReasons(counters); renderVhosts(data);
$("transform-panel-state").textContent = `${t("cacheUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
}
+1 -1
View File
@@ -600,7 +600,7 @@
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.5", "text", { maxLength: 256 });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.7", "text", { maxLength: 256 });
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
+66 -30
View File
@@ -720,22 +720,37 @@ impl Cache {
}
}
fn enqueue_cold_delete(&self, paths: Vec<PathBuf>) {
fn enqueue_cold_delete(&self, mut paths: Vec<PathBuf>) {
if paths.is_empty() {
return;
}
if let Some(tx) = &self.cold_tx {
if tx.try_send(ColdCommand::Delete(paths.clone())).is_ok() {
return;
match tx.try_send(ColdCommand::Delete(paths)) {
Ok(()) => return,
Err(error) => {
paths = match error.into_inner() {
ColdCommand::Delete(paths) => paths,
_ => unreachable!("enqueue_cold_delete only sends delete commands"),
};
}
}
}
// Queue saturation must not block request processing. The index is already
// updated; a best-effort detached delete keeps physical cleanup asynchronous.
tokio::spawn(async move {
// Do not create an unbounded number of detached delete tasks when the
// writer queue is saturated. A tiny fallback pool absorbs short bursts;
// sustained saturation falls back to synchronous removal so stale files
// cannot be resurrected on restart at the cost of bounded backpressure.
if let Ok(permit) = self.cold_delete_fallbacks.clone().try_acquire_owned() {
tokio::spawn(async move {
let _permit = permit;
for path in paths {
let _ = tokio::fs::remove_file(path).await;
}
});
} else {
for path in paths {
let _ = tokio::fs::remove_file(path).await;
let _ = std::fs::remove_file(path);
}
});
}
}
async fn remove_cold_candidates(
@@ -748,8 +763,13 @@ impl Cache {
const SNAPSHOT_BATCH: usize = 256;
const DELETE_BATCH: usize = 32;
let requested_limit = request
.and_then(|value| value.oldest)
.unwrap_or(max_delete)
.min(max_delete);
let manual = request.is_some();
let now = SystemTime::now();
let mut candidates = Vec::new();
let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1));
let mut after_sequence = 0u64;
// Copy candidate metadata in bounded chunks. Request-path lookups only
@@ -788,27 +808,32 @@ impl Cache {
})
});
if matches {
candidates.push((sequence, key, hits, last_access, stored_at));
let priority = if manual {
CleanupPriority::Manual(stored_at, last_access, hits)
} else {
CleanupPriority::Automatic(hits, last_access, stored_at)
};
let candidate = CleanupCandidate {
priority,
sequence,
key,
};
if candidates.len() < requested_limit {
candidates.push(candidate);
} else if candidates
.peek()
.is_some_and(|worst| candidate.cmp(worst).is_lt())
{
candidates.pop();
candidates.push(candidate);
}
}
}
tokio::task::yield_now().await;
}
if request.is_some() {
candidates.sort_by_key(|(_, _, hits, last_access, stored_at)| {
(*stored_at, *last_access, *hits)
});
} else {
// Automatic GC prefers rarely used, least recently used, oldest data.
candidates.sort_by_key(|(_, _, hits, last_access, stored_at)| {
(*hits, *last_access, *stored_at)
});
}
let requested_limit = request
.and_then(|value| value.oldest)
.unwrap_or(max_delete)
.min(max_delete);
let mut candidates = candidates.into_vec();
candidates.sort_unstable();
let mut result = ColdCleanupResult::default();
let mut delete = Vec::new();
@@ -818,7 +843,7 @@ impl Cache {
}
{
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
for (sequence, key, _, _, _) in chunk {
for candidate in chunk {
if result.removed_entries >= requested_limit {
break;
}
@@ -835,18 +860,21 @@ impl Cache {
}
if !cold
.entries
.get(key)
.is_some_and(|entry| entry.sequence == *sequence && entry.pinned_reads == 0)
.get(&candidate.key)
.is_some_and(|entry| entry.sequence == candidate.sequence && entry.pinned_reads == 0)
{
continue;
}
if let Some(entry) = cold.remove(key) {
if let Some(entry) = cold.remove(&candidate.key) {
result.removed_entries += 1;
result.freed_bytes = result.freed_bytes.saturating_add(entry.disk_bytes);
delete.extend([entry.meta_path, entry.body_path]);
}
}
}
// Keep pending deletion metadata bounded to one selection batch; a
// 10k-entry manual cleanup must not build one large Vec<PathBuf>.
self.enqueue_cold_delete(std::mem::take(&mut delete));
tokio::task::yield_now().await;
}
@@ -855,11 +883,13 @@ impl Cache {
.cold_evictions
.fetch_add(result.removed_entries as u64, Ordering::Relaxed);
}
self.enqueue_cold_delete(delete);
result
}
async fn cold_gc_pass(&self) -> ColdCleanupResult {
let Ok(_cleanup_slot) = self.cleanup_slots.clone().try_acquire_owned() else {
return ColdCleanupResult::default();
};
let settings = self
.tier_settings
.lock()
@@ -903,6 +933,11 @@ impl Cache {
.unwrap_or_else(|e| e.into_inner())
.clone();
ensure!(settings.cold_enabled, "cold cache is disabled");
let cleanup_slot = self
.cleanup_slots
.clone()
.try_acquire_owned()
.map_err(|_| anyhow::anyhow!("cold cache cleanup is already running"))?;
let id = self.cleanup_sequence.fetch_add(1, Ordering::Relaxed).saturating_add(1);
let job = ColdCleanupJob {
id,
@@ -924,6 +959,7 @@ impl Cache {
}
let cache = self.clone();
tokio::spawn(async move {
let _cleanup_slot = cleanup_slot;
{
let mut jobs = cache.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(job) = jobs.get_mut(&id) {
+13
View File
@@ -210,6 +210,19 @@ impl Cache {
.proactive_scheduled_keys
.lock()
.unwrap_or_else(|e| e.into_inner());
// Each scheduled key owns a sleeping Tokio task plus refresh context.
// Bound the pending set independently from the cache entry count so a
// large cache with long TTLs cannot turn into thousands of resident tasks.
let pending_limit = settings
.proactive_refresh_max_inflight
.saturating_mul(64)
.clamp(64, 256);
if scheduled.len() >= pending_limit && !scheduled.contains(&ticket.key) {
self.counters
.proactive_skipped
.fetch_add(1, Ordering::Relaxed);
return None;
}
if !scheduled.insert(ticket.key) {
return None;
}
+4
View File
@@ -38,6 +38,8 @@ impl Cache {
cold_queue_limit_bytes: settings.cold_queue_max_bytes,
cold_entries: cold.entries.len(),
cold_bytes: cold.bytes,
cold_limit_bytes: settings.cold_max_bytes,
cold_limit_entries: settings.cold_max_entries,
cold_hits: n(&self.counters.cold_hits),
cold_promotions: n(&self.counters.cold_promotions),
hot_demotions: n(&self.counters.hot_demotions),
@@ -121,6 +123,8 @@ impl Cache {
),
("cold_entries", "gauge", view.cold_entries as u64),
("cold_bytes", "gauge", view.cold_bytes),
("cold_limit_bytes", "gauge", view.cold_limit_bytes),
("cold_limit_entries", "gauge", view.cold_limit_entries as u64),
("cold_hits_total", "counter", view.cold_hits),
("cold_promotions_total", "counter", view.cold_promotions),
("hot_demotions_total", "counter", view.hot_demotions),
+4
View File
@@ -10,6 +10,8 @@ impl Cache {
tier_settings: Mutex::new(Settings::default()),
data_root: None,
cold_tx: None,
cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS)),
cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS)),
fills: Arc::new(Semaphore::new(FILL_SLOTS)),
fill_bytes_active: AtomicU64::new(0),
transient_bytes_active: Arc::new(AtomicU64::new(0)),
@@ -47,6 +49,8 @@ impl Cache {
tier_settings: Mutex::new(settings),
data_root,
cold_tx: Some(tx),
cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS)),
cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS)),
fills: Arc::new(Semaphore::new(FILL_SLOTS)),
fill_bytes_active: AtomicU64::new(0),
transient_bytes_active: Arc::new(AtomicU64::new(0)),
+44 -1
View File
@@ -6,7 +6,7 @@ use http_body::{Body as HttpBody, Frame, SizeHint};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, HashMap, HashSet},
collections::{BTreeMap, BinaryHeap, HashMap, HashSet},
path::{Component, Path, PathBuf},
pin::Pin,
sync::{
@@ -21,12 +21,28 @@ use tokio_util::sync::CancellationToken;
const MAX_HEADER_BYTES: usize = 32 * 1024;
const FILL_SLOTS: usize = 8;
const COLD_DELETE_FALLBACKS: usize = 2;
const COLD_CLEANUP_SLOTS: usize = 1;
const BACKGROUND_REVALIDATION_LIMIT: usize = 8;
// Cold writes can contain complete response bodies. Keep the queue deliberately
// small so demotion cannot become a second, hidden RAM cache while disk is slow.
const COLD_QUEUE: usize = 16;
const COLD_FORMAT_VERSION: u8 = 3;
type Key = [u8; 32];
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
enum CleanupPriority {
Automatic(u64, SystemTime, SystemTime),
Manual(SystemTime, SystemTime, u64),
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
struct CleanupCandidate {
priority: CleanupPriority,
sequence: u64,
key: Key,
}
/// `Bytes` releases its allocation when the last reference is dropped, but
/// glibc can keep large freed arenas mapped. After bulk cache demotions/purges,
/// return releasable pages to the OS instead of letting RSS stay artificially
@@ -59,10 +75,28 @@ impl Default for CollapseTimeoutFallback {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MemoryPreset {
Conservative,
Recommended,
Performance,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ColdPreset {
Recommended,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
/// UI sizing profile last explicitly selected by the administrator. The
/// actual numeric limits remain authoritative; this only preserves intent
/// across reloads so the dashboard can show which preset was applied.
pub memory_preset: Option<MemoryPreset>,
/// Hard RAM budget for published HOT entries, charged by backing allocation
/// plus directly owned metadata rather than only logical body length.
pub max_bytes: usize,
@@ -80,6 +114,9 @@ pub struct Settings {
pub hot_eviction_max_objects: usize,
/// Persistent cold tier. Disabled by default to avoid implicit disk writes.
pub cold_enabled: bool,
/// Last explicitly selected COLD policy profile, persisted with the cache
/// configuration for the same reason as `memory_preset`.
pub cold_preset: Option<ColdPreset>,
/// Relative to the RustProxy data directory.
pub cold_path: String,
pub cold_max_bytes: u64,
@@ -135,6 +172,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
enabled: true,
memory_preset: None,
max_bytes: 256 * 1024 * 1024,
max_object_bytes: 16 * 1024 * 1024,
max_entries: 4096,
@@ -142,6 +180,7 @@ impl Default for Settings {
cold_queue_max_bytes: 32 * 1024 * 1024,
hot_eviction_max_objects: 64,
cold_enabled: false,
cold_preset: None,
cold_path: "cache/cold".into(),
cold_max_bytes: 4 * 1024 * 1024 * 1024,
cold_max_entries: 50_000,
@@ -572,6 +611,8 @@ pub struct View {
pub cold_queue_limit_bytes: usize,
pub cold_entries: usize,
pub cold_bytes: u64,
pub cold_limit_bytes: u64,
pub cold_limit_entries: usize,
pub cold_hits: u64,
pub cold_promotions: u64,
pub hot_demotions: u64,
@@ -1067,6 +1108,8 @@ pub struct Cache {
tier_settings: Mutex<Settings>,
data_root: Option<PathBuf>,
cold_tx: Option<mpsc::Sender<ColdCommand>>,
cold_delete_fallbacks: Arc<Semaphore>,
cleanup_slots: Arc<Semaphore>,
fills: Arc<Semaphore>,
fill_bytes_active: AtomicU64,
transient_bytes_active: Arc<AtomicU64>,
+109
View File
@@ -662,6 +662,8 @@ mod tests {
#[test]
fn cache_memory_defaults_are_bounded_and_sane() {
let s = Settings::default();
assert_eq!(s.memory_preset, None);
assert_eq!(s.cold_preset, None);
assert_eq!(s.max_bytes, 256 * 1024 * 1024);
assert_eq!(s.max_object_bytes, 16 * 1024 * 1024);
assert_eq!(s.transient_max_bytes, 64 * 1024 * 1024);
@@ -672,6 +674,19 @@ mod tests {
assert!(Cache::fill_headroom_bytes(s.max_bytes) <= 1024 * 1024);
}
#[test]
fn cache_preset_intent_round_trips_with_configuration() {
let mut settings = Settings::default();
settings.memory_preset = Some(MemoryPreset::Recommended);
settings.cold_preset = Some(ColdPreset::Recommended);
let encoded = serde_json::to_value(&settings).unwrap();
assert_eq!(encoded["memory_preset"], "recommended");
assert_eq!(encoded["cold_preset"], "recommended");
let decoded: Settings = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded.memory_preset, Some(MemoryPreset::Recommended));
assert_eq!(decoded.cold_preset, Some(ColdPreset::Recommended));
}
#[test]
fn transient_budget_is_hard_and_released_with_lease() {
let cache = Cache::new(1);
@@ -2142,6 +2157,84 @@ mod v022_cache_tests {
assert_eq!(cache.view().proactive_inflight, 0);
}
#[test]
fn proactive_refresh_pending_tasks_are_bounded() {
let cache = Cache::new(22);
let mut settings = active_settings();
settings.proactive_refresh_enabled = true;
settings.proactive_refresh_percent = 100;
settings.proactive_refresh_max_inflight = 1;
let mut guards = Vec::new();
for index in 0..64 {
let ticket = cache
.ticket(
22,
"http",
"refresh.example",
"origin",
&format!("/asset-{index}"),
&HeaderMap::new(),
)
.unwrap();
guards.push(
cache
.begin_proactive_schedule(&ticket, &settings)
.expect("schedule below pending limit"),
);
}
let overflow = cache
.ticket(
22,
"http",
"refresh.example",
"origin",
"/overflow",
&HeaderMap::new(),
)
.unwrap();
assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none());
assert!(cache.view().proactive_skipped > 0);
drop(guards);
}
#[test]
fn background_revalidation_set_has_a_hard_limit() {
let cache = Cache::new(22);
let base = Revalidation {
headers: HeaderMap::new(),
body: Bytes::new(),
body_allocated: 0,
_transient_leases: Vec::new(),
hits: 0,
transformed: false,
storage_key: [0; 32],
resident_age_at_lookup: Duration::ZERO,
skip_compression: false,
reusable_without_validation: false,
age: 0,
ttl: 0,
stale_if_error: 0,
must_revalidate: false,
proxy_revalidate: false,
origin_etag: None,
origin_last_modified: None,
};
let mut guards = Vec::new();
for index in 0..BACKGROUND_REVALIDATION_LIMIT {
let mut cached = base.clone();
cached.storage_key[0..8].copy_from_slice(&(index as u64).to_be_bytes());
guards.push(
cache
.begin_background_revalidation(&cached)
.expect("revalidation below hard limit"),
);
}
let mut overflow = base;
overflow.storage_key = [0xff; 32];
assert!(cache.begin_background_revalidation(&overflow).is_none());
drop(guards);
}
#[tokio::test]
async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() {
let cache = Cache::new(22);
@@ -3036,6 +3129,22 @@ mod cold_tier_tests {
panic!("cold cleanup did not finish");
}
#[tokio::test]
async fn manual_cold_cleanup_is_single_flight() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings, shutdown.clone());
let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
let result = cache.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
});
assert!(result.is_err());
shutdown.cancel();
}
#[tokio::test]
async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() {
let dir = tempfile::tempdir().unwrap();
+3
View File
@@ -310,6 +310,9 @@ impl Cache {
// representation so an SWR refresh for one variant cannot suppress another.
let key = cached.storage_key;
let mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner());
if active.len() >= BACKGROUND_REVALIDATION_LIMIT && !active.contains(&key) {
return None;
}
if !active.insert(key) {
return None;
}
+20 -1
View File
@@ -385,7 +385,12 @@ mod tests {
let file = dir.path().join("test.sqlite3");
let store = Store::open(&file, &Config::default()).await.unwrap();
assert_eq!(store.load().await.unwrap().revision, 0);
let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
config.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended);
config.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended);
config.transform.preset = Some(crate::transform::Preset::Performance);
config.transform.memory_budget_bytes = 512 * 1024 * 1024;
config.transform.max_inflight = 64;
assert!(store.replace(0, &config).await.unwrap());
assert!(!store.replace(0, &Config::default()).await.unwrap());
assert_eq!(store.recent_audit().await.unwrap().len(), 1);
@@ -394,6 +399,20 @@ mod tests {
let data = reopened.load().await.unwrap();
assert_eq!(data.revision, 1);
assert_eq!(data.config.vhosts[0].backends[0].id, "demo");
assert_eq!(
data.config.cache.memory_preset,
Some(crate::cache::MemoryPreset::Recommended)
);
assert_eq!(
data.config.cache.cold_preset,
Some(crate::cache::ColdPreset::Recommended)
);
assert_eq!(
data.config.transform.preset,
Some(crate::transform::Preset::Performance)
);
assert_eq!(data.config.transform.memory_budget_bytes, 512 * 1024 * 1024);
assert_eq!(data.config.transform.max_inflight, 64);
reopened.close().await;
}
#[tokio::test]
+2 -2
View File
@@ -40,7 +40,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy 0.24.5"
about = "RustProxy - independent Rust reverse proxy 0.24.7"
)]
struct Cli {
#[arg(
@@ -1426,7 +1426,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.24.5 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.24.7 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
+36 -1
View File
@@ -83,8 +83,23 @@ enum CacheInvalidation {
/// Keeping it stable across logging/TLS/admin saves lets the persistent cold
/// tier survive a restart after unrelated control-plane edits.
fn cache_revision(config: &config::Config) -> u64 {
// Preset fields are UI intent metadata. Persist them, but do not rotate the
// cache compatibility identity (and purge HOT/COLD) when only the label
// changes while the effective cache limits stay identical.
let mut cache = config.cache.clone();
cache.memory_preset = None;
cache.cold_preset = None;
// Resource-only transform controls decide how much optimization work may
// happen concurrently, but do not change the transformed representation.
// Keep those out of the cache identity while including representation and
// eligibility controls such as enabled kinds, extensions and max body.
let mut transform = config.transform.clone();
transform.preset = None;
transform.memory_budget_bytes = 0;
transform.max_inflight = 0;
let encoded = serde_json::to_vec(&(
&config.cache,
&cache,
&transform,
&config.headers,
&config.forwarding,
&config.vhosts,
@@ -605,6 +620,26 @@ mod cache_revision_tests {
});
assert_eq!(cache_revision(&docker), identity);
let mut preset = base.clone();
preset.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended);
preset.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended);
preset.transform.preset = Some(crate::transform::Preset::Uber);
preset.transform.memory_budget_bytes = 1024 * 1024 * 1024;
preset.transform.max_inflight = 128;
assert_eq!(cache_revision(&preset), identity);
assert!(matches!(
cache_invalidation(&base, &preset),
CacheInvalidation::None
));
let mut transform_policy = base.clone();
transform_policy.transform.json = !transform_policy.transform.json;
assert_ne!(cache_revision(&transform_policy), identity);
let mut transform_body_limit = base.clone();
transform_body_limit.transform.max_body_bytes += 1024;
assert_ne!(cache_revision(&transform_body_limit), identity);
let mut cache = base.clone();
cache.cache.max_entries += 1;
let cache_identity = cache_revision(&cache);
-180
View File
@@ -1,180 +0,0 @@
use super::{now_ms, platform_name, NetworkInterfaceView, View};
use std::{collections::BTreeMap, process::Command};
#[derive(Default)]
pub(crate) struct Counters {
cpu_total: Option<u64>,
cpu_idle: Option<u64>,
process_cpu_micros: Option<u64>,
}
fn command_output(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program).args(args).output().ok()?;
output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn sysctl(name: &str) -> Option<String> {
command_output("sysctl", &["-n", name])
}
fn sysctl_u64(names: &[&str]) -> Option<u64> {
names.iter().find_map(|name| sysctl(name)?.split_whitespace().next()?.parse().ok())
}
fn cpu_times() -> Option<(u64, u64)> {
let values: Vec<u64> = sysctl("kern.cp_time")?
.split_whitespace()
.filter_map(|value| value.parse().ok())
.collect();
if values.len() < 5 {
return None;
}
Some((values.iter().sum(), values[4]))
}
fn process_cpu_micros() -> Option<u64> {
let mut usage = std::mem::MaybeUninit::<libc::rusage>::zeroed();
// SAFETY: getrusage initializes the supplied rusage structure on success.
let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) };
if rc != 0 {
return None;
}
// SAFETY: rc == 0 guarantees the structure was initialized by libc.
let usage = unsafe { usage.assume_init() };
let user = u64::try_from(usage.ru_utime.tv_sec).ok()?.saturating_mul(1_000_000)
+ u64::try_from(usage.ru_utime.tv_usec).ok()?;
let system = u64::try_from(usage.ru_stime.tv_sec).ok()?.saturating_mul(1_000_000)
+ u64::try_from(usage.ru_stime.tv_usec).ok()?;
Some(user.saturating_add(system))
}
fn load_average() -> Option<[f64; 3]> {
let raw = sysctl("vm.loadavg")?;
let values: Vec<f64> = raw
.trim_matches(|c: char| c == '{' || c == '}' || c.is_whitespace())
.split_whitespace()
.filter_map(|value| value.parse().ok())
.collect();
(values.len() >= 3).then(|| [values[0], values[1], values[2]])
}
fn process_rss_bytes() -> Option<u64> {
let pid = std::process::id().to_string();
command_output("ps", &["-o", "rss=", "-p", &pid])?
.split_whitespace()
.next()?
.parse::<u64>()
.ok()
.map(|kib| kib.saturating_mul(1024))
}
fn network_interfaces() -> Vec<NetworkInterfaceView> {
let Some(text) = command_output("netstat", &["-ibn"]) else {
return Vec::new();
};
let mut lines = text.lines();
let Some(header) = lines.next() else {
return Vec::new();
};
let columns: Vec<_> = header.split_whitespace().collect();
let name_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Name"));
let ibytes_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Ibytes"));
let obytes_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Obytes"));
let (Some(name_index), Some(ibytes_index), Some(obytes_index)) =
(name_index, ibytes_index, obytes_index)
else {
return Vec::new();
};
let mut interfaces: BTreeMap<String, (u64, u64)> = BTreeMap::new();
for line in lines {
let fields: Vec<_> = line.split_whitespace().collect();
if fields.len() <= name_index || fields.len() <= ibytes_index || fields.len() <= obytes_index {
continue;
}
let name = fields[name_index].trim_end_matches('*');
if name.is_empty() || name == "lo0" {
continue;
}
let (Ok(rx), Ok(tx)) = (fields[ibytes_index].parse::<u64>(), fields[obytes_index].parse::<u64>()) else {
continue;
};
let entry = interfaces.entry(name.to_owned()).or_default();
entry.0 = entry.0.max(rx);
entry.1 = entry.1.max(tx);
}
interfaces
.into_iter()
.take(64)
.map(|(name, (rx_bytes, tx_bytes))| NetworkInterfaceView {
name,
rx_bytes,
tx_bytes,
rx_bytes_per_second: None,
tx_bytes_per_second: None,
})
.collect()
}
pub(crate) fn sample(previous: Option<(View, Counters)>) -> (View, Counters) {
let (cpu_total, cpu_idle) = cpu_times().map_or((None, None), |(total, idle)| (Some(total), Some(idle)));
let counters = Counters {
cpu_total,
cpu_idle,
process_cpu_micros: process_cpu_micros(),
};
let mut view = View {
source: "bsd_native",
platform: platform_name(),
sampled_at_ms: now_ms(),
logical_cpus: sysctl_u64(&["hw.ncpu", "hw.ncpuonline"])
.and_then(|value| usize::try_from(value).ok())
.or_else(|| std::thread::available_parallelism().ok().map(usize::from)),
memory_total_bytes: sysctl_u64(&["hw.physmem64", "hw.physmem"]),
process_rss_bytes: process_rss_bytes(),
load_average: load_average(),
network_interfaces: network_interfaces(),
..Default::default()
};
if !view.network_interfaces.is_empty() {
view.network_rx_bytes = Some(view.network_interfaces.iter().map(|item| item.rx_bytes).sum());
view.network_tx_bytes = Some(view.network_interfaces.iter().map(|item| item.tx_bytes).sum());
}
if let Some((old, old_counters)) = previous {
let system_total = counters
.cpu_total
.zip(old_counters.cpu_total)
.and_then(|(new, old)| new.checked_sub(old))
.filter(|value| *value > 0);
if let Some(total) = system_total {
view.cpu_percent = counters
.cpu_idle
.zip(old_counters.cpu_idle)
.and_then(|(new, old)| new.checked_sub(old))
.map(|idle| (100.0 * (1.0 - idle as f64 / total as f64)).clamp(0.0, 100.0));
}
let seconds = view.sampled_at_ms.saturating_sub(old.sampled_at_ms) as f64 / 1000.0;
if seconds > 0.0 {
view.process_cpu_percent = counters
.process_cpu_micros
.zip(old_counters.process_cpu_micros)
.and_then(|(new, old)| new.checked_sub(old))
.map(|micros| micros as f64 / (seconds * 10_000.0));
view.network_rx_bytes_per_second = view
.network_rx_bytes
.zip(old.network_rx_bytes)
.map(|(new, old)| new.saturating_sub(old) as f64 / seconds);
view.network_tx_bytes_per_second = view
.network_tx_bytes
.zip(old.network_tx_bytes)
.map(|(new, old)| new.saturating_sub(old) as f64 / seconds);
for interface in &mut view.network_interfaces {
if let Some(previous) = old.network_interfaces.iter().find(|old| old.name == interface.name) {
interface.rx_bytes_per_second = Some(interface.rx_bytes.saturating_sub(previous.rx_bytes) as f64 / seconds);
interface.tx_bytes_per_second = Some(interface.tx_bytes.saturating_sub(previous.tx_bytes) as f64 / seconds);
}
}
}
}
// Keep unknown/unsupported BSD metrics as None instead of substituting a different semantic.
(view, counters)
}
+11 -3
View File
@@ -6,12 +6,15 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
let mut chars = input.chars().peekable();
let mut quote: Option<char> = None;
let mut pending_space = false;
let mut last_char: Option<char> = None;
while let Some(ch) = chars.next() {
if let Some(q) = quote {
out.push(ch);
last_char = Some(ch);
if ch == '\\' {
if let Some(next) = chars.next() {
out.push(next);
last_char = Some(next);
}
} else if ch == q {
quote = None;
@@ -19,12 +22,14 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
continue;
}
if matches!(ch, '\'' | '"') {
if pending_space && needs_space(out.chars().last(), Some(ch)) {
if pending_space && needs_space(last_char, Some(ch)) {
out.push(' ');
last_char = Some(' ');
}
pending_space = false;
quote = Some(ch);
out.push(ch);
last_char = Some(ch);
continue;
}
if ch == '/' && chars.peek() == Some(&'*') {
@@ -43,14 +48,17 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
pending_space = true;
continue;
}
if pending_space && needs_space(out.chars().last(), Some(ch)) {
if pending_space && needs_space(last_char, Some(ch)) {
out.push(' ');
last_char = Some(' ');
}
pending_space = false;
if matches!(ch, '{' | '}' | ':' | ';' | ',' | '>') && out.ends_with(' ') {
if matches!(ch, '{' | '}' | ':' | ';' | ',' | '>') && last_char == Some(' ') {
out.pop();
last_char = out.chars().last();
}
out.push(ch);
last_char = Some(ch);
}
let trimmed_len = out.trim_end().len();
out.truncate(trimmed_len);
+32 -13
View File
@@ -1,5 +1,13 @@
use anyhow::{Context, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RawElement {
Script,
Style,
Textarea,
Pre,
}
/// Conservative HTML minification.
///
/// HTML whitespace can be semantically significant, especially between inline
@@ -11,13 +19,15 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
let bytes = input.as_bytes();
let mut out = String::with_capacity(input.len());
let mut cursor = 0usize;
let mut raw_element: Option<String> = None;
let mut raw_element: Option<RawElement> = None;
while cursor < bytes.len() {
if bytes[cursor] == b'<' {
if raw_element.is_none() && starts_with_ascii_ci(bytes, cursor, b"<!--[if") {
let Some(end) = find_bytes(bytes, cursor + 4, b"-->") else {
return Ok(body.to_vec());
out.clear();
out.push_str(input);
return Ok(out.into_bytes());
};
let end = end + 3;
out.push_str(&input[cursor..end]);
@@ -26,7 +36,9 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
}
if raw_element.is_none() && bytes[cursor..].starts_with(b"<!--") {
let Some(end) = find_bytes(bytes, cursor + 4, b"-->") else {
return Ok(body.to_vec());
out.clear();
out.push_str(input);
return Ok(out.into_bytes());
};
cursor = end + 3;
continue;
@@ -35,15 +47,12 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
if let Some(end) = find_tag_end(bytes, cursor + 1) {
let tag = &input[cursor..=end];
out.push_str(tag);
if let Some((name, closing, self_closing)) = tag_name(tag) {
if let Some((name, closing, self_closing)) = raw_tag(tag) {
if closing {
if raw_element.as_deref() == Some(name.as_str()) {
if raw_element == Some(name) {
raw_element = None;
}
} else if !self_closing
&& raw_element.is_none()
&& matches!(name.as_str(), "script" | "style" | "textarea" | "pre")
{
} else if !self_closing && raw_element.is_none() {
raw_element = Some(name);
}
}
@@ -91,7 +100,7 @@ fn find_tag_end(bytes: &[u8], mut cursor: usize) -> Option<usize> {
None
}
fn tag_name(tag: &str) -> Option<(String, bool, bool)> {
fn raw_tag(tag: &str) -> Option<(RawElement, bool, bool)> {
let inner = tag.strip_prefix('<')?.strip_suffix('>')?.trim();
if inner.starts_with('!') || inner.starts_with('?') {
return None;
@@ -101,9 +110,19 @@ fn tag_name(tag: &str) -> Option<(String, bool, bool)> {
let self_closing = inner.ends_with('/');
let name = inner
.split(|ch: char| ch.is_ascii_whitespace() || ch == '/' || ch == '>')
.next()?
.to_ascii_lowercase();
(!name.is_empty()).then_some((name, closing, self_closing))
.next()?;
let name = if name.eq_ignore_ascii_case("script") {
RawElement::Script
} else if name.eq_ignore_ascii_case("style") {
RawElement::Style
} else if name.eq_ignore_ascii_case("textarea") {
RawElement::Textarea
} else if name.eq_ignore_ascii_case("pre") {
RawElement::Pre
} else {
return None;
};
Some((name, closing, self_closing))
}
#[cfg(test)]
+50 -2
View File
@@ -1,6 +1,54 @@
use anyhow::{Context, Result};
use serde::{de::IgnoredAny, Deserialize};
/// Validate JSON without materializing a full `serde_json::Value`, then remove
/// insignificant ASCII whitespace in one pass. This keeps peak memory close to
/// input + output instead of input + parsed tree + serialized output.
pub fn compact(body: &[u8]) -> Result<Vec<u8>> {
let value: serde_json::Value = serde_json::from_slice(body).context("parse JSON")?;
serde_json::to_vec(&value).context("serialize compact JSON")
let mut deserializer = serde_json::Deserializer::from_slice(body);
IgnoredAny::deserialize(&mut deserializer).context("parse JSON")?;
deserializer.end().context("parse JSON trailing data")?;
let mut output = Vec::with_capacity(body.len());
let mut in_string = false;
let mut escaped = false;
for &byte in body {
if in_string {
output.push(byte);
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
in_string = false;
}
continue;
}
if byte == b'"' {
in_string = true;
output.push(byte);
} else if !matches!(byte, b' ' | b'\n' | b'\r' | b'\t') {
output.push(byte);
}
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compacts_without_rewriting_values_or_duplicate_keys() {
let input = br#"{ "n": 1.2300e+02, "s": "a b\\n c", "k": 1, "k": 2 }"#;
assert_eq!(
compact(input).unwrap(),
br#"{"n":1.2300e+02,"s":"a b\\n c","k":1,"k":2}"#
);
}
#[test]
fn rejects_invalid_json_instead_of_stripping_whitespace_blindly() {
assert!(compact(br#"{"a": 1,}"#).is_err());
}
}
+320 -14
View File
@@ -3,13 +3,16 @@
use anyhow::{ensure, Context as _, Result};
use axum::{body::Body, response::Response};
use http::{HeaderMap, HeaderValue};
use http_body_util::BodyExt;
use http_body_util::{BodyExt, Limited};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::io::Read;
use std::sync::Mutex;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Mutex,
};
use std::time::Duration;
mod css;
@@ -19,9 +22,23 @@ mod json;
mod svg;
const DEFAULT_MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
const DEFAULT_MEMORY_BUDGET_BYTES: usize = 128 * 1024 * 1024;
const DEFAULT_MAX_INFLIGHT: usize = 32;
const TRANSFORM_MEMORY_UNIT_BYTES: usize = 64 * 1024;
static TRANSFORM_MEMORY_IN_USE_BYTES: AtomicUsize = AtomicUsize::new(0);
static TRANSFORM_INFLIGHT: AtomicUsize = AtomicUsize::new(0);
#[cfg(all(target_os = "linux", target_env = "gnu"))]
const ALLOCATOR_TRIM_MIN_BODY_BYTES: usize = 256 * 1024;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Preset {
Conservative,
Recommended,
Performance,
Uber,
}
/// Large transformable responses can temporarily own the collected origin body,
/// a decoded buffer and the transformed output at the same time. On glibc, freed
/// pages may remain in malloc arenas for a long time. The systemd unit also caps
@@ -65,6 +82,10 @@ fn trim_allocator_after_large_transform(released_bytes: usize) {
pub struct Settings {
/// Global kill switch. Per-vhost opt-in is still required.
pub enabled: bool,
/// Last resource profile explicitly selected in the administrator UI. The
/// numeric limits below are authoritative; this field only preserves UI
/// intent across reloads.
pub preset: Option<Preset>,
pub javascript: bool,
pub css: bool,
pub html: bool,
@@ -75,11 +96,18 @@ pub struct Settings {
pub extensions: Vec<String>,
/// Hard aggregation/decode ceiling for transformable static responses.
pub max_body_bytes: usize,
/// Process-wide ceiling for estimated temporary response buffers owned by
/// active transformations. This is a limit, not a pre-allocation.
pub memory_budget_bytes: usize,
/// Process-wide number of transformation attempts allowed to own an active
/// transform slot at the same time. Excess work fails open unchanged.
pub max_inflight: usize,
}
impl Default for Settings {
fn default() -> Self {
Self {
enabled: false,
preset: None,
javascript: true,
css: true,
html: false,
@@ -92,6 +120,8 @@ impl Default for Settings {
.map(str::to_owned)
.collect(),
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
max_inflight: DEFAULT_MAX_INFLIGHT,
}
}
}
@@ -101,6 +131,14 @@ impl Settings {
(1024..=32 * 1024 * 1024).contains(&self.max_body_bytes),
"transform.max_body_bytes must be 1 KiB..32 MiB"
);
ensure!(
(1024 * 1024..=2 * 1024 * 1024 * 1024).contains(&self.memory_budget_bytes),
"transform.memory_budget_bytes must be 1 MiB..2 GiB"
);
ensure!(
(1..=1024).contains(&self.max_inflight),
"transform.max_inflight must be 1..1024"
);
ensure!(self.extensions.len() <= 64, "at most 64 transform extensions");
let mut seen = std::collections::HashSet::new();
for extension in &self.extensions {
@@ -296,6 +334,24 @@ pub struct MetricsView {
pub scopes: Vec<ScopeCounters>,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct ResourceUsage {
/// Estimated temporary transform bytes currently reserved by active work.
pub memory_reserved_bytes: usize,
pub memory_budget_bytes: usize,
pub inflight: usize,
pub max_inflight: usize,
}
pub fn resource_usage(settings: &Settings) -> ResourceUsage {
ResourceUsage {
memory_reserved_bytes: TRANSFORM_MEMORY_IN_USE_BYTES.load(Ordering::Acquire),
memory_budget_bytes: settings.memory_budget_bytes,
inflight: TRANSFORM_INFLIGHT.load(Ordering::Acquire),
max_inflight: settings.max_inflight,
}
}
#[derive(Default)]
struct MetricsStore {
total: Counters,
@@ -505,14 +561,27 @@ fn supported_encoding(headers: &HeaderMap) -> Option<Option<&'static str>> {
}
fn decode<'a>(body: &'a [u8], encoding: Option<&str>, limit: usize) -> Result<Cow<'a, [u8]>> {
fn read_bounded(reader: impl Read, limit: usize, label: &'static str) -> Result<Vec<u8>> {
fn read_bounded(mut reader: impl Read, limit: usize, label: &'static str) -> Result<Vec<u8>> {
let mut output = Vec::with_capacity(limit.min(64 * 1024));
let mut bounded = reader.take(limit.saturating_add(1) as u64);
bounded.read_to_end(&mut output).with_context(|| format!("decode {label}"))?;
ensure!(
output.len() <= limit,
"decoded transform body exceeds configured limit"
);
let mut chunk = [0u8; 16 * 1024];
loop {
let read = reader
.read(&mut chunk)
.with_context(|| format!("decode {label}"))?;
if read == 0 {
break;
}
ensure!(
output.len().saturating_add(read) <= limit,
"decoded transform body exceeds configured limit"
);
if output.capacity().saturating_sub(output.len()) < read {
output
.try_reserve_exact(read)
.map_err(|_| anyhow::anyhow!("decoded transform allocation failed"))?;
}
output.extend_from_slice(&chunk[..read]);
}
Ok(output)
}
@@ -588,15 +657,123 @@ fn drop_vary_accept_encoding(headers: &mut HeaderMap) {
fn transformed_etag(body: &[u8]) -> HeaderValue {
let digest = Sha256::digest(body);
let mut hex = String::with_capacity(64);
let mut value = String::with_capacity(76);
value.push_str("\"rustproxy-");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(&mut hex, "{byte:02x}");
let _ = write!(&mut value, "{byte:02x}");
}
HeaderValue::from_str(&format!("\"rustproxy-{hex}\""))
value.push('"');
HeaderValue::from_str(&value)
.expect("sha256 validator is always a valid header")
}
fn transform_memory_required_bytes(
content_length: usize,
encoding: Option<&str>,
max_body_bytes: usize,
) -> Option<usize> {
let estimated = if encoding.is_none() {
content_length.checked_mul(2)?
} else {
content_length.checked_add(max_body_bytes.checked_mul(2)?)?
};
let units = estimated.checked_add(TRANSFORM_MEMORY_UNIT_BYTES - 1)?
/ TRANSFORM_MEMORY_UNIT_BYTES;
units.max(1).checked_mul(TRANSFORM_MEMORY_UNIT_BYTES)
}
struct TransformMemoryReservation<'a> {
counter: &'a AtomicUsize,
bytes: usize,
}
impl Drop for TransformMemoryReservation<'_> {
fn drop(&mut self) {
self.counter.fetch_sub(self.bytes, Ordering::AcqRel);
}
}
fn try_reserve_memory_from(
counter: &AtomicUsize,
required_bytes: usize,
budget_bytes: usize,
) -> Option<TransformMemoryReservation<'_>> {
if required_bytes > budget_bytes {
return None;
}
let mut current = counter.load(Ordering::Acquire);
loop {
let next = current.checked_add(required_bytes)?;
if next > budget_bytes {
return None;
}
match counter.compare_exchange_weak(
current,
next,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => {
return Some(TransformMemoryReservation {
counter,
bytes: required_bytes,
})
}
Err(actual) => current = actual,
}
}
}
fn try_reserve_transform_memory(
required_bytes: usize,
budget_bytes: usize,
) -> Option<TransformMemoryReservation<'static>> {
try_reserve_memory_from(
&TRANSFORM_MEMORY_IN_USE_BYTES,
required_bytes,
budget_bytes,
)
}
struct TransformInflightReservation<'a> {
counter: &'a AtomicUsize,
}
impl Drop for TransformInflightReservation<'_> {
fn drop(&mut self) {
self.counter.fetch_sub(1, Ordering::AcqRel);
}
}
fn try_reserve_slot_from(
counter: &AtomicUsize,
max_inflight: usize,
) -> Option<TransformInflightReservation<'_>> {
let mut current = counter.load(Ordering::Acquire);
loop {
if current >= max_inflight {
return None;
}
let next = current.checked_add(1)?;
match counter.compare_exchange_weak(
current,
next,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => return Some(TransformInflightReservation { counter }),
Err(actual) => current = actual,
}
}
}
fn try_reserve_transform_slot(
max_inflight: usize,
) -> Option<TransformInflightReservation<'static>> {
try_reserve_slot_from(&TRANSFORM_INFLIGHT, max_inflight)
}
/// Transform an eligible response. The call is fail-open for unsupported media,
/// encodings and malformed minifiable content; transport/body collection errors
/// still propagate because the original stream can no longer be replayed safely.
@@ -651,12 +828,33 @@ pub async fn apply(
if content_length > settings.max_body_bytes {
return Ok(skip(response, Some(kind), "too_large"));
}
let Some(required_memory) =
transform_memory_required_bytes(content_length, encoding, settings.max_body_bytes)
else {
return Ok(skip(response, Some(kind), "memory_budget"));
};
let Some(_inflight_slot) = try_reserve_transform_slot(settings.max_inflight) else {
return Ok(skip(response, Some(kind), "concurrency_pressure"));
};
let Some(_memory_reservation) =
try_reserve_transform_memory(required_memory, settings.memory_budget_bytes)
else {
// Transformations are an optimization. Under concurrent allocation
// pressure, preserve streaming/origin bytes instead of queueing more
// full-response buffers behind the memory budget.
return Ok(skip(response, Some(kind), "memory_pressure"));
};
let (mut parts, body) = response.into_parts();
let raw = body
// Do not trust Content-Length as a memory bound. A broken or malicious
// upstream can send more bytes than declared; Limited stops aggregation at
// the configured ceiling instead of letting BodyExt::collect grow without
// bound. Once aggregation starts an over-limit stream cannot be replayed
// safely, so it is treated like the other body/transport collection errors.
let raw = Limited::new(body, settings.max_body_bytes)
.collect()
.await
.context("collect static response for transformation")?
.context("collect bounded static response for transformation")?
.to_bytes();
if raw.len() > settings.max_body_bytes {
let mut response = Response::from_parts(parts, Body::from(raw));
@@ -751,6 +949,114 @@ mod tests {
assert!(matches!(decoded, Cow::Borrowed(_)));
}
#[test]
fn transform_memory_budget_accounts_for_identity_and_decoded_buffers() {
assert_eq!(
transform_memory_required_bytes(2 * 1024 * 1024, None, 2 * 1024 * 1024),
Some(4 * 1024 * 1024)
);
assert_eq!(
transform_memory_required_bytes(
2 * 1024 * 1024,
Some("gzip"),
2 * 1024 * 1024
),
Some(6 * 1024 * 1024)
);
assert_eq!(
transform_memory_required_bytes(
DEFAULT_MEMORY_BUDGET_BYTES,
Some("gzip"),
DEFAULT_MEMORY_BUDGET_BYTES
),
Some(3 * DEFAULT_MEMORY_BUDGET_BYTES)
);
}
#[test]
fn transform_memory_reservation_obeys_runtime_budget() {
let counter = AtomicUsize::new(0);
let required = 4 * 1024 * 1024;
let reservation = try_reserve_memory_from(&counter, required, required).unwrap();
assert!(try_reserve_memory_from(&counter, required, required).is_none());
drop(reservation);
assert!(try_reserve_memory_from(&counter, required, required).is_some());
}
#[test]
fn transform_inflight_reservation_obeys_runtime_limit() {
let counter = AtomicUsize::new(0);
let slot = try_reserve_slot_from(&counter, 1).unwrap();
assert!(try_reserve_slot_from(&counter, 1).is_none());
drop(slot);
assert!(try_reserve_slot_from(&counter, 1).is_some());
}
#[test]
fn resource_usage_exposes_configured_limits() {
let settings = Settings {
memory_budget_bytes: 512 * 1024 * 1024,
max_inflight: 64,
..Default::default()
};
let usage = resource_usage(&settings);
assert_eq!(usage.memory_budget_bytes, 512 * 1024 * 1024);
assert_eq!(usage.max_inflight, 64);
}
#[test]
fn resource_preset_metadata_round_trips() {
let mut settings = Settings::default();
settings.preset = Some(Preset::Uber);
settings.memory_budget_bytes = 1024 * 1024 * 1024;
settings.max_body_bytes = 32 * 1024 * 1024;
settings.max_inflight = 128;
let encoded = serde_json::to_value(&settings).unwrap();
assert_eq!(encoded["preset"], "uber");
let decoded: Settings = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded.preset, Some(Preset::Uber));
assert_eq!(decoded.memory_budget_bytes, 1024 * 1024 * 1024);
assert_eq!(decoded.max_inflight, 128);
}
#[test]
fn legacy_transform_settings_receive_resource_defaults() {
let decoded: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap();
assert!(decoded.enabled);
assert_eq!(decoded.preset, None);
assert_eq!(decoded.memory_budget_bytes, DEFAULT_MEMORY_BUDGET_BYTES);
assert_eq!(decoded.max_body_bytes, DEFAULT_MAX_BODY_BYTES);
assert_eq!(decoded.max_inflight, DEFAULT_MAX_INFLIGHT);
}
#[test]
fn bounded_decoder_rejects_expansion_past_limit() {
use std::io::Write as _;
let identity = vec![b'x'; 128 * 1024];
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&identity).unwrap();
let encoded = encoder.finish().unwrap();
assert!(decode(&encoded, Some("gzip"), 64 * 1024).is_err());
}
#[tokio::test]
async fn aggregation_is_bounded_even_when_content_length_is_false() {
let settings = Settings {
enabled: true,
json: true,
max_body_bytes: 1024,
..Default::default()
};
let mut response = Response::new(Body::from(vec![b' '; 2048]));
response
.headers_mut()
.insert("content-type", HeaderValue::from_static("application/json"));
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("128"));
assert!(apply(response, "/data.json", &settings, false).await.is_err());
}
#[test]
fn metrics_track_applied_skipped_and_cached_transformed_responses() {
let metrics = Metrics::default();
+6 -2
View File
@@ -12,7 +12,9 @@ pub fn optimize(body: &[u8]) -> Result<Vec<u8>> {
while cursor < bytes.len() {
if bytes[cursor..].starts_with(b"<![CDATA[") {
let Some(end) = find_bytes(bytes, cursor + 9, b"]]>") else {
return Ok(body.to_vec());
out.clear();
out.push_str(input);
return Ok(out.into_bytes());
};
let end = end + 3;
out.push_str(&input[cursor..end]);
@@ -21,7 +23,9 @@ pub fn optimize(body: &[u8]) -> Result<Vec<u8>> {
}
if bytes[cursor..].starts_with(b"<!--") {
let Some(end) = find_bytes(bytes, cursor + 4, b"-->") else {
return Ok(body.to_vec());
out.clear();
out.push_str(input);
return Ok(out.into_bytes());
};
cursor = end + 3;
continue;