Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 63 additions & 8 deletions bin/hyperion-web/src/handlers/hostings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1802,6 +1802,8 @@ pub struct PhpMemAutoView {
pub raised_from_mb: Option<i64>,
/// 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 {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
40 changes: 38 additions & 2 deletions bin/hyperion-web/templates/hostings_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -2771,16 +2771,52 @@ <h2>
<div class="field-row">
<div class="field">
<label class="field-checkbox" style="margin-top:1.6rem">
<input type="checkbox" name="mem_auto" value="on"{% if mem_auto.enabled %} checked{% endif %}>
<input id="mem_auto" type="checkbox" name="mem_auto" value="on"{% if mem_auto.enabled %} checked{% endif %}>
<span>Raise PHP memory automatically when the site runs out <span class="hint">— +128 MB per step, back down after 2 quiet weeks</span></span>
</label>
</div>
<div class="field">
<label for="mem_auto_max_mb">Automatic ceiling <span class="hint">MB</span></label>
<input id="mem_auto_max_mb" name="mem_auto_max_mb" type="number" min="16" max="8192"
value="{{ mem_auto.max_mb }}">
value="{{ mem_auto.max_mb }}"
data-shown-mb="{{ limits.php_memory_mb }}"
data-floor-mb="{{ mem_auto.floor_mb }}">
</div>
</div>
{# 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. #}
<p id="mem_auto_warn" class="note text-warn" style="margin:0 0 0.8rem"{% if !mem_auto.no_room() %} hidden{% endif %}>
The ceiling must be above the site's own PHP memory (<span data-floor>{{ mem_auto.floor_mb }}</span> MB) —
at or under it the automation has nothing to raise to and only sends a daily
"still runs out at its ceiling" alert.
</p>
<script>
(function () {
'use strict';
const on = document.getElementById('mem_auto');
const max = document.getElementById('mem_auto_max_mb');
const mem = document.getElementById('php_memory_mb');
const warn = document.getElementById('mem_auto_warn');
if (!on || !max || !mem || !warn) return;
function check() {
const typed = parseInt(mem.value, 10);
const floor = String(typed) === max.dataset.shownMb
? parseInt(max.dataset.floorMb, 10)
: typed;
const ceiling = parseInt(max.value, 10);
const bad = on.checked && Number.isFinite(floor) && Number.isFinite(ceiling) && ceiling <= floor;
warn.querySelector('[data-floor]').textContent = Number.isFinite(floor) ? floor : '?';
warn.hidden = !bad;
}
[on, max, mem].forEach(function (el) {
el.addEventListener('input', check);
el.addEventListener('change', check);
});
check();
})();
</script>
{% if mem_auto.enabled %}
<p class="note" style="margin:0 0 0.8rem">
{% 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 %}
Expand Down
45 changes: 45 additions & 0 deletions crates/hyperion-types/src/phpmem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>, 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<i64> {
Expand Down Expand Up @@ -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 { .. }
));
}
}