diff --git a/bin/hyperion-web/src/handlers/hostings.rs b/bin/hyperion-web/src/handlers/hostings.rs index c30f4cf6..ee5d5bc3 100644 --- a/bin/hyperion-web/src/handlers/hostings.rs +++ b/bin/hyperion-web/src/handlers/hostings.rs @@ -1802,6 +1802,8 @@ pub struct PhpMemAutoView { pub raised_from_mb: Option, /// Last out-of-memory fatal seen; 0 = none since it was turned on. pub last_oom_at: i64, + /// The operator's own limit — what the ceiling must be above. + pub floor_mb: i64, } impl PhpMemAutoView { @@ -1814,18 +1816,25 @@ impl PhpMemAutoView { .map(|(_, v)| v.as_str()) }; let st = get(phpmem::KV_STATE).and_then(phpmem::State::parse); + let raised_from_mb = st + .as_ref() + .map(|s| s.base_mb) + .filter(|&b| b > 0 && b < current_mb); PhpMemAutoView { enabled: get(phpmem::KV_ENABLED) == Some("on"), max_mb: get(phpmem::KV_MAX_MB) .and_then(|v| v.trim().parse().ok()) .unwrap_or(phpmem::DEFAULT_MAX_MB), - raised_from_mb: st - .as_ref() - .map(|s| s.base_mb) - .filter(|&b| b > 0 && b < current_mb), + raised_from_mb, last_oom_at: st.map(|s| s.last_oom_at).unwrap_or(0), + floor_mb: raised_from_mb.unwrap_or(current_mb), } } + + /// Switched on with a ceiling that leaves nothing to raise to. + pub fn no_room(&self) -> bool { + self.enabled && hyperion_types::phpmem::ceiling_leaves_no_room(self.max_mb, self.floor_mb) + } } /// Operator-tunable php.ini settings, applied via a per-hosting @@ -5074,6 +5083,56 @@ pub async fn post_set_limits( }; let mem_auto_on = form.mem_auto.trim() == "on"; let target = node_target(&form.target_node); + let hosting_id = find_hosting_anywhere(&state, sel.clone()) + .await? + .0 + .id + .as_str() + .to_string(); + if mem_auto_on { + // A ceiling at or under the operator's own value gives the + // automation nothing to raise to — every out-of-memory would end as + // a daily "stuck at the ceiling" alert. Refuse it before anything is + // written. The floor is decided the way set_limits will decide it, + // from what the OWNING node has stored. + use hyperion_types::phpmem; + let stored = fetch_limits(&state, target, sel.clone()) + .await + .ok() + .map(|l| l.php_memory_mb); + let st = match crate::dispatcher::dispatch_to_node( + &state, + target, + Request::HostingKvList { + hosting_id: hosting_id.clone(), + }, + ) + .await + { + Ok(RpcResponse::HostingKvList(v)) => v + .into_iter() + .find(|(k, _)| k == phpmem::KV_STATE) + .and_then(|(_, v)| phpmem::State::parse(&v)), + _ => None, + }; + let floor = phpmem::floor_after_save(form.php_memory_mb, stored, st.as_ref()); + if phpmem::ceiling_leaves_no_room(mem_auto_max, floor) { + let msg = format!( + "Automatic ceiling ({mem_auto_max} MB) must be above the site's own PHP memory \ + ({floor} MB), or there is nothing to raise to." + ); + return Ok(save_result( + &headers, + false, + &msg, + format!( + "/hostings/{}?flash_error={}#limits", + urlencoding(&form.selector), + urlencoding(&msg) + ), + )); + } + } let resp = crate::dispatcher::dispatch_to_node( &state, target, @@ -5088,10 +5147,6 @@ pub async fn post_set_limits( // hosting_kv, beside the state its tick keeps — the pool it rewrites // is there. Written after the limits, so a refused save above leaves // them as they were. - let hosting_id = match find_hosting_anywhere(&state, sel).await { - Ok((d, _)) => d.id.as_str().to_string(), - Err(e) => return Err(e), - }; for (key, value) in [ ( hyperion_types::phpmem::KV_ENABLED, diff --git a/bin/hyperion-web/templates/hostings_detail.html b/bin/hyperion-web/templates/hostings_detail.html index 1d9f87fd..90df121c 100644 --- a/bin/hyperion-web/templates/hostings_detail.html +++ b/bin/hyperion-web/templates/hostings_detail.html @@ -2771,16 +2771,52 @@

+ value="{{ mem_auto.max_mb }}" + data-shown-mb="{{ limits.php_memory_mb }}" + data-floor-mb="{{ mem_auto.floor_mb }}">
+ {# Ceiling at or under the site's own value = nothing to raise to; the + save refuses it. Rendered for a stored bad pair, kept live by the + script below while the fields are edited. Floor mirrors set_limits: + the shown (possibly raised) value resubmitted keeps the old floor. #} +

+ The ceiling must be above the site's own PHP memory ({{ mem_auto.floor_mb }} MB) — + at or under it the automation has nothing to raise to and only sends a daily + "still runs out at its ceiling" alert. +

+ {% if mem_auto.enabled %}

{% if let Some(from) = mem_auto.raised_from_mb %}Raised automatically from your {{ from }} MB to {{ limits.php_memory_mb }} MB.{% else %}At your own value.{% endif %} diff --git a/crates/hyperion-types/src/phpmem.rs b/crates/hyperion-types/src/phpmem.rs index 1108e790..5bdfdfd9 100644 --- a/crates/hyperion-types/src/phpmem.rs +++ b/crates/hyperion-types/src/phpmem.rs @@ -180,6 +180,24 @@ pub fn decide( Step::Nothing } +/// The operator's own `memory_limit` once a limits-card save lands — the +/// value the automation steps back down to, so the one its ceiling must be +/// above. Mirrors `set_limits`: a value different from the stored one becomes +/// the new floor; resubmitting the stored (possibly raised) value keeps the +/// floor already in `st`. +pub fn floor_after_save(typed_mb: i64, stored_mb: Option, st: Option<&State>) -> i64 { + match (stored_mb, st) { + (Some(stored), Some(s)) if typed_mb == stored && s.base_mb > 0 => s.base_mb, + _ => typed_mb, + } +} + +/// A ceiling at or under the floor leaves the automation nothing to raise +/// to: every out-of-memory ends as a "stuck at the ceiling" alert. +pub fn ceiling_leaves_no_room(ceiling_mb: i64, floor_mb: i64) -> bool { + ceiling_mb <= floor_mb +} + /// Per-worker memory the node can afford: three quarters of its RAM shared /// by every worker the pool may run at once. pub fn ram_cap_mb(mem_total_mb: i64, max_children: i64) -> Option { @@ -359,4 +377,31 @@ mod tests { assert_eq!(State::parse(r#"{"base_mb":128}"#), Some(st(128))); assert_eq!(State::parse("garbage"), None); } + + #[test] + fn floor_after_save_follows_set_limits() { + // Raised 512 → 640; the card resubmits 640 unchanged: floor stays 512. + assert_eq!(floor_after_save(640, Some(640), Some(&st(512))), 512); + // A new value typed by hand is the new floor. + assert_eq!(floor_after_save(1024, Some(640), Some(&st(512))), 1024); + // No state yet (never on) or no stored row: the typed value. + assert_eq!(floor_after_save(1024, Some(1024), None), 1024); + assert_eq!(floor_after_save(256, None, Some(&st(512))), 256); + assert_eq!(floor_after_save(256, Some(256), Some(&st(0))), 256); + } + + #[test] + fn a_ceiling_at_or_under_the_floor_leaves_no_room() { + // The reported case: 1024 MB of its own, ceiling 512. + assert!(ceiling_leaves_no_room(512, 1024)); + assert!(ceiling_leaves_no_room(1024, 1024)); + assert!(!ceiling_leaves_no_room(1152, 1024)); + // And decide() agrees: an OOM there only ever reports Capped. + let mut s = st(1024); + s.last_oom_at = 1_000_000; + assert!(matches!( + decide(1_000_000, 1024, 512, None, &s), + Step::Capped { .. } + )); + } }