Compare commits

..
3 Commits
Author SHA1 Message Date
Felitendo 243115ea19 Show what a run is doing, and record when one is cut short
A run that held back a blocker then upgraded 214 packages printed one line and
then nothing for nearly five minutes while pacman downloaded and installed. It
looked hung, so it got killed - during pacman's uninterruptible commit phase,
which meant the packages landed but our bookkeeping never did. The menu then
kept showing a failure from a previous run on a fully up-to-date machine.

pacman, the AUR helper and flatpak now stream their output when a person is
watching, and the progress bar is left enabled for that case. Timer runs are
unchanged: quiet, --noprogressbar, everything captured in the log.

Interactivity is decided once at startup rather than tested at the point of
use. cau_pacman_flags runs inside a process substitution, so its stdout is
always a pipe and a -t 1 check there would have silently always been false.

INT/TERM/HUP now record last_result=interrupted, so a run that is stopped says
so instead of leaving the previous verdict standing.
2026-08-08 15:24:42 +02:00
Felitendo 9fd2458d20 Hold back blocking packages instead of failing the whole upgrade
A repo package that replaces something an installed AUR package still depends
on aborted the entire transaction, and would have done so on every subsequent
run - one stale AUR package was enough to cut a machine off from all updates
indefinitely. Observed in the wild: percona-server-clients replaces
libperconaserverclient without providing it, while heidisql-qt6-bin hard-depends
on it, blocking 213 unrelated package updates.

pacman names the offending package in its dependency errors, so it is now
extracted and passed to --ignore for one retry: the other 213 packages go
through and the blocker is reported. The hold is per-run, never written to
IgnorePkg, so it disappears by itself once upstream catches up.

The single retry is also now a bounded recovery loop, because fixing one
problem regularly uncovers the next - a conflict resolved with --ask=20 can
surface a dependency error behind it. Each remedy is applied at most once.

The held-back set is shown in the menu and notified only when it changes, so a
blocker waiting on an upstream fix does not produce the same message daily.
2026-08-08 15:12:43 +02:00
Felitendo f593cc1933 Menu: flip the switches without asking for a keypress
Toggling automatic updates or notifications returned to a 'press any key'
prompt for no reason - the status block at the top of the menu already shows
the new state. cau_bad and cau_note now flag that they printed something, so
the acknowledgement only appears when there is genuinely something to read
(a failed sudoers check, a missing AUR helper) instead of after every toggle.
2026-08-08 15:05:38 +02:00
7 changed files with 194 additions and 26 deletions

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
# Overridable so a packager can pass the version it is actually building # Overridable so a packager can pass the version it is actually building
# (`make VERSION=$pkgver`). The literal below is the fallback for builds # (`make VERSION=$pkgver`). The literal below is the fallback for builds
# straight from a checkout, and is what a release tag has to carry. # straight from a checkout, and is what a release tag has to carry.
VERSION ?= 1.0.3 VERSION ?= 1.0.6
PREFIX ?= /usr PREFIX ?= /usr
DESTDIR ?= DESTDIR ?=
+14
View File
@@ -243,3 +243,17 @@ msgstr ""
msgid "pacman's lock file looks left over from an interrupted update. Updates are paused until it is cleared." msgid "pacman's lock file looks left over from an interrupted update. Updates are paused until it is cleared."
msgstr "" msgstr ""
msgid "Some packages were held back"
msgstr ""
#, c-format
msgid "%s could not be updated and was skipped. Everything else is up to date."
msgstr ""
#, c-format
msgid "Held back: %s"
msgstr ""
msgid "The last run was stopped before it finished."
msgstr ""
+14
View File
@@ -244,3 +244,17 @@ msgstr "Paketdatenbank gesperrt"
msgid "pacman's lock file looks left over from an interrupted update. Updates are paused until it is cleared." msgid "pacman's lock file looks left over from an interrupted update. Updates are paused until it is cleared."
msgstr "Die Sperrdatei von pacman scheint von einem abgebrochenen Update übrig zu sein. Bis sie entfernt ist, pausieren die Updates." msgstr "Die Sperrdatei von pacman scheint von einem abgebrochenen Update übrig zu sein. Bis sie entfernt ist, pausieren die Updates."
msgid "Some packages were held back"
msgstr "Einige Pakete wurden zurückgehalten"
#, c-format
msgid "%s could not be updated and was skipped. Everything else is up to date."
msgstr "%s konnte nicht aktualisiert werden und wurde übersprungen. Alles andere ist aktuell."
#, c-format
msgid "Held back: %s"
msgstr "Zurückgehalten: %s"
msgid "The last run was stopped before it finished."
msgstr "Der letzte Lauf wurde abgebrochen, bevor er fertig war."
+31
View File
@@ -63,6 +63,21 @@ fi
cau_config_load cau_config_load
cau_log_open cau_log_open
# Record an interruption rather than leaving the previous run's verdict behind.
# Without this, killing an interactive run leaves last_result at whatever it was
# before - so the menu can keep reporting a problem from hours ago while the
# machine is in fact fully up to date, which is worse than saying nothing.
# pacman makes the commit phase itself uninterruptible, so the packages either
# all landed or none did; only our own bookkeeping is at risk here.
_cau_interrupted() {
cau_error "Update run interrupted ($1)"
cau_state_write last_result interrupted
exit 130
}
trap '_cau_interrupted SIGINT' INT
trap '_cau_interrupted SIGTERM' TERM
trap '_cau_interrupted SIGHUP' HUP
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Should this run happen at all? # Should this run happen at all?
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -202,6 +217,22 @@ else
fi fi
fi fi
# Packages skipped so the rest of the upgrade could proceed. The notification
# only fires when the set changes: a blocker waiting on an upstream fix would
# otherwise produce the same message every single day.
prev_held="$(cau_state_read held_back '')"
if [[ -n $CAU_PACMAN_HELD ]]; then
cau_state_write held_back "$CAU_PACMAN_HELD"
cau_warn "Held back: $CAU_PACMAN_HELD"
if [[ $CAU_PACMAN_HELD != "$prev_held" && $CFG_NOTIFY_ERROR == yes ]]; then
cau_notify normal "Some packages were held back" \
"%s could not be updated and was skipped. Everything else is up to date." \
"$CAU_PACMAN_HELD"
fi
else
cau_state_clear held_back
fi
if cau_pacman_reboot_needed; then if cau_pacman_reboot_needed; then
cau_state_write reboot_needed 1 cau_state_write reboot_needed 1
cau_info "A kernel update needs a restart" cau_info "A kernel update needs a restart"
+27 -4
View File
@@ -91,7 +91,14 @@ cau_msg_in() {
# Output and logging # Output and logging
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [[ -t 1 && -z ${NO_COLOR:-} ]]; then # Is a person watching? Decided once, here, while stdout is still whatever the
# process was started with. Testing `-t 1` at the point of use is unreliable:
# any function called through $(...) or <(...) sees a pipe on stdout and would
# conclude nobody is there.
CAU_INTERACTIVE=''
[[ -t 1 ]] && CAU_INTERACTIVE=1
if [[ -n $CAU_INTERACTIVE && -z ${NO_COLOR:-} ]]; then
CAU_C_RESET=$'\033[0m' CAU_C_RESET=$'\033[0m'
CAU_C_BOLD=$'\033[1m' CAU_C_BOLD=$'\033[1m'
CAU_C_DIM=$'\033[2m' CAU_C_DIM=$'\033[2m'
@@ -132,10 +139,19 @@ cau_log_open() {
CAU_LOG_OPEN=1 CAU_LOG_OPEN=1
} }
# Runs a command, streaming its combined output into the run log. Returns the # Runs a command, capturing its combined output in the run log. Returns the
# command's exit status. # command's exit status.
#
# When a person is watching - `cachy-auto-update run` from a terminal - the
# output is shown as well. Building an AUR package or pulling a few hundred
# megabytes of Flatpak can take minutes, and silence for that long is
# indistinguishable from a hang.
cau_run_logged() { cau_run_logged() {
if [[ -n ${CAU_LOG_OPEN:-} ]]; then if [[ -n ${CAU_LOG_OPEN:-} ]]; then
if [[ -n $CAU_INTERACTIVE ]]; then
"$@" 2>&1 | tee -a "$CAU_RUNLOG"
return "${PIPESTATUS[0]}"
fi
"$@" >> "$CAU_RUNLOG" 2>&1 "$@" >> "$CAU_RUNLOG" 2>&1
else else
"$@" >&2 "$@" >&2
@@ -146,11 +162,18 @@ cau_run_logged() {
# Terminal helpers for the CLI # Terminal helpers for the CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Set by cau_bad and cau_note. The menu redraws immediately after an action,
# which would wipe the screen; this marks that something was printed that the
# user still has to read, so only those cases wait for a keypress. A plain
# success needs no acknowledgement - the status block at the top of the menu
# already shows the new state.
CAU_UI_NEEDS_ACK=''
cau_say() { printf '%s\n' "$*"; } cau_say() { printf '%s\n' "$*"; }
cau_head() { printf '\n%s%s%s\n\n' "$CAU_C_BOLD$CAU_C_BLUE" "$*" "$CAU_C_RESET"; } cau_head() { printf '\n%s%s%s\n\n' "$CAU_C_BOLD$CAU_C_BLUE" "$*" "$CAU_C_RESET"; }
cau_ok() { printf '%s✔%s %s\n' "$CAU_C_GREEN" "$CAU_C_RESET" "$*"; } cau_ok() { printf '%s✔%s %s\n' "$CAU_C_GREEN" "$CAU_C_RESET" "$*"; }
cau_bad() { printf '%s✘%s %s\n' "$CAU_C_RED" "$CAU_C_RESET" "$*" >&2; } cau_bad() { CAU_UI_NEEDS_ACK=1; printf '%s✘%s %s\n' "$CAU_C_RED" "$CAU_C_RESET" "$*" >&2; }
cau_note() { printf '%s•%s %s\n' "$CAU_C_DIM" "$CAU_C_RESET" "$*"; } cau_note() { CAU_UI_NEEDS_ACK=1; printf '%s•%s %s\n' "$CAU_C_DIM" "$CAU_C_RESET" "$*"; }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# State files # State files
+18 -2
View File
@@ -81,12 +81,23 @@ cau_ui_status() {
printf '\n %s%s%s\n' "$CAU_C_YELLOW" \ printf '\n %s%s%s\n' "$CAU_C_YELLOW" \
"$(cau_msg "The last run reported a problem - see 'cachy-auto-update log'.")" \ "$(cau_msg "The last run reported a problem - see 'cachy-auto-update log'.")" \
"$CAU_C_RESET" "$CAU_C_RESET"
elif [[ $result == interrupted ]]; then
printf '\n %s%s%s\n' "$CAU_C_YELLOW" \
"$(cau_msg "The last run was stopped before it finished.")" \
"$CAU_C_RESET"
fi fi
if [[ $reboot == 1 ]]; then if [[ $reboot == 1 ]]; then
printf '\n %s%s%s\n' "$CAU_C_YELLOW" \ printf '\n %s%s%s\n' "$CAU_C_YELLOW" \
"$(cau_msg "A restart is recommended to finish a kernel update.")" \ "$(cau_msg "A restart is recommended to finish a kernel update.")" \
"$CAU_C_RESET" "$CAU_C_RESET"
fi fi
local held
held="$(cau_state_read held_back '')"
if [[ -n $held ]]; then
printf '\n %s%s%s\n' "$CAU_C_YELLOW" \
"$(cau_msg "Held back: %s" "$held")" "$CAU_C_RESET"
fi
} }
# cau_ui_status_conditions # cau_ui_status_conditions
@@ -152,17 +163,22 @@ cau_ui_menu() {
printf '%s\n' "$choice" printf '%s\n' "$choice"
case "$choice" in case "$choice" in
# The two switches flip and return straight to the menu, where the
# status block shows the result. Only a warning or an error holds
# the screen (see CAU_UI_NEEDS_ACK).
1) 1)
CAU_UI_NEEDS_ACK=''
if [[ $CFG_ENABLED == yes ]]; then cau_do_disable; else cau_do_enable; fi if [[ $CFG_ENABLED == yes ]]; then cau_do_disable; else cau_do_enable; fi
cau_pause if [[ -n $CAU_UI_NEEDS_ACK ]]; then cau_pause; fi
;; ;;
2) 2)
CAU_UI_NEEDS_ACK=''
if [[ $CFG_NOTIFICATIONS == yes ]]; then if [[ $CFG_NOTIFICATIONS == yes ]]; then
cau_do_notifications off cau_do_notifications off
else else
cau_do_notifications on cau_do_notifications on
fi fi
cau_pause if [[ -n $CAU_UI_NEEDS_ACK ]]; then cau_pause; fi
;; ;;
3) cau_do_run --force; cau_pause ;; 3) cau_do_run --force; cau_pause ;;
4) cau_do_log; cau_pause ;; 4) cau_do_log; cau_pause ;;
+89 -19
View File
@@ -10,15 +10,40 @@
CAU_PACMAN_COUNT=0 CAU_PACMAN_COUNT=0
CAU_PACMAN_PENDING='' CAU_PACMAN_PENDING=''
# Packages that had to be skipped so the rest of the upgrade could go through.
CAU_PACMAN_HELD=''
# Base flags for every unattended pacman invocation. # Base flags for every unattended pacman invocation.
cau_pacman_flags() { cau_pacman_flags() {
printf '%s\n' --noconfirm --color never --noprogressbar --disable-download-timeout printf '%s\n' --noconfirm --color never --disable-download-timeout
# A progress bar is worth having when somebody is watching a `run` from a
# terminal; in the timer's log it is only carriage-return noise.
[[ -n $CAU_INTERACTIVE ]] || printf '%s\n' --noprogressbar
local pkg local pkg
for pkg in $CFG_IGNORE_PKG; do for pkg in $CFG_IGNORE_PKG; do
printf '%s\n' --ignore "$pkg" printf '%s\n' --ignore "$pkg"
done done
} }
# _cau_pacman_exec <logfile> <pacman args...>
# Captures pacman's output for classification, and streams it as well when a
# person is watching. Upgrading a few hundred packages takes minutes; without
# this an interactive run shows one line and then nothing at all, which is
# indistinguishable from a hang and invites someone to kill it mid-transaction.
_cau_pacman_exec() {
local log="$1"
shift
if [[ -n $CAU_INTERACTIVE ]]; then
pacman "$@" 2>&1 | tee "$log"
return "${PIPESTATUS[0]}"
fi
pacman "$@" > "$log" 2>&1
}
# cau_pacman_pending # cau_pacman_pending
# Fills CAU_PACMAN_PENDING and returns 1 when there is nothing to do. # Fills CAU_PACMAN_PENDING and returns 1 when there is nothing to do.
cau_pacman_pending() { cau_pacman_pending() {
@@ -55,6 +80,8 @@ _cau_pacman_classify() {
if grep -qiE 'are in conflict|unresolvable package conflicts' "$log"; then if grep -qiE 'are in conflict|unresolvable package conflicts' "$log"; then
printf 'conflict\n' printf 'conflict\n'
elif grep -qiE 'could not satisfy dependencies|breaks dependency|unable to satisfy dependency' "$log"; then
printf 'dependency\n'
elif grep -qiE 'signature from .* is (unknown trust|marginal trust|invalid)|invalid or corrupted package \(PGP signature\)|key ".*" is unknown|keyring is not writable' "$log"; then elif grep -qiE 'signature from .* is (unknown trust|marginal trust|invalid)|invalid or corrupted package \(PGP signature\)|key ".*" is unknown|keyring is not writable' "$log"; then
printf 'keyring\n' printf 'keyring\n'
elif grep -qiE 'exists in filesystem' "$log"; then elif grep -qiE 'exists in filesystem' "$log"; then
@@ -64,6 +91,25 @@ _cau_pacman_classify() {
fi fi
} }
# _cau_pacman_blockers <logfile>
# The packages standing in the way of an otherwise fine upgrade. pacman names
# them in its dependency errors:
#
# :: removing libperconaserverclient breaks dependency 'libperconaserverclient'
# required by heidisql-qt6-bin
# :: unable to satisfy dependency 'foo' required by bar
#
# In the first form the package being removed is the one to keep; in the second
# it is the package that cannot be installed.
_cau_pacman_blockers() {
local log="$1"
{
sed -nE "s/.*removing ([^ ]+) breaks dependency.*/\\1/p" "$log"
sed -nE "s/.*unable to satisfy dependency '[^']*' required by ([^ ]+).*/\\1/p" "$log"
} | grep -E '^[A-Za-z0-9@._+-]+$' | sort -u
}
# cau_pacman_update # cau_pacman_update
# Returns 0 on success (including "nothing to do"), 1 on a failure the user # Returns 0 on success (including "nothing to do"), 1 on a failure the user
# needs to hear about. CAU_PACMAN_COUNT holds how many packages moved. # needs to hear about. CAU_PACMAN_COUNT holds how many packages moved.
@@ -90,8 +136,17 @@ cau_pacman_update() {
mapfile -t flags < <(cau_pacman_flags) mapfile -t flags < <(cau_pacman_flags)
log="$(mktemp)" || return 1 log="$(mktemp)" || return 1
if pacman -Syu "${flags[@]}" > "$log" 2>&1; then # Recovery loop rather than a single retry: fixing one problem regularly
# uncovers the next (a conflict resolved into a dependency error, say).
# Each remedy is applied at most once, so this always terminates.
local -a extra=() blockers=() tried=()
local attempt=0 b
while true; do
if _cau_pacman_exec "$log" -Syu "${flags[@]}" "${extra[@]}"; then
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null cat "$log" >> "$CAU_RUNLOG" 2>/dev/null
grep -E '^(removing|replacing) ' "$log" 2>/dev/null \
| while read -r line; do cau_info " $line"; done
rm -f "$log" rm -f "$log"
return 0 return 0
fi fi
@@ -100,6 +155,11 @@ cau_pacman_update() {
kind="$(_cau_pacman_classify "$log")" kind="$(_cau_pacman_classify "$log")"
cau_warn "pacman -Syu failed ($kind)" cau_warn "pacman -Syu failed ($kind)"
if (( ++attempt > 3 )) || [[ " ${tried[*]} " == *" $kind "* ]]; then
break
fi
tried+=("$kind")
case "$kind" in case "$kind" in
keyring) keyring)
# A stale keyring is the one failure that is always safe to fix # A stale keyring is the one failure that is always safe to fix
@@ -111,12 +171,6 @@ cau_pacman_update() {
if (( ${#keyrings[@]} )); then if (( ${#keyrings[@]} )); then
cau_run_logged pacman -Sy --noconfirm --color never "${keyrings[@]}" || true cau_run_logged pacman -Sy --noconfirm --color never "${keyrings[@]}" || true
fi fi
if pacman -Syu "${flags[@]}" > "$log" 2>&1; then
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null
rm -f "$log"
return 0
fi
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null
;; ;;
conflict) conflict)
@@ -126,18 +180,29 @@ cau_pacman_update() {
# question bitmask: 4 = CONFLICT_PKG, 16 = REMOVE_PKGS. # question bitmask: 4 = CONFLICT_PKG, 16 = REMOVE_PKGS.
if [[ $CFG_RESOLVE_CONFLICTS != yes ]]; then if [[ $CFG_RESOLVE_CONFLICTS != yes ]]; then
cau_error "Package conflict requires a decision (AutoResolveConflicts is off)" cau_error "Package conflict requires a decision (AutoResolveConflicts is off)"
rm -f "$log" break
return 1
fi fi
cau_info "Resolving package conflicts automatically and retrying" cau_info "Resolving package conflicts automatically and retrying"
if pacman -Syu "${flags[@]}" --ask=20 > "$log" 2>&1; then extra+=(--ask=20)
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null ;;
grep -E '^(removing|replacing) ' "$log" 2>/dev/null \
| while read -r line; do cau_info " $line"; done dependency)
rm -f "$log" # Something installed still depends on a package the repos want
return 0 # to drop or replace - almost always an AUR package that has not
# caught up yet. Nothing here can fix that, and it is not worth
# failing over: letting one stuck package block every other
# update indefinitely is far worse on an unattended machine.
# Hold the blockers back and upgrade everything else.
mapfile -t blockers < <(_cau_pacman_blockers "$log")
if (( ${#blockers[@]} == 0 )); then
cau_error "Dependency problem with no package to hold back"
break
fi fi
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null for b in "${blockers[@]}"; do
extra+=(--ignore "$b")
done
CAU_PACMAN_HELD="${blockers[*]}"
cau_warn "Holding back ${blockers[*]} and retrying without them"
;; ;;
filesystem) filesystem)
@@ -145,13 +210,18 @@ cau_pacman_update() {
# silently clobber something the user put there deliberately, so # silently clobber something the user put there deliberately, so
# this one stays a human decision. # this one stays a human decision.
cau_error "Files on disk conflict with the update; manual review needed" cau_error "Files on disk conflict with the update; manual review needed"
rm -f "$log" break
return 1 ;;
*)
break
;; ;;
esac esac
done
rm -f "$log" rm -f "$log"
CAU_PACMAN_COUNT=0 CAU_PACMAN_COUNT=0
CAU_PACMAN_HELD=''
return 1 return 1
} }