testsAndMisc/linux_configuration/periodic_background/digital_wellbeing/heavy_job_lock.sh
Krzysztof kuhy Rudnicki 4f2b0ccdf7 Dissolve linux_configuration/scripts/ and single_use/
Two levels of pure taxonomy carrying no information, and a 2-directory
cap leaves no room for them. Categories now sit directly under
linux_configuration/: features, fixes, utils, misc, fresh-install,
installers, diagnostics, gaming, boot_recovery, periodic_background, lib,
meta, tests. A script's own lib/ and tests/ are exempt from the cap, so
those stay put.

Depth violations 248 -> 139; roughly 40 of the remainder are inside
digital_wellbeing and hosts, deferred until the guard-lib repoint.

The moves are the easy half. What breaks is every path that counted
levels, and three classes of it had to be found by resolving paths rather
than reading diffs:

  - 52 relative `source` lines for lib/common.sh and lib/android.sh. The
    correct prefix differs per file: 40 were ../../ and 12 were ../../../,
    so a blanket replace would have fixed one set and broken the other.
  - REPO_ROOT in 7 fixes/lib/tests harnesses and setup_syncyomi_guard.sh,
    each walking up four levels from a directory now two levels shallower.
    This caused a real regression -- the fixes suite went red with
    "log_warn: command not found" -- caught by running it and comparing
    against the pre-move baseline worktree.
  - conftest.py's sys.path entries, which took the pytest gate down with
    14 collection errors, and the `# shellcheck source=` directives, which
    took shellcheck down with SC1091.

16 symlinks in $HOME broke, including the entire i3blocks bar. All
repointed, and cpu_monitor/memory/disk/time/volume were run to confirm
they emit real output rather than merely resolving.

Verified: 1798 tests pass at 100% coverage, 8 of 9 lib/tests suites pass
(the ninth needs the coverage jail, identically at baseline), pre-commit
is clean, and the two untracked secret configs are still ignored after
their gitignore paths changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
2026-08-23 18:53:30 +02:00

233 lines
7.1 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# heavy_job_lock.sh - one heavy job at a time on this machine
#
# This box has 7.6 GB. On 2026-08-03 a Gradle/Flutter APK build, a full
# `pytest --cov` run and Chromium overlapped; an earlier overlap the same week
# got a whole session OOM-killed (exit 137/143). The rule "one heavy job at a
# time" was written down in ~/.claude/memories/mistakes.md - but a rule that
# only works when a human remembers it is deployment hygiene, not a fix. This
# makes the overlap impossible instead.
#
# Usage as a CLI:
# heavy_job_lock.sh --name gradle -- flutter build apk --debug
# heavy_job_lock.sh --name pytest --fail-fast -- pytest --cov
#
# Usage as a library:
# source /path/to/heavy_job_lock.sh
# with_heavy_lock gradle -- ./gradlew assembleDebug
#
# Setup (once, as root - /run is tmpfs so the lock needs recreating on boot):
# sudo heavy_job_lock.sh --install
#
# Design notes:
# - ONE system-wide lock shared between root and the user. pacman runs as
# root while builds run as the user, so a per-user /run/user/$UID lock
# would give them separate locks and no mutual exclusion at all.
# - The lock file is mode 0666 (created by root via tmpfiles.d) precisely so
# an unprivileged build can take the same lock a root pacman takes.
# - FAIL OPEN, always. A lock this central must never be able to brick
# package management or builds: if the lock file cannot be created, or the
# wait times out, we warn and run the command anyway. Blocking work forever
# is a worse failure than the memory contention we are avoiding.
# ============================================================================
HEAVY_JOB_LOCK_FILE="${HEAVY_JOB_LOCK_FILE:-/run/heavy-job.lock}"
# Long enough for a real Gradle build to finish ahead of us, short enough that
# a wedged holder cannot stall a machine indefinitely.
HEAVY_JOB_LOCK_TIMEOUT="${HEAVY_JOB_LOCK_TIMEOUT:-1800}"
HEAVY_JOB_TMPFILES_CONF="/etc/tmpfiles.d/heavy-job-lock.conf"
_hjl_warn() { printf '[heavy-job-lock] %s\n' "$*" >&2; }
# Who holds it right now? Best-effort: the holder writes "<name> <pid> <date>"
# after acquiring, so a refusal can name the culprit instead of just failing.
_hjl_holder() {
local holder=""
[[ -r $HEAVY_JOB_LOCK_FILE ]] && read -r holder <"$HEAVY_JOB_LOCK_FILE" 2>/dev/null
printf '%s' "${holder:-unknown}"
}
# Create the lock file world-writable. Only meaningful as root; unprivileged
# callers just discover they cannot and fail open.
_hjl_ensure_lock_file() {
[[ -e $HEAVY_JOB_LOCK_FILE ]] && return 0
if [[ $EUID -eq 0 ]]; then
install -m 0666 /dev/null "$HEAVY_JOB_LOCK_FILE" 2>/dev/null && return 0
else
# Braces around the redirect: a failing redirection is reported by the
# shell itself, before the command runs, so `cmd 2>/dev/null` does not
# suppress it. The group does.
{ : >"$HEAVY_JOB_LOCK_FILE"; } 2>/dev/null &&
chmod 0666 "$HEAVY_JOB_LOCK_FILE" 2>/dev/null && return 0
fi
return 1
}
# with_heavy_lock <name> [--fail-fast] -- <command...>
with_heavy_lock() {
local name="$1"
shift
local fail_fast=0
while [[ $# -gt 0 ]]; do
case "$1" in
--fail-fast)
fail_fast=1
shift
;;
--)
shift
break
;;
*) break ;;
esac
done
if [[ $# -eq 0 ]]; then
_hjl_warn "with_heavy_lock: no command given"
return 2
fi
if ! _hjl_ensure_lock_file; then
_hjl_warn "cannot create $HEAVY_JOB_LOCK_FILE - running '$name' UNSERIALISED"
_hjl_warn "run 'sudo $0 --install' once to enable serialisation"
"$@"
return $?
fi
local lock_fd
# `<>` (read-write), NOT `>`. Redirecting with `>` truncates the file at
# open time, so a second caller wiped the holder's "<name> <pid> <date>"
# line before it could read it — every refusal said "held by unknown".
exec {lock_fd}<>"$HEAVY_JOB_LOCK_FILE" || {
_hjl_warn "cannot open lock - running '$name' unserialised"
"$@"
return $?
}
local acquired=0
if ((fail_fast == 1)); then
if flock --nonblock "$lock_fd"; then
acquired=1
else
_hjl_warn "refusing to start '$name': held by $(_hjl_holder)"
exec {lock_fd}>&-
return 75 # EX_TEMPFAIL
fi
else
local holder
holder="$(_hjl_holder)"
if ! flock --nonblock "$lock_fd"; then
_hjl_warn "waiting for '$holder' to finish before starting '$name' (timeout ${HEAVY_JOB_LOCK_TIMEOUT}s)"
fi
if flock --timeout "$HEAVY_JOB_LOCK_TIMEOUT" "$lock_fd"; then
acquired=1
else
# Fail open: see the header. A stuck holder must not permanently
# block builds or package management.
_hjl_warn "timed out after ${HEAVY_JOB_LOCK_TIMEOUT}s waiting for $(_hjl_holder)"
_hjl_warn "proceeding UNSERIALISED with '$name'"
fi
fi
if ((acquired == 1)); then
printf '%s %s %s\n' "$name" "$$" "$(date -Is 2>/dev/null || echo unknown)" \
>"$HEAVY_JOB_LOCK_FILE" 2>/dev/null || true
fi
local rc=0
"$@" || rc=$?
# Closing the fd releases the lock; do it explicitly so the holder line is
# not left implying we still hold it.
exec {lock_fd}>&-
return "$rc"
}
# ----------------------------------------------------------------------------
# CLI (skipped when this file is sourced as a library)
# ----------------------------------------------------------------------------
_hjl_install() {
if [[ $EUID -ne 0 ]]; then
_hjl_warn "--install must run as root"
return 1
fi
# /run is tmpfs: without a tmpfiles.d entry the lock vanishes every boot and
# the first unprivileged caller silently falls back to unserialised.
printf 'f %s 0666 root root -\n' "$HEAVY_JOB_LOCK_FILE" >"$HEAVY_JOB_TMPFILES_CONF"
systemd-tmpfiles --create "$HEAVY_JOB_TMPFILES_CONF" 2>/dev/null || true
_hjl_ensure_lock_file || {
_hjl_warn "failed to create $HEAVY_JOB_LOCK_FILE"
return 1
}
printf 'installed %s and %s\n' "$HEAVY_JOB_TMPFILES_CONF" "$HEAVY_JOB_LOCK_FILE"
}
_hjl_usage() {
cat <<EOF
Usage: $(basename "$0") --name <label> [--fail-fast] -- <command...>
$(basename "$0") --install # once, as root
$(basename "$0") --status
Serialises heavy jobs (builds, coverage runs, pacman transactions) so only one
runs at a time on this memory-constrained machine. Always fails open.
Options:
--name <label> Name recorded as the lock holder (shown to whoever waits)
--fail-fast Refuse immediately if held, instead of waiting
--install Create the lock file + tmpfiles.d entry (root)
--status Show the current holder
EOF
}
# ${BASH_SOURCE[0]} != $0 means we were sourced - define functions, run nothing.
if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
set -uo pipefail
_name="heavy-job"
_fail_fast=()
while [[ $# -gt 0 ]]; do
case "$1" in
--name)
_name="$2"
shift 2
;;
--fail-fast)
_fail_fast=(--fail-fast)
shift
;;
--install)
_hjl_install
exit $?
;;
--status)
printf 'lock file: %s\n' "$HEAVY_JOB_LOCK_FILE"
printf 'holder: %s\n' "$(_hjl_holder)"
exit 0
;;
-h | --help)
_hjl_usage
exit 0
;;
--)
shift
break
;;
*)
_hjl_warn "unknown option: $1"
exit 2
;;
esac
done
if [[ $# -eq 0 ]]; then
_hjl_usage
exit 2
fi
with_heavy_lock "$_name" "${_fail_fast[@]}" -- "$@"
exit $?
fi