simpler check and deps messages

This commit is contained in:
2026-09-17 15:01:48 -03:00
parent 1dc9d38c80
commit 565cecfb50
49 changed files with 1442 additions and 1426 deletions

View File

@@ -1,32 +1,8 @@
#!/usr/bin/env bash
# rig:standalone rigmini status
# How much memory this machine will actually give you before something dies —
# rig's memory tool, and (generated from this file) the standalone rigmini.sh.
#
# There are two numbers and they are rarely the same. `status` reports what the
# machine ADVERTISES and what is quietly capping it. `push` finds what it will
# SURVIVE, by allocating until it stops. `all` does both and weighs the result
# against what this profile's cluster needs.
#
# The gap between them is the whole reason this exists. Under WSL the cap lives
# in .wslconfig; in a container or a managed workspace it is a cgroup limit, and
# there /proc/meminfo reports the HOST's memory while the kernel kills you at a
# fraction of it. A script that only read MemTotal would confidently report 32 GB
# on a box that OOMs at 2.
#
# Runs on native Linux and under WSL. On WSL the memory you see is a VM
# allocation that can be raised, and the commonest failure is raising it without
# restarting — so status compares what .wslconfig says with what actually booted.
#
# Reports and instructs. It never raises a limit, frees anything or installs a
# package. The one write it can make is `backup`, which copies .wslconfig beside
# itself, so that `restore` has something to put back after a hand edit.
#
# Usage:
# mem.sh status what it has, what caps it
# mem.sh push [--to GB] [--to-oom] climb until it stops
# mem.sh all [--budget GB] both, then the verdict
# mem.sh backup | restore .wslconfig, WSL only
# rig's memory tool (also generated as rigmini.sh): what the machine advertises vs. what it survives.
# Usage: mem.sh status | push [--to GB] [--to-oom] | all [--budget GB] | backup | restore (WSL)
# Notes: docs/notes/mem.md
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
@@ -42,9 +18,7 @@ BUDGET_EXPLICIT=no # whether --budget was given, which retires the guess belo
# ── platform ───────────────────────────────────────────────────────────────
# Windows outside WSL — Git Bash, MSYS, Cygwin — looks close enough to work and
# then fails in a pile of confusing ways: no /proc, no docker socket, none of
# the tooling. Detectable, so name it instead.
# Refuse Git Bash / MSYS / Cygwin and kernels without /proc, with a clear message.
require_linux() {
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
@@ -99,9 +73,7 @@ avail_meminfo_mb() {
fi
}
# Where a cgroup records this cgroup's own limit and usage. Set once by
# find_cgroup, because every later reading needs both and hunting for the files
# on each call would be the slow part of the poll loop.
# This cgroup's limit/usage files, set once by find_cgroup (cheap for the poll loop).
CG_MAX_FILE=""
CG_CUR_FILE=""
CG_VERSION=""
@@ -109,10 +81,8 @@ CG_VERSION=""
find_cgroup() {
local rel
# Inside a container the cgroup namespace makes the top of the tree BE the
# container's own cgroup, so the unqualified path is already the right one.
# On a host it is the root cgroup, which is never limited — hence the second
# attempt via /proc/self/cgroup, which names the slice this shell is in.
# Top of tree first (right inside a container), then this shell's own slice
# from /proc/self/cgroup (right on a host).
if [ -r /sys/fs/cgroup/memory.max ]; then
CG_VERSION=v2
CG_MAX_FILE=/sys/fs/cgroup/memory.max
@@ -141,10 +111,7 @@ find_cgroup() {
return 0
}
# The cap in MB, or "" when there is none worth reporting. v2 spells unlimited
# "max"; v1 spells it as a number near 2^63, which is why this compares against
# MemTotal rather than testing for a magic value — a "limit" above the machine's
# own memory is not a limit, however it is written.
# The cap in MB, or "" when unlimited ("max", or any value >= MemTotal).
cgroup_cap_mb() {
local raw cap
[ -n "$CG_MAX_FILE" ] && [ -r "$CG_MAX_FILE" ] || { echo ""; return 0; }
@@ -185,10 +152,7 @@ effective_ceiling_mb() {
echo "$c"
}
# How much room is left RIGHT NOW, from whichever accounting actually governs.
# In a capped container /proc/meminfo describes the host and is worse than
# useless for this — it would report tens of gigabytes free on a box that is one
# allocation from being killed.
# Room left right now: cgroup cap minus usage when capped, else MemAvailable.
headroom_mb() {
local cap used
cap=$(cgroup_cap_mb)
@@ -202,9 +166,7 @@ headroom_mb() {
# ── status ─────────────────────────────────────────────────────────────────
# /mnt/c/Users can hold several real accounts — a renamed login leaves the old
# directory behind — so picking the first alphabetically is a coin toss. Ask
# Windows, then fall back to whichever profile actually owns a config.
# Ask Windows for %USERPROFILE%; fall back to whichever profile owns a .wslconfig.
wslconfig_path() {
local profile winpath found
profile=$(cmd.exe /c "echo %USERPROFILE%" 2>/dev/null | tr -d "\r\n" || true)
@@ -260,9 +222,7 @@ status() {
echo " ulimit -v unlimited"
fi
# overcommit_memory=0 is the default heuristic: a large allocation is
# granted on a guess, and the reckoning arrives later as an OOM kill rather
# than as a failed malloc. It is why `push` touches every page it asks for.
# Overcommit mode decides whether limits show as failed mallocs or OOM kills.
local om or_
om=$(cat /proc/sys/vm/overcommit_memory 2>/dev/null || echo '?')
or_=$(cat /proc/sys/vm/overcommit_ratio 2>/dev/null || echo '?')
@@ -335,9 +295,7 @@ status() {
echo " ! docker cli present but the daemon is unreachable"
fi
# WSL keeps its cap on the Windows side, in a file this shell can read but
# not usefully apply — the change costs a full VM restart. Report it, and
# report the commonest mistake, which is editing it and not restarting.
# WSL: report the .wslconfig cap and whether it was applied (needs wsl --shutdown).
if is_wsl; then
local cfg conf conf_mb n
cfg=$(wslconfig_path)
@@ -397,7 +355,7 @@ require_wsl() {
# backup and restore act on the file, so unlike status they must not guess.
wslconfig_required() {
local cfg; cfg=$(wslconfig_required)
local cfg; cfg=$(wslconfig_path)
if [ -z "$cfg" ]; then
echo "cannot tell which Windows profile owns .wslconfig. Candidates:" >&2
ls -d /mnt/c/Users/*/ 2>/dev/null \
@@ -429,8 +387,7 @@ backup() {
local cfg dest
cfg=$(wslconfig_required)
[ -r "$cfg" ] || { echo "nothing to back up: $cfg does not exist" >&2; exit 1; }
# Timestamped and never overwritten: a backup that can destroy itself on a
# second run is not a backup.
# Timestamped, never overwritten.
dest="${cfg}.$(date +%Y%m%d-%H%M%S).bak"
cp "$cfg" "$dest"
echo "backed up $dest"
@@ -449,9 +406,7 @@ restore() {
echo " -> $cfg"
echo
# Newest is the right default — undo the last edit — but if you backed up
# *after* editing, the state you want is older. Show the rest so a no-op
# restore is obviously a no-op rather than a mystery.
# Restores the newest; list the others in case an older one is wanted.
count=$(ls "$cfg".*.bak 2>/dev/null | wc -l)
if [ "$count" -gt 1 ]; then
echo "$count backups exist, newest first:"
@@ -495,14 +450,9 @@ cleanup() {
return 0
}
# The child allocates and stops itself; the parent only watches. That split is
# the point: under --to-oom the allocating process is expected to be killed, and
# something has to survive to say how far it got.
# Runs as a child that may be OOM-killed; the parent survives to report.
allocator() {
# Raise our own OOM score to the maximum so the kernel picks THIS process
# first. Raising needs no privilege (only lowering does). Without it, the
# kernel is free to choose your shell, your ssh session or dockerd — on a
# box you are still using, that is not an acceptable coin toss.
# Make this process the preferred OOM victim (raising needs no privilege).
echo 1000 > "/proc/$BASHPID/oom_score_adj" 2>/dev/null || true
local arr=() held=0 i=0 rss swapped avail first_swap=0
@@ -511,16 +461,7 @@ allocator() {
swap_used_start=$(( $(mb SwapTotal) - $(mb SwapFree) ))
while :; do
# Written STRAIGHT INTO the array element. The obvious spelling —
# build one chunk and `arr+=("$chunk")` — costs three copies per step,
# not one: the template stays resident, expanding "$chunk" makes a
# temporary word, and the append makes the element. A 128 MB step then
# needs 384 MB transiently, and on a small box it is killed on the
# first append while reporting a third of the true ceiling.
#
# printf -v into a subscript also means every page is written, so it is
# resident rather than merely promised — the only kind of allocation
# that measures anything under heuristic overcommit.
# Write straight into the element (one copy, not three) and touch every page.
printf -v "arr[$i]" '%*s' "$bytes" ''
i=$((i + 1)); held=$((held + STEP_MB))
@@ -533,9 +474,7 @@ allocator() {
"$held" "$rss" "$avail" "$swapped"
printf '%s %s %s %s\n' "$held" "$rss" "$avail" "$swapped" >> "$STATE"
# Worth calling out separately from the ceiling: this is where the box
# stops being fast and starts being unusable, which for a scheduler is
# a different and earlier problem than being killed.
# First swap is reported separately: slow comes before killed.
if [ "$swapped" -gt 0 ] && [ "$first_swap" -eq 0 ]; then
first_swap=$held
echo " - first swap page at ${held} MB — past here it works but crawls"
@@ -556,30 +495,20 @@ push() {
total=$(mb MemTotal)
ceiling=$(effective_ceiling_mb)
# A step is worth about a sixty-fourth of the ceiling: enough resolution to
# find the edge, few enough lines to read, and small enough that the
# transient cost of one allocation never dominates a small box. A fixed
# size cannot do all three — 128 MB is fine on 16 GB and absurd on 512 MB.
# Default step: ceiling/64, clamped to 4..256 MB.
if [ "$STEP_EXPLICIT" = no ]; then
STEP_MB=$(( ceiling / 64 ))
[ "$STEP_MB" -lt 4 ] && STEP_MB=4
[ "$STEP_MB" -gt 256 ] && STEP_MB=256
fi
# Stop with a cushion rather than riding it to the kill. How big a cushion
# depends on what it is protecting. Under a cgroup cap, running out kills
# only this container's own processes, so it need cover no more than the
# shell that prints the result — and a 512 MB cushion on a 1 GB box would
# halve the answer. On a host there is everything else to protect, and the
# OOM killer does not promise to pick the process that caused the problem.
# Stop with a cushion: 64 MB under a cgroup cap, 512 MB on a host, or 5% of ceiling if larger.
if [ -n "$(cgroup_cap_mb)" ]; then FLOOR_MB=64; else FLOOR_MB=512; fi
[ $(( ceiling / 20 )) -gt "$FLOOR_MB" ] && FLOOR_MB=$(( ceiling / 20 ))
STATE=$(mktemp "${TMPDIR:-/tmp}/rigmini.XXXXXX")
trap cleanup EXIT
# INT kills the child and lets the summary below print anyway, so an
# impatient Ctrl-C still tells you how far it got — and, more importantly,
# still gives the memory back.
# Ctrl-C kills the child, frees the memory, and still prints the summary.
trap 'echo; echo " interrupted"; echo "stop interrupted" >> "$STATE"; [ -n "$CHILD" ] && kill -KILL "$CHILD" 2>/dev/null || true' INT
echo "push"
@@ -652,11 +581,7 @@ push() {
fi ;;
esac
# The gap between the claim and the measurement is the finding — but only
# when the BOX chose where to stop. An empty $stop means the child was ended
# rather than deciding to end; anything else (--to, the floor) is a stop we
# asked for, and flagging those as short of the ceiling would put a warning
# on every deliberately small run.
# Warn about claimed-vs-measured gap only when the box, not us, chose the stop.
local got="${rss:-$held}"
echo
if [ -z "$stop" ] && [ "$got" -lt $(( ceiling * 70 / 100 )) ]; then