Last active
April 11, 2026 16:59
-
-
Save kjanat/418e33723185e7efd09d4903901e4064 to your computer and use it in GitHub Desktop.
Grouped memory usage by application
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # memhogs — grouped memory usage by application | |
| # Usage: memhogs [INTERVAL] [-w|--watch] [-k|--kill|--rescue] | |
| # PRIORITY: rescue target must be available IMMEDIATELY. | |
| # update_rescue runs FIRST, before flags, before anything. | |
| # Every code path must have RESCUE_NAME set before any slow work or traps. | |
| RESCUE_NAME="" | |
| RESCUE_RSS="" | |
| RESCUE_PROCS="" | |
| TOTAL_MEM_KB=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) | |
| update_rescue() { | |
| RESCUE_NAME="${1}" | |
| RESCUE_RSS="${2}" | |
| RESCUE_PROCS="${3:-}" | |
| } | |
| # THIS RUNS FIRST. Before arg parsing. Before anything. | |
| # Fast path via ps — no /proc walk. | |
| read -r fast_name fast_rss < <(ps -eo rss,comm --no-headers | awk ' | |
| { rss[$2]+=$1 } | |
| END { max=0; for(n in rss) if(rss[n]>max){max=rss[n]; name=n}; printf "%s %d", name, max/1024 } | |
| ' 2>/dev/null) | |
| update_rescue "${fast_name}" "${fast_rss}" | |
| unset fast_name fast_rss | |
| MODE="once" | |
| INTERVAL=3 | |
| # Parse flags | |
| while [[ $# -gt 0 ]]; do | |
| case "${1}" in | |
| --internal-watch) | |
| MODE="watch" | |
| if [[ -n "${2:-}" ]] && [[ "${2}" =~ ^[0-9]+$ ]]; then | |
| INTERVAL="${2}" | |
| shift | |
| fi | |
| shift | |
| ;; | |
| -w | --watch) | |
| MODE="watch" | |
| if [[ -n "${2:-}" ]] && [[ "${2}" =~ ^[0-9]+$ ]]; then | |
| INTERVAL="${2}" | |
| shift | |
| fi | |
| shift | |
| ;; | |
| -k | --kill | --rescue) | |
| # do_kill defined below, runs after trap is set | |
| MODE="kill" | |
| shift | |
| ;; | |
| -h | --help) | |
| echo "Usage: memhogs [INTERVAL] [-w|--watch] [-k|--kill|--rescue]" | |
| echo "" | |
| echo " (no args) Single snapshot" | |
| echo " INTERVAL Watch with given interval (e.g. memhogs 6)" | |
| echo " -w, --watch [N] Watch, optional interval (default: 3)" | |
| echo " -k, --kill Kill the biggest memory hog (prompts first)" | |
| echo "" | |
| echo " WATCH=N memhogs Watch with interval N" | |
| echo " WATCH= memhogs Watch with default interval" | |
| exit 0 | |
| ;; | |
| [0-9]*) | |
| MODE="watch" | |
| INTERVAL="${1}" | |
| shift | |
| ;; | |
| *) | |
| printf 'Unknown flag: %s\n' "${1}" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| done | |
| # WATCH env var: set = watch mode, numeric value = interval, empty = default | |
| if [[ -n "${WATCH+set}" ]]; then | |
| MODE="watch" | |
| if [[ "${WATCH:-}" =~ ^[0-9]+$ ]] && [[ -n "${WATCH:-}" ]]; then | |
| INTERVAL="${WATCH}" | |
| fi | |
| fi | |
| # --------------------------------------------------------------------------- | |
| # Data collection | |
| # --------------------------------------------------------------------------- | |
| collect_data() { | |
| APP_TABLE=$(awk -v total="${TOTAL_MEM_KB}" ' | |
| BEGIN { OFS="\t" } | |
| { | |
| dir = "/proc/" $1 | |
| comm_file = dir "/comm" | |
| status_file = dir "/status" | |
| if ((getline name < comm_file) <= 0) next | |
| close(comm_file) | |
| rss = 0; swap = 0 | |
| while ((getline line < status_file) > 0) { | |
| if (line ~ /^VmRSS:/) { split(line, a); rss = a[2] } | |
| if (line ~ /^VmSwap:/) { split(line, a); swap = a[2] } | |
| } | |
| close(status_file) | |
| rss_sum[name] += rss | |
| swap_sum[name] += swap | |
| count[name]++ | |
| } | |
| END { | |
| for (n in rss_sum) { | |
| pct = (rss_sum[n] / total) * 100 | |
| if (rss_sum[n] > 1024) | |
| printf "%s\t%d\t%d\t%d\t%.1f\n", n, rss_sum[n]/1024, swap_sum[n]/1024, count[n], pct | |
| } | |
| }' <(printf '%s\n' /proc/[0-9]* | sed 's|/proc/||') 2>/dev/null \ | |
| | sort -t$'\t' -k2 -rn | head -25) | |
| SYSTEM_INFO=$(awk ' | |
| /^MemTotal:/ {mt=$2} | |
| /^MemAvailable:/{ma=$2} | |
| /^SwapTotal:/ {st=$2} | |
| /^SwapFree:/ {sf=$2} | |
| /^Zswap:/ {zs=$2} | |
| /^Zswapped:/ {zw=$2} | |
| END { | |
| mu=mt-ma; su=st-sf | |
| printf "RAM: %5dMB / %5dMB used (%5dMB avail)\n", mu/1024, mt/1024, ma/1024 | |
| printf "Swap: %5dMB / %5dMB used (%5dMB free)\n", su/1024, st/1024, sf/1024 | |
| if (zs > 0) | |
| printf "Zswap: %5dMB pool → %5dMB data (%.1fx ratio)\n", zs/1024, zw/1024, zw/zs | |
| } | |
| ' /proc/meminfo) | |
| PSI_INFO="" | |
| if [[ -f /proc/pressure/memory ]]; then | |
| PSI_INFO=$(awk ' | |
| NR==1 { | |
| for(i=1;i<=NF;i++) { split($i,kv,"="); v[kv[1]]=kv[2] } | |
| printf "PSI: some avg10=%.2f%% avg60=%.2f%% avg300=%.2f%%\n", v["avg10"], v["avg60"], v["avg300"] | |
| } | |
| NR==2 { | |
| for(i=1;i<=NF;i++) { split($i,kv,"="); v[kv[1]]=kv[2] } | |
| printf " full avg10=%.2f%% avg60=%.2f%% avg300=%.2f%%\n", v["avg10"], v["avg60"], v["avg300"] | |
| } | |
| ' /proc/pressure/memory) | |
| fi | |
| OOM_TABLE=$( | |
| for dir in /proc/[0-9]*; do | |
| score=$(<"${dir}/oom_score") || continue | |
| [[ "${score}" -lt 10 ]] && continue | |
| comm=$(<"${dir}/comm") || continue | |
| rss=$(awk '/^VmRSS:/{printf "%dMB", $2/1024}' "${dir}/status" 2>/dev/null) | |
| [[ -z "${rss}" ]] && continue | |
| printf '%s\t%s\t%s\n' "${score}" "${rss}" "${comm}" | |
| done 2>/dev/null | sort -t$'\t' -k1 -rn | head -10 \ | |
| | while IFS=$'\t' read -r score rss comm; do | |
| printf '%-6s %8s %s\n' "${score}" "${rss}" "${comm}" | |
| done | |
| ) | |
| # Refine rescue target from full /proc data | |
| local top_line | |
| top_line=$(printf '%s\n' "${APP_TABLE}" | head -1) | |
| update_rescue \ | |
| "$(printf '%s' "${top_line}" | cut -f1)" \ | |
| "$(printf '%s' "${top_line}" | cut -f2)" \ | |
| "$(printf '%s' "${top_line}" | cut -f4)" | |
| # Snapshot for exec-based diff | |
| CURRENT_DATA="${APP_TABLE}${SYSTEM_INFO}${PSI_INFO}${OOM_TABLE}${RESCUE_NAME}" | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Rendering | |
| # --------------------------------------------------------------------------- | |
| divider() { | |
| local i | |
| for ((i = 0; i < COLS; i++)); do printf '─'; done | |
| } | |
| section() { | |
| local title="${1}" | |
| local title_len=$((${#title} + 4)) | |
| local pad=$((COLS - title_len)) | |
| if [[ "${pad}" -lt 0 ]]; then pad=0; fi | |
| printf '\e[1m── %s ' "${title}" | |
| local i | |
| for ((i = 0; i < pad; i++)); do printf '─'; done | |
| printf '\e[0m' | |
| } | |
| rescue_line() { | |
| printf 'pkill -x %s # frees ~%sMB' "${RESCUE_NAME}" "${RESCUE_RSS}" | |
| } | |
| render_frame() { | |
| local term_width="${COLUMNS:-$(tput cols 2>/dev/null || echo 80)}" | |
| COLS="${term_width}" | |
| # Name column: fit the longest app name, minimum 16 | |
| local name_width=16 | |
| while IFS=$'\t' read -r n _; do | |
| [[ ${#n} -gt ${name_width} ]] && name_width=${#n} | |
| done <<<"${APP_TABLE}" | |
| # Derive bar budget from terminal width minus fixed columns | |
| local num_cols | |
| num_cols=$(printf " %7dMB %7dMB %6d %5.1f%% " 0 0 0 0 | wc -m) | |
| local fixed_cols=$((name_width + num_cols)) | |
| local bar_max=$((term_width - fixed_cols)) | |
| if [[ "${bar_max}" -lt 1 ]]; then bar_max=1; fi | |
| # Top hog fills full bar, others scale relative | |
| local top_rss | |
| top_rss=$(printf '%s\n' "${APP_TABLE}" | head -1 | cut -f2) | |
| top_rss="${top_rss:-1}" | |
| local frame="" | |
| frame+=$(printf "\e[1m%-${name_width}s %9s %9s %6s %6s %s\e[0m\n" \ | |
| "APPLICATION" "RSS" "SWAP" "PROCS" "MEM%" "BAR") | |
| frame+=$'\n'"$(divider)"$'\n' | |
| while IFS=$'\t' read -r name rss swap procs pct; do | |
| [[ -z "${name}" ]] && continue | |
| local bar_len color bar | |
| bar_len=$(awk -v max="${bar_max}" -v top="${top_rss}" "BEGIN{v=int(${rss}/top*max); if(v>max)v=max; if(v<1 && ${rss}>0)v=1; print v}") | |
| bar="" | |
| local i | |
| for ((i = 0; i < bar_len; i++)); do bar+='█'; done | |
| if awk "BEGIN{exit !(${pct} > 10)}"; then | |
| color='\e[31m' | |
| elif awk "BEGIN{exit !(${pct} > 5)}"; then | |
| color='\e[33m' | |
| else | |
| color='\e[32m' | |
| fi | |
| frame+=$(printf "%-${name_width}s %7dMB %7dMB %6d %5.1f%% ${color}%s\e[0m\n" \ | |
| "${name}" "${rss}" "${swap}" "${procs}" "${pct}" "${bar}") | |
| frame+=$'\n' | |
| done <<<"${APP_TABLE}" | |
| frame+=$'\n' | |
| frame+="$(section System)"$'\n' | |
| frame+=$'\n'"${SYSTEM_INFO}"$'\n' | |
| if [[ -n "${PSI_INFO}" ]]; then | |
| frame+="${PSI_INFO}"$'\n' | |
| fi | |
| frame+=$'\n' | |
| frame+="$(section "Top OOM kill targets")"$'\n' | |
| frame+=$'\n' | |
| frame+=$(printf '%-6s %8s %s\n' "SCORE" "RSS" "PROCESS") | |
| frame+=$'\n'"$(divider)"$'\n' | |
| frame+="${OOM_TABLE}"$'\n' | |
| frame+=$'\n' | |
| frame+="$(section "Emergency memory rescue")"$'\n' | |
| frame+=$'\n' | |
| frame+=$(printf '\e[33m%s\e[0m\n' "$(rescue_line)") | |
| if [[ "${MODE}" == "watch" ]]; then | |
| clear | |
| fi | |
| printf '%s\n' "${frame}" | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Actions | |
| # --------------------------------------------------------------------------- | |
| do_kill() { | |
| if [[ -z "${RESCUE_NAME}" ]]; then | |
| echo "No process found to kill." >&2 | |
| exit 1 | |
| fi | |
| local detail="" | |
| if [[ -n "${RESCUE_RSS}" ]]; then | |
| detail=" (${RESCUE_RSS}MB" | |
| if [[ -n "${RESCUE_PROCS}" ]]; then | |
| detail+=" across ${RESCUE_PROCS} processes" | |
| fi | |
| detail+=")" | |
| fi | |
| printf 'Kill \e[1m%s\e[0m%s? [y/N] ' "${RESCUE_NAME}" "${detail}" | |
| read -r confirm | |
| if [[ "${confirm}" != "y" && "${confirm}" != "Y" ]]; then | |
| echo "Aborted." | |
| exit 0 | |
| fi | |
| if pkill -x "${RESCUE_NAME}" 2>/dev/null; then | |
| printf 'Killed %s\n' "${RESCUE_NAME}" | |
| else | |
| printf 'pkill failed (EPERM?), retrying with sudo...\n' | |
| sudo pkill -x "${RESCUE_NAME}" | |
| fi | |
| } | |
| on_exit() { | |
| if [[ -n "${RESCUE_NAME}" ]]; then | |
| echo "" | |
| section "Emergency memory rescue" | |
| echo "" | |
| printf '\e[33m%s\e[0m\n' "$(rescue_line)" | |
| fi | |
| exit 0 | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Main — one path, no branches | |
| # --------------------------------------------------------------------------- | |
| trap on_exit INT TERM | |
| if [[ "${MODE}" == "kill" ]]; then | |
| do_kill | |
| exit 0 | |
| fi | |
| collect_data | |
| # In watch mode via exec: skip render if data unchanged | |
| if [[ "${MODE}" == "watch" ]] && [[ "${CURRENT_DATA}" == "${MEMHOGS_PREV:-}" ]]; then | |
| sleep "${INTERVAL}" | |
| export MEMHOGS_PREV="${CURRENT_DATA}" | |
| exec "$0" --internal-watch "${INTERVAL}" | |
| fi | |
| render_frame | |
| [[ "${MODE}" == "once" ]] && exit 0 | |
| sleep "${INTERVAL}" | |
| export MEMHOGS_PREV="${CURRENT_DATA}" | |
| exec "$0" --internal-watch "${INTERVAL}" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # memhogs2 — grouped memory usage by application | |
| # Usage: memhogs2 [-w|--watch [INTERVAL]] [-k|--kill|--rescue] | |
| RESCUE_CMD="" | |
| RESCUE_NAME="" | |
| TOTAL_MEM_KB=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) | |
| MODE="once" | |
| INTERVAL=3 | |
| DO_KILL=false | |
| PREV_FRAME="" | |
| APP_TABLE="" | |
| SYSTEM_INFO="" | |
| PSI_INFO="" | |
| OOM_TABLE="" | |
| COLS=80 | |
| # --- terminal/style/glyph config (ASCII source, UTF-8 output when possible) --- | |
| ESC=$'\033' | |
| RESET="${ESC}[0m" | |
| BOLD="${ESC}[1m" | |
| DIM="${ESC}[2m" | |
| RED="${ESC}[31m" | |
| GREEN="${ESC}[32m" | |
| YELLOW="${ESC}[33m" | |
| CYAN="${ESC}[36m" | |
| WHITE="${ESC}[37m" | |
| is_utf8_locale() { | |
| local loc="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" | |
| [[ "${loc^^}" == *UTF-8* || "${loc^^}" == *UTF8* ]] | |
| } | |
| if is_utf8_locale; then | |
| HR_CHAR=$'\342\224\200' | |
| BAR_CHAR=$'\342\226\210' | |
| else | |
| HR_CHAR='-' | |
| BAR_CHAR='#' | |
| fi | |
| show_help() { | |
| cat <<'EOF' | |
| Usage: memhogs2 [INTERVAL] [-w|--watch] [-k|--kill|--rescue] | |
| (no args) Single snapshot | |
| INTERVAL Watch with given interval (e.g. memhogs2 6) | |
| -w, --watch [N] Watch, optional interval (default: 3) | |
| -k, --kill Kill the biggest memory hog (prompts first) | |
| WATCH=N memhogs2 Watch with interval N | |
| WATCH= memhogs2 Watch with default interval | |
| EOF | |
| } | |
| set_rescue_target() { | |
| local name="${1:-}" | |
| local rss_mb="${2:-0}" | |
| local escaped="" | |
| RESCUE_NAME="${name}" | |
| if [[ -n "${name}" ]]; then | |
| escaped=$(printf '%q' "${name}") | |
| RESCUE_CMD="${RED}pkill -x --${RESET} ${CYAN}${escaped}${RESET} ${DIM}# frees ~${YELLOW}${rss_mb}MB${RESET}" | |
| else | |
| RESCUE_CMD="${DIM}No rescue target available${RESET}" | |
| fi | |
| } | |
| calc_rescue_fast() { | |
| local top_line name rss_mb | |
| top_line=$( | |
| ps -eo rss=,args= 2>/dev/null | awk ' | |
| { | |
| cmd = $2 | |
| sub(/.*\//, "", cmd) | |
| rss[cmd] += $1 | |
| } | |
| END { | |
| max = 0 | |
| for (name in rss) { | |
| if (rss[name] > max) { | |
| max = rss[name] | |
| top = name | |
| } | |
| } | |
| if (top != "") | |
| printf "%s\t%d\n", top, max / 1024 | |
| } | |
| ' | |
| ) | |
| if [[ -n "${top_line}" ]]; then | |
| IFS=$'\t' read -r name rss_mb <<<"${top_line}" | |
| set_rescue_target "${name}" "${rss_mb}" | |
| else | |
| set_rescue_target "" 0 | |
| fi | |
| } | |
| # Must run before arg parsing, traps, or any slower work. | |
| calc_rescue_fast | |
| parse_args() { | |
| while [[ $# -gt 0 ]]; do | |
| case "${1}" in | |
| -w | --watch) | |
| MODE="watch" | |
| if [[ -n "${2:-}" && "${2}" =~ ^[0-9]+$ ]]; then | |
| INTERVAL="${2}" | |
| shift | |
| fi | |
| shift | |
| ;; | |
| -k | --kill | --rescue) | |
| DO_KILL=true | |
| shift | |
| ;; | |
| -h | --help) | |
| show_help | |
| exit 0 | |
| ;; | |
| [0-9]*) | |
| MODE="watch" | |
| INTERVAL="${1}" | |
| shift | |
| ;; | |
| *) | |
| printf 'Unknown flag: %s\n' "${1}" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| done | |
| # WATCH env var: | |
| # set => watch mode | |
| # numeric => interval | |
| # empty => default interval | |
| if [[ -n "${WATCH+set}" ]]; then | |
| MODE="watch" | |
| if [[ -n "${WATCH:-}" && "${WATCH}" =~ ^[0-9]+$ ]]; then | |
| INTERVAL="${WATCH}" | |
| fi | |
| fi | |
| } | |
| repeat_char() { | |
| local count="${1:-0}" | |
| local char="${2:--}" | |
| local out="" | |
| local i | |
| ((count < 1)) && return 0 | |
| for ((i = 0; i < count; i++)); do | |
| out+="${char}" | |
| done | |
| printf '%s' "${out}" | |
| } | |
| divider() { | |
| repeat_char "${COLS}" "${HR_CHAR}" | |
| } | |
| section() { | |
| local title="${1}" | |
| local title_len=$((${#title} + 4)) | |
| local pad=$((COLS - title_len)) | |
| ((pad < 0)) && pad=0 | |
| printf '%s%s %s %s%s' "${BOLD}" "${HR_CHAR}${HR_CHAR}" "${title}" "$(repeat_char "${pad}" "${HR_CHAR}")" "${RESET}" | |
| } | |
| bar_chars() { | |
| local len="${1:-0}" | |
| ((len < 1)) && len=1 | |
| repeat_char "${len}" "${BAR_CHAR}" | |
| } | |
| color_for_pct() { | |
| local pct="${1:-0.0}" | |
| local whole frac | |
| whole="${pct%.*}" | |
| frac="${pct#*.}" | |
| if ((whole > 10 || (whole == 10 && frac > 0))); then | |
| printf '%s' "${RED}" | |
| elif ((whole > 5 || (whole == 5 && frac > 0))); then | |
| printf '%s' "${YELLOW}" | |
| else | |
| printf '%s' "${GREEN}" | |
| fi | |
| } | |
| update_rescue_from_table() { | |
| local first name rss_mb | |
| first=$(printf '%s\n' "${APP_TABLE}" | head -n1) | |
| [[ -z "${first}" ]] && return | |
| IFS=$'\t' read -r name rss_mb _ <<<"${first}" | |
| set_rescue_target "${name}" "${rss_mb:-0}" | |
| } | |
| collect_app_table() { | |
| APP_TABLE=$( | |
| awk -v total="${TOTAL_MEM_KB}" ' | |
| BEGIN { OFS = "\t" } | |
| { | |
| dir = "/proc/" $1 | |
| comm_file = dir "/comm" | |
| cmdline_file = dir "/cmdline" | |
| status_file = dir "/status" | |
| if ((getline name < comm_file) <= 0) next | |
| close(comm_file) | |
| # comm is kernel-truncated to 15 chars; try cmdline for full name | |
| cmd = "" | |
| if ((getline cmd < cmdline_file) > 0) { | |
| gsub(/\0/, " ", cmd) | |
| # strip leading path | |
| n = split(cmd, parts, " ") | |
| if (n > 0) { | |
| sub(/.*\//, "", parts[1]) | |
| candidate = parts[1] | |
| # use cmdline name if comm was truncated | |
| if (length(name) >= 15 && length(candidate) > length(name)) | |
| name = candidate | |
| } | |
| } | |
| close(cmdline_file) | |
| rss = 0 | |
| swap = 0 | |
| while ((getline line < status_file) > 0) { | |
| if (line ~ /^VmRSS:/) { | |
| split(line, a) | |
| rss = a[2] | |
| } else if (line ~ /^VmSwap:/) { | |
| split(line, a) | |
| swap = a[2] | |
| } | |
| } | |
| close(status_file) | |
| rss_sum[name] += rss | |
| swap_sum[name] += swap | |
| count[name]++ | |
| } | |
| END { | |
| for (name in rss_sum) { | |
| if (rss_sum[name] <= 1024) continue | |
| pct = (rss_sum[name] / total) * 100 | |
| printf "%s\t%d\t%d\t%d\t%.1f\n", | |
| name, | |
| rss_sum[name] / 1024, | |
| swap_sum[name] / 1024, | |
| count[name], | |
| pct | |
| } | |
| } | |
| ' < <(printf '%s\n' /proc/[0-9]* | sed 's|/proc/||') 2>/dev/null \ | |
| | sort -t$'\t' -k2,2rn \ | |
| | head -25 | |
| ) | |
| } | |
| collect_system_info() { | |
| # TSV: type \t used \t total \t extra_label \t extra_val \t note | |
| SYSTEM_INFO=$( | |
| awk ' | |
| /^MemTotal:/ { mt = $2 } | |
| /^MemAvailable:/ { ma = $2 } | |
| /^SwapTotal:/ { st = $2 } | |
| /^SwapFree:/ { sf = $2 } | |
| /^Zswap:/ { zs = $2 } | |
| /^Zswapped:/ { zw = $2 } | |
| END { | |
| mu = mt - ma; su = st - sf | |
| printf "RAM\t%d\t%d\t%d\tavail\n", mu/1024, mt/1024, ma/1024 | |
| printf "Swap\t%d\t%d\t%d\tfree\n", su/1024, st/1024, sf/1024 | |
| if (zs > 0) | |
| printf "Zswap\t%d\t%d\t%.1f\tratio\n", zs/1024, zw/1024, zw/zs | |
| } | |
| ' /proc/meminfo | |
| ) | |
| } | |
| collect_psi_info() { | |
| PSI_INFO="" | |
| [[ -f /proc/pressure/memory ]] || return | |
| # TSV: type \t avg10 \t avg60 \t avg300 | |
| PSI_INFO=$( | |
| awk ' | |
| { | |
| for (i = 1; i <= NF; i++) { | |
| split($i, kv, "=") | |
| v[kv[1]] = kv[2] | |
| } | |
| label = (NR == 1) ? "some" : "full" | |
| printf "%s\t%.2f%%\t%.2f%%\t%.2f%%\n", label, v["avg10"], v["avg60"], v["avg300"] | |
| } | |
| ' /proc/pressure/memory | |
| ) | |
| } | |
| collect_oom_table() { | |
| OOM_TABLE=$( | |
| awk -v bold="${BOLD}" -v reset="${RESET}" -v yellow="${YELLOW}" ' | |
| { | |
| dir = "/proc/" $1 | |
| score_file = dir "/oom_score" | |
| adj_file = dir "/oom_score_adj" | |
| comm_file = dir "/comm" | |
| status_file = dir "/status" | |
| cgroup_file = dir "/cgroup" | |
| if ((getline score < score_file) <= 0) next | |
| close(score_file) | |
| score += 0 | |
| if (score < 10) next | |
| adj = 0 | |
| if ((getline adj < adj_file) > 0) adj += 0 | |
| close(adj_file) | |
| if ((getline name < comm_file) <= 0) next | |
| close(comm_file) | |
| rss = 0; swap = 0 | |
| while ((getline line < status_file) > 0) { | |
| if (line ~ /^VmRSS:/) { | |
| split(line, a); rss = a[2] | |
| } else if (line ~ /^VmSwap:/) { | |
| split(line, a); swap = a[2] | |
| } | |
| } | |
| close(status_file) | |
| if (rss < 1) next | |
| # extract service/slice from cgroup path | |
| slice = "?" | |
| while ((getline line < cgroup_file) > 0) { | |
| # cgroupv2: "0::/user.slice/user-1000.slice/..../foo.service" | |
| n = split(line, parts, "/") | |
| # walk segments, prefer .service, then .slice | |
| for (si = n; si >= 1; si--) { | |
| seg = parts[si] | |
| if (seg ~ /\.service$/) { slice = seg; break } | |
| } | |
| if (slice == "?") { | |
| for (si = n; si >= 1; si--) { | |
| seg = parts[si] | |
| if (seg ~ /\.slice$/ && seg != "-.slice") { slice = seg; break } | |
| } | |
| } | |
| if (slice == "?") { | |
| # fallback: innermost non-empty segment | |
| for (si = n; si >= 1; si--) { | |
| seg = parts[si] | |
| sub(/\.scope$/, "", seg) | |
| if (seg != "" && seg != "0::") { slice = seg; break } | |
| } | |
| } | |
| } | |
| close(cgroup_file) | |
| pid = $1 + 0 | |
| count[name]++ | |
| rss_sum[name] += rss | |
| swap_sum[name] += swap | |
| # track slice + ppid from highest-scoring PID | |
| if (score > max_score[name]) { | |
| max_score[name] = score | |
| slices[name] = slice | |
| top_pid[name] = pid | |
| } | |
| if (adj != 0 && !(name in adj_val)) adj_val[name] = adj | |
| # find parent pid | |
| stat_file = dir "/stat" | |
| if ((getline stat_line < stat_file) > 0) { | |
| # /proc/PID/stat: pid (comm) state ppid ... | |
| match(stat_line, /\) [A-Za-z] ([0-9]+)/, m) | |
| if (m[1] + 0 > 1) { | |
| ppid = m[1] + 0 | |
| if (!(name in parent_pid) || pid == top_pid[name]) | |
| parent_pid[name] = ppid | |
| } | |
| } | |
| close(stat_file) | |
| } | |
| END { | |
| for (name in max_score) { | |
| adj_str = "" | |
| if (name in adj_val) { | |
| v = adj_val[name] | |
| if (v < 0) adj_str = sprintf("adj %d", v) | |
| else if (v > 0) adj_str = sprintf("adj +%d", v) | |
| } | |
| # prefer parent pid, fall back to top-scoring pid | |
| target = (name in parent_pid) ? parent_pid[name] : top_pid[name] | |
| printf "%d\t%d\t%d\t%d\t%s\t%s\t%s\t%d\n", | |
| max_score[name], | |
| rss_sum[name] / 1024, | |
| swap_sum[name] / 1024, | |
| count[name], | |
| name, | |
| slices[name], | |
| adj_str, | |
| target | |
| } | |
| } | |
| ' < <(printf '%s\n' /proc/[0-9]* | sed 's|/proc/||') 2>/dev/null \ | |
| | sort -t$'\t' -k1,1rn \ | |
| | head -10 \ | |
| | head -10 | |
| ) | |
| } | |
| collect_data() { | |
| collect_app_table | |
| collect_system_info | |
| collect_psi_info | |
| collect_oom_table | |
| update_rescue_from_table | |
| } | |
| render_frame() { | |
| local frame="" | |
| local term_width | |
| local name_width=16 | |
| local num_cols | |
| local fixed_cols | |
| local bar_max | |
| local top_rss | |
| local sample | |
| term_width="${COLUMNS:-$(tput cols 2>/dev/null || echo 80)}" | |
| COLS="${term_width}" | |
| while IFS=$'\t' read -r name _; do | |
| ((${#name} > name_width)) && name_width=${#name} | |
| done <<<"${APP_TABLE}" | |
| sample=$(printf " %7dMB %7dMB %6d %5.1f%% " 0 0 0 0) | |
| num_cols=${#sample} | |
| fixed_cols=$((name_width + num_cols)) | |
| bar_max=$((term_width - fixed_cols)) | |
| ((bar_max < 1)) && bar_max=1 | |
| top_rss=$(printf '%s\n' "${APP_TABLE}" | head -n1 | cut -f2) | |
| [[ -z "${top_rss}" || "${top_rss}" -lt 1 ]] && top_rss=1 | |
| frame+=$(printf "%s%-${name_width}s %9s %9s %6s %6s %s%s\n" \ | |
| "${BOLD}" "APPLICATION" "RSS" "SWAP" "PROCS" "MEM%" "BAR" "${RESET}") | |
| frame+=$'\n'"$(divider)"$'\n' | |
| while IFS=$'\t' read -r name rss swap procs pct; do | |
| local bar_len color bar | |
| [[ -z "${name}" ]] && continue | |
| bar_len=$((rss * bar_max / top_rss)) | |
| ((rss > 0 && bar_len < 1)) && bar_len=1 | |
| ((bar_len > bar_max)) && bar_len=bar_max | |
| bar=$(bar_chars "${bar_len}") | |
| color=$(color_for_pct "${pct}") | |
| frame+=$(printf "%s%-${name_width}s%s %s%7dMB %7dMB%s %s%6d%s %s%5.1f%%%s %s%s%s\n" \ | |
| "${CYAN}" "${name}" "${RESET}" \ | |
| "${YELLOW}" "${rss}" "${swap}" "${RESET}" \ | |
| "${DIM}" "${procs}" "${RESET}" \ | |
| "${WHITE}" "${pct}" "${RESET}" \ | |
| "${color}" "${bar}" "${RESET}") | |
| frame+=$'\n' | |
| done <<<"${APP_TABLE}" | |
| frame+=$'\n' | |
| frame+="$(section "System")"$'\n' | |
| frame+=$'\n' | |
| frame+=$(printf "%-6s %8s %8s %8s %s\n" "TYPE" "USED" "TOTAL" "OTHER" "NOTE") | |
| frame+=$'\n'"$(divider)"$'\n' | |
| while IFS=$'\t' read -r stype used total extra note; do | |
| [[ -z "${stype}" ]] && continue | |
| frame+=$(printf "%s%-6s%s %s%6dMB %6dMB %6sMB%s %s%s%s\n" \ | |
| "${CYAN}" "${stype}" "${RESET}" \ | |
| "${YELLOW}" "${used}" "${total}" "${extra}" "${RESET}" \ | |
| "${DIM}" "${note}" "${RESET}") | |
| frame+=$'\n' | |
| done <<<"${SYSTEM_INFO}" | |
| if [[ -n "${PSI_INFO}" ]]; then | |
| frame+=$'\n' | |
| frame+="$(section "Memory pressure (PSI)")"$'\n' | |
| frame+=$'\n' | |
| frame+=$(printf "%-6s %8s %8s %8s\n" "TYPE" "AVG10" "AVG60" "AVG300") | |
| frame+=$'\n'"$(divider)"$'\n' | |
| while IFS=$'\t' read -r ptype avg10 avg60 avg300; do | |
| [[ -z "${ptype}" ]] && continue | |
| frame+=$(printf "%s%-6s%s %s%8s %8s %8s%s\n" \ | |
| "${CYAN}" "${ptype}" "${RESET}" \ | |
| "${YELLOW}" "${avg10}" "${avg60}" "${avg300}" "${RESET}") | |
| frame+=$'\n' | |
| done <<<"${PSI_INFO}" | |
| fi | |
| frame+=$'\n' | |
| frame+="$(section "Top OOM kill targets")"$'\n' | |
| frame+=$'\n' | |
| # compute max widths for variable-length OOM columns | |
| local oom_name_w=7 oom_adj_w=3 oom_slice_w=5 oom_kill_w=4 | |
| while IFS=$'\t' read -r _ _ _ _ name slice adj_note target_pid; do | |
| [[ -z "${name}" ]] && continue | |
| ((${#name} > oom_name_w)) && oom_name_w=${#name} | |
| ((${#adj_note} > oom_adj_w)) && oom_adj_w=${#adj_note} | |
| ((${#slice} > oom_slice_w)) && oom_slice_w=${#slice} | |
| local kc="kill ${target_pid}" | |
| ((${#kc} > oom_kill_w)) && oom_kill_w=${#kc} | |
| done <<<"${OOM_TABLE}" | |
| frame+=$(printf "%-6s %10s %10s %5s %-${oom_name_w}s %-${oom_adj_w}s %-${oom_slice_w}s %s\n" \ | |
| "SCORE" "RSS" "SWAP" "PROCS" "PROCESS" "ADJ" "SLICE" "KILL") | |
| frame+=$'\n'"$(divider)"$'\n' | |
| while IFS=$'\t' read -r score rss swap procs name slice adj_note target_pid; do | |
| [[ -z "${score}" ]] && continue | |
| kill_cmd="kill ${target_pid}" | |
| frame+=$(printf "%s%-6s%s %s%8dMB %8dMB%s %s%5d%s %s%-${oom_name_w}s%s %s%-${oom_adj_w}s%s %s%-${oom_slice_w}s%s %s%s%s\n" \ | |
| "${WHITE}" "${score}" "${RESET}" \ | |
| "${YELLOW}" "${rss}" "${swap}" "${RESET}" \ | |
| "${DIM}" "${procs}" "${RESET}" \ | |
| "${CYAN}" "${name}" "${RESET}" \ | |
| "${DIM}" "${adj_note}" "${RESET}" \ | |
| "${GREEN}" "${slice}" "${RESET}" \ | |
| "${RED}" "${kill_cmd}" "${RESET}") | |
| frame+=$'\n' | |
| done <<<"${OOM_TABLE}" | |
| frame+=$'\n' | |
| frame+="$(section "Emergency memory rescue")"$'\n' | |
| frame+=$'\n' | |
| frame+=$(printf '%s\n' "${RESCUE_CMD}") | |
| if [[ "${frame}" != "${PREV_FRAME}" ]]; then | |
| [[ "${MODE}" == "watch" ]] && clear | |
| printf '%s\n' "${frame}" | |
| PREV_FRAME="${frame}" | |
| fi | |
| } | |
| do_kill() { | |
| if [[ -z "${RESCUE_NAME}" ]]; then | |
| echo "No process found to kill." >&2 | |
| exit 1 | |
| fi | |
| printf '%sKill%s %s%s%s? [y/N] ' "${RED}" "${RESET}" "${CYAN}" "${RESCUE_NAME}" "${RESET}" | |
| read -r confirm | |
| if [[ "${confirm}" != "y" && "${confirm}" != "Y" ]]; then | |
| echo "Aborted." | |
| exit 0 | |
| fi | |
| if pkill -x -- "${RESCUE_NAME}" 2>/dev/null; then | |
| printf 'Killed %s\n' "${RESCUE_NAME}" | |
| else | |
| printf 'pkill failed, retrying with sudo...\n' | |
| sudo pkill -x -- "${RESCUE_NAME}" | |
| fi | |
| } | |
| on_exit() { | |
| if [[ -n "${RESCUE_CMD}" ]]; then | |
| echo | |
| section "Emergency memory rescue" | |
| echo | |
| printf '%s\n' "${RESCUE_CMD}" | |
| fi | |
| exit 0 | |
| } | |
| run_once() { | |
| collect_data | |
| render_frame | |
| } | |
| run_watch() { | |
| run_once | |
| while true; do | |
| sleep "${INTERVAL}" | |
| collect_data | |
| render_frame | |
| done | |
| } | |
| main() { | |
| parse_args "$@" | |
| trap on_exit INT TERM | |
| if "${DO_KILL}"; then | |
| do_kill | |
| exit 0 | |
| fi | |
| if [[ "${MODE}" == "watch" ]]; then | |
| run_watch | |
| else | |
| run_once | |
| fi | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment