Show the run log's last lines under Details, live

The percentage says an update is alive. It does not say what it is alive
doing, and for the longest stretch of a run there was no way to find out:
an AUR package compiling for a quarter of an hour talks constantly, and
every word of it went into a file nobody was looking at.

So the run log's last five lines now travel with the progress entry and
sit under Details, refreshed every two seconds for as long as the update
lasts. Polled rather than followed, for the same reason the pacman
watcher polls: only the last few lines are ever displayed, so every line
in between would be work nobody sees, and re-reading the tail whole makes
truncation and rotation of the log a non-event.

The job model behind this interface carries exactly two description
fields - descriptionValue1 and 2, there is no third - so one stays with
the package being worked on and the other takes the tail. Newlines inside
a field do render, which is what lets five lines share one of them. They
travel tab separated because the protocol is one instruction per line,
and a tab inside a log line becomes a space first: either renders as
whitespace, but a line split in half renders as nonsense. Five lines
clipped to 120 columns also keeps an instruction well inside PIPE_BUF,
which is what makes the write atomic against the runner sending its own
progress down the same pipe.
This commit is contained in:
Felitendo committed 2026-08-20 19:55:43 +02:00
1 parent f9cd8a0ace
commit c8666da714
7 files changed
+118 -2

No files matched your search

+8
View File
@@ -228,6 +228,14 @@ Two things about how it is put together:
instead of leaping 30% the moment unpacking starts — and likewise for AUR instead of leaping 30% the moment unpacking starts — and likewise for AUR
with nothing pending, or a machine with no Flatpaks. The weights only ever with nothing pending, or a machine with no Flatpaks. The weights only ever
have to be right about the steps that actually run. have to be right about the steps that actually run.
- **Expanding *Details* shows the run log's last five lines, live.** The
percentage says the update is alive; these say what it is alive doing. It
matters most where there is nothing to count — an AUR package compiling for
a quarter of an hour talks constantly, and all of it used to go into a file
nobody was looking at. The job model behind this interface carries exactly
two description fields (`descriptionValue1` and `2` — there is no third), so
one holds the current package and the other the tail; newlines inside a value
do render, which is what makes five lines fit in one field.
- pacman's output is line-buffered through `stdbuf`. Writing to a log rather - pacman's output is line-buffered through `stdbuf`. Writing to a log rather
than a terminal, libc would hand it over in 4 KB blocks instead, and 4 KB of than a terminal, libc would hand it over in 4 KB blocks instead, and 4 KB of
`upgrading foo...` is on the order of a hundred and sixty packages arriving `upgrading foo...` is on the order of a hundred and sixty packages arriving
+7
View File
@@ -131,6 +131,13 @@ stuck update. On a domestic line the download is then the longest of the three,
and a bar that called the whole thing "installing" would sit near its beginning and a bar that called the whole thing "installing" would sit near its beginning
for minutes at a time looking stuck. for minutes at a time looking stuck.
Expanding "Details" shows the last five lines of the run log as they are
written, alongside the package currently being worked on. The percentage says
the update is alive; these lines say what it is alive doing, which matters most
during the stretches that have nothing countable to report - an AUR package
being compiled, above all. The job interface carries exactly two description
fields, so those are the two things shown.
A step that turns out to have no work is dropped from the bar instead of being A step that turns out to have no work is dropped from the bar instead of being
handed its share for nothing. Packages already in the cache are never handed its share for nothing. Packages already in the cache are never
announced, so a run with everything already fetched skips the download step announced, so a run with everything already fetched skips the download step
+3
View File
@@ -258,6 +258,9 @@ msgstr ""
msgid "Cleaning up after the update" msgid "Cleaning up after the update"
msgstr "" msgstr ""
msgid "Log"
msgstr ""
msgid "Package" msgid "Package"
msgstr "" msgstr ""
+3
View File
@@ -259,6 +259,9 @@ msgstr "AppImages werden aktualisiert"
msgid "Cleaning up after the update" msgid "Cleaning up after the update"
msgstr "Aufräumen nach dem Update" msgstr "Aufräumen nach dem Update"
msgid "Log"
msgstr "Protokoll"
msgid "Package" msgid "Package"
msgstr "Paket" msgstr "Paket"
+11
View File
@@ -8,6 +8,7 @@
# #
# info<TAB>text headline, already translated by the caller # info<TAB>text headline, already translated by the caller
# detail<TAB>name<TAB>value a labelled line under "Details" # detail<TAB>name<TAB>value a labelled line under "Details"
# log<TAB>name<TAB>line<TAB>line... the run log's last lines, one per field
# total<TAB>n how many items this step has # total<TAB>n how many items this step has
# done<TAB>n how many of them are finished # done<TAB>n how many of them are finished
# percent<TAB>n overall progress, 0-100 # percent<TAB>n overall progress, 0-100
@@ -113,6 +114,16 @@ def main():
elif cmd == "detail" and len(fields) > 2: elif cmd == "detail" and len(fields) > 2:
job.call("setDescriptionField", job.call("setDescriptionField",
GLib.Variant("(uss)", (0, arg, fields[2]))) GLib.Variant("(uss)", (0, arg, fields[2])))
elif cmd == "log" and len(fields) > 2:
# Field 1, and there is no field 2: the job model behind this
# interface carries exactly two, and anything further is
# discarded without complaint at the other end.
#
# The tail arrives one line per field, because the protocol
# itself is one instruction per line. Joined back up with the
# newlines the job view does render.
job.call("setDescriptionField",
GLib.Variant("(uss)", (1, arg, "\n".join(fields[2:]))))
elif cmd == "total": elif cmd == "total":
job.call("setTotalAmount", GLib.Variant("(ts)", (int(arg), unit))) job.call("setTotalAmount", GLib.Variant("(ts)", (int(arg), unit)))
elif cmd == "done": elif cmd == "done":
+5
View File
@@ -192,6 +192,11 @@ progress_steps=(resolve download repo)
progress_steps+=(cleanup) progress_steps+=(cleanup)
cau_progress_begin "${progress_steps[@]}" cau_progress_begin "${progress_steps[@]}"
# And carry the run log's last lines along with it, so "Details" shows what the
# update is doing during the stretches that have nothing to count - an AUR
# package building for a quarter of an hour, most of all.
cau_progress_tail_start "$CAU_RUNLOG"
if ! cau_pacman_update; then if ! cau_pacman_update; then
failed=1 failed=1
fi fi
+81 -2
View File
@@ -367,6 +367,83 @@ cau_progress_detail() {
done done
} }
# How many lines of the run log the job entry carries, and how wide each one
# is allowed to be. Both are display limits rather than arbitrary ones: five
# lines is about what fits under a notification popup before it starts pushing
# the buttons off the bottom, and a line long enough to be elided anyway is
# only costing room in the pipe. Together they also keep one instruction well
# inside PIPE_BUF, which is what makes the write atomic against the runner
# writing its own progress down the same pipe.
CAU_PROGRESS_TAIL_LINES=5
CAU_PROGRESS_TAIL_COLS=120
CAU_PROGRESS_TAIL_PID=0
# cau_progress_log <label-msgid> <text>
# The second description field, holding the last few lines of the run log.
#
# The protocol is one instruction per line, so the tail travels tab separated
# and is put back together on the other side. Tabs inside a log line would
# split it in two on the way, so they become spaces first - the job view
# renders either as whitespace, and a line broken in half renders as nonsense.
cau_progress_log() {
local label="$1" text="$2" i fd
(( ${#CAU_PROGRESS_FDS[@]} )) || return 0
text="${text//$'\t'/ }"
text="${text//$'\n'/$'\t'}"
for i in "${!CAU_PROGRESS_FDS[@]}"; do
fd="${CAU_PROGRESS_FDS[$i]}"
[[ -n $fd ]] || continue
cau_msg_into "${CAU_PROGRESS_LOCALES[$i]}" "$label"
printf 'log\t%s\t%s\n' "$CAU_MSG_RESULT" "$text" >&"$fd" 2>/dev/null || true
done
}
# cau_progress_tail_start <logfile> / cau_progress_tail_stop
# Follows the run log for as long as the update lasts, so expanding "Details"
# shows what the update is actually doing right now.
#
# This is the answer to the part of a run that has no counter and never will:
# an AUR helper compiling for a quarter of an hour says plenty about what it is
# up to, none of it countable, and all of it going into a log file nobody is
# looking at. The percentage says the update is alive; these lines say what it
# is alive doing.
#
# Polled rather than followed with tail -F, for the same reason the pacman
# watcher polls: only the last few lines are ever displayed, so every line in
# between is work nobody would see. Re-read whole and compared, which also
# makes truncation and rotation of the log a non-event.
cau_progress_tail_start() {
local file="$1"
cau_progress_tail_stop
(( ${#CAU_PROGRESS_FDS[@]} )) || return 0
cau_have tail || return 0
{
local nap last='' now
exec {nap}<> <(:)
while :; do
read -r -t 2 -u "$nap" _ || true
now="$(tail -n "$CAU_PROGRESS_TAIL_LINES" "$file" 2>/dev/null \
| cut -c "1-${CAU_PROGRESS_TAIL_COLS}")"
[[ -n $now && $now != "$last" ]] || continue
last="$now"
cau_progress_log "Log" "$now"
done
} &
CAU_PROGRESS_TAIL_PID=$!
}
cau_progress_tail_stop() {
(( CAU_PROGRESS_TAIL_PID )) || return 0
kill "$CAU_PROGRESS_TAIL_PID" 2>/dev/null
wait "$CAU_PROGRESS_TAIL_PID" 2>/dev/null
CAU_PROGRESS_TAIL_PID=0
}
# cau_progress_end [outcome: ok|failed] [failure-msgid] # cau_progress_end [outcome: ok|failed] [failure-msgid]
# Closes the entry. Must run on every exit path, including a killed run: an # Closes the entry. Must run on every exit path, including a killed run: an
# entry whose owner merely vanishes is reported by the desktop as "the # entry whose owner merely vanishes is reported by the desktop as "the
@@ -386,9 +463,11 @@ cau_progress_end() {
local outcome="${1:-ok}" msgid="${2:-}" local outcome="${1:-ok}" msgid="${2:-}"
local i fd local i fd
# Before the descriptors go: a ticker still running would be writing into # Before the descriptors go: anything still running in the background holds
# a pipe whose reader is about to be waited on. # its own copy of them, so the helper would not see the end of its input
# until it exited - and it is about to be waited on.
cau_progress_creep_stop cau_progress_creep_stop
cau_progress_tail_stop
# The last step never consumes its own share - nothing reports items for # The last step never consumes its own share - nothing reports items for
# the cleanup - so the bar would stop a few percent short of the end and # the cleanup - so the bar would stop a few percent short of the end and