From 5bb6d9e16373a13abaaeb5bccbdb43670b506802 Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:37:26 +0200 Subject: [PATCH 1/2] Telemetry: explain and categorize by the command that failed The summary ran the exit code through the table alone, so systemctl's 5 arrived as "curl: Could not resolve proxy" and pct exec's 255 as "DPKG: Fatal internal error", and the server's text classifier filed them as network and apt. The summary now uses the handler's tool guard, systemctl 5 is named for what it is, and systemctl and pct/qm failures carry their own categories. --- api/API.txt | 1 + api/errorlog.func | 12 +++++++++++- api/exitcodes.func | 23 ++++++++++++++++------- api/telemetry.func | 2 +- core/error_handler.func | 3 +++ docs/api.md | 6 +++++- tests/exit-code-hints.sh | 20 +++++++++++++++++++- 7 files changed, 56 insertions(+), 11 deletions(-) diff --git a/api/API.txt b/api/API.txt index 6aa9e8f..73fde43 100644 --- a/api/API.txt +++ b/api/API.txt @@ -1,6 +1,7 @@ _telemetry_report_exit _tm_clean_log _tm_enabled +_tm_explain _tm_find_errinfo _tm_payload _tm_pick_logfile diff --git a/api/errorlog.func b/api/errorlog.func index f7b59a9..c894d3b 100644 --- a/api/errorlog.func +++ b/api/errorlog.func @@ -278,6 +278,16 @@ write_errinfo() { # * $1: exit_code # * $2: log_text (optional override for the output section) # ------------------------------------------------------------------------------ +# The table's curl and APT readings only hold when that tool ran. The error +# handler's guard knows which; the host always has it loaded. +_tm_explain() { + if [[ -n "${2:-}" ]] && declare -f _eh_explanation >/dev/null 2>&1; then + _eh_explanation "$1" "$2" + else + explain_exit_code "$1" + fi +} + build_error_string() { local exit_code="${1:-1}" local log_text="${2:-}" @@ -305,7 +315,7 @@ build_error_string() { [[ -z "$cmd" && -n "${FAILED_COMMAND:-}" ]] && cmd="${FAILED_COMMAND}" [[ -z "$line" && -n "${FAILED_LINE:-}" ]] && line="${FAILED_LINE}" - explanation=$(explain_exit_code "$exit_code") + explanation=$(_tm_explain "$exit_code" "$cmd") if [[ -n "$cmd" ]]; then if [[ -n "$line" ]]; then diff --git a/api/exitcodes.func b/api/exitcodes.func index f0b24bc..ad83c87 100644 --- a/api/exitcodes.func +++ b/api/exitcodes.func @@ -271,7 +271,22 @@ categorize_error() { return fi - local code="$1" + local code="$1" command="${2:-${FAILED_COMMAND:-}}" errinfo tool + case "$code" in + 113 | 114 | 122 | 129 | 130 | 143 | 253 | 254) echo "user_aborted"; return ;; + 134 | 137) echo "resource"; return ;; + 139 | 141) echo "signal"; return ;; + esac + # The tool that ran says more than a status several tools share. + if [[ -z "$command" ]] && declare -f _tm_find_errinfo >/dev/null 2>&1 && errinfo=$(_tm_find_errinfo); then + command=$(sed -n 's/^COMMAND=//p' "$errinfo" | head -n1) + fi + tool="${command%% *}" + tool="${tool##*/}" + case "$tool" in + systemctl) echo "service"; return ;; + pct | qm | pvesm | pvesh | pveam) echo "proxmox"; return ;; + esac case "$code" in 6 | 7 | 22 | 35) echo "network" ;; 10) echo "config" ;; @@ -284,12 +299,6 @@ categorize_error() { 150 | 151 | 153 | 154) echo "service" ;; 170 | 171 | 172 | 173 | 180 | 181 | 182 | 183 | 190 | 191 | 192 | 193) echo "database" ;; 243 | 245 | 246 | 247 | 248 | 249) echo "runtime" ;; - # Kept in step with the aborted branch of post_update_to_api: a code that - # makes the run "aborted" must not describe itself as something else here. - - 113 | 114 | 122 | 129 | 130 | 143 | 253 | 254) echo "user_aborted" ;; - 134 | 137) echo "resource" ;; - 139 | 141) echo "signal" ;; 1 | 2) echo "shell" ;; *) echo "unknown" ;; esac diff --git a/api/telemetry.func b/api/telemetry.func index 1d9fd3e..899149c 100644 --- a/api/telemetry.func +++ b/api/telemetry.func @@ -394,7 +394,7 @@ post_update_to_api() { # Retry with a smaller error trace (in case the payload was the problem) if [[ -n "$error_raw" ]]; then local short_error - short_error="exit_code=${exit_code} | $(explain_exit_code "$exit_code")" + short_error="exit_code=${exit_code} | $(_tm_explain "$exit_code" "${FAILED_COMMAND:-}")" [[ -n "${FAILED_COMMAND:-}" ]] && short_error+=" | at line ${FAILED_LINE:-?}: $(printf '%s' "$FAILED_COMMAND" | head -c 200)" payload=$(_tm_payload "$pb_status" "$exit_code" "$short_error") if _tm_send "$payload" "$STATUS_TIMEOUT" 2; then diff --git a/core/error_handler.func b/core/error_handler.func index a79a367..10cd66b 100644 --- a/core/error_handler.func +++ b/core/error_handler.func @@ -343,6 +343,9 @@ _eh_explanation() { if [[ -n "$tools" ]] && ! _eh_command_is "$command" "$tools"; then explanation="Command failed with exit status ${exit_code}" fi + if [[ "$exit_code" == 5 ]] && _eh_command_is "$command" "systemctl"; then + explanation="Systemd: unit not found" + fi printf '%s' "$explanation" } diff --git a/docs/api.md b/docs/api.md index 26e208a..3cb5bdd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,7 +19,11 @@ terminal status: it writes `/root/.install-.failed` and an `explain_exit_code` — the exit code table — and `categorize_error`. This is the authoritative copy of the table; `core/error_handler.func` carries a fallback -for the container case where this file is not loaded. +for the container case where this file is not loaded. The table borrows curl's +and APT's numbering, so the telemetry summary (`_tm_explain` in +`errorlog.func`) runs it through the handler's tool guard when the failing +command is known, and `categorize_error` files `systemctl` and `pct`/`qm` +failures under their own categories rather than the code's. ## [`errorlog.func`](../api/errorlog.func) diff --git a/tests/exit-code-hints.sh b/tests/exit-code-hints.sh index 7f5b80b..98b1a02 100644 --- a/tests/exit-code-hints.sh +++ b/tests/exit-code-hints.sh @@ -2,8 +2,11 @@ set -uo pipefail CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -eval "$(awk '/^explain_exit_code[(][)] [{]/,/^}/' "$CORE_DIR/api/exitcodes.func")" +eval "$(awk '/^(explain_exit_code|categorize_error)[(][)] [{]/,/^}/' "$CORE_DIR/api/exitcodes.func")" eval "$(awk '/^_eh_(hint_from_log|explanation|tools_for|command_is)[(][)] [{]/,/^}/' "$CORE_DIR/core/error_handler.func")" +eval "$(awk '/^(_tm_explain|build_error_string)[(][)] [{]/,/^}/' "$CORE_DIR/api/errorlog.func")" +_tm_find_errinfo() { return 1; } +get_error_log() { :; } TEST_DIR="$(mktemp -d)" trap 'rm -rf -- "$TEST_DIR"' EXIT @@ -37,6 +40,21 @@ expect "node 243 keeps the Node text" Node.js "$(_eh_explanation 243 'node serve expect "python 243 does not" "Command failed with exit status 243" "$(_eh_explanation 243 'python3 app.py')" expect "core codes stay as they are" Proxmox "$(_eh_explanation 209 '' | cut -d: -f1)" expect "unprefixed codes stay as they are" "Command not found" "$(_eh_explanation 127 'foo')" +expect "systemctl 5 is a missing unit" "Systemd: unit not found" "$(_eh_explanation 5 'systemctl start portainer')" + +# The telemetry summary goes through the same guard when the command is known. +expect "the summary does not call systemctl 5 a proxy error" "exit_code=5 | Systemd: unit not found | at line 76: systemctl start portainer" "$(FAILED_COMMAND='systemctl start portainer' FAILED_LINE=76 build_error_string 5)" +expect "pct exec 255 is not a DPKG error" "exit_code=255 | Command failed with exit status 255 | at line 12: pct exec 100 -- ip a" "$(FAILED_COMMAND='pct exec 100 -- ip a' FAILED_LINE=12 build_error_string 255)" +expect "apt 100 keeps the APT text" APT "$(FAILED_COMMAND='apt install -y grafana' FAILED_LINE=31 build_error_string 100 | cut -d'|' -f2 | cut -d: -f1 | tr -d ' ')" +expect "without a command the table stands" "exit_code=255 | DPKG: Fatal internal error" "$(FAILED_COMMAND= build_error_string 255)" + +# The category follows the tool where the code is shared. +expect "systemctl is a service failure" service "$(categorize_error 5 'systemctl start portainer')" +expect "pct exec is a Proxmox failure" proxmox "$(categorize_error 255 'pct exec 100 -- ip a')" +expect "a kill stays a resource failure whatever ran" resource "$(categorize_error 137 'pct exec 100 -- cargo build')" +expect "apt keeps its category" dependency "$(categorize_error 100 'apt install -y grafana')" +expect "an abort stays an abort" user_aborted "$(categorize_error 130 'systemctl start x')" +expect "no command: the table" shell "$(FAILED_COMMAND= categorize_error 1)" : >"$WARN" printf '%s\n' 'Scope: all 36 workspace projects' ' ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with /server/package.json' >"$LOG" From 6715f87a5e3c635546337caede458499fcc6637e Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:42:01 +0200 Subject: [PATCH 2/2] Add _tm_explain to the ui API snapshot --- ui/API.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/API.txt b/ui/API.txt index eb7aaf1..e878cb7 100644 --- a/ui/API.txt +++ b/ui/API.txt @@ -53,6 +53,7 @@ _stop_container_if_installing _telemetry_report_exit _tm_clean_log _tm_enabled +_tm_explain _tm_find_errinfo _tm_payload _tm_pick_logfile