Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/verify-split-files.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ on:
- "tests/nodejs-key-refresh.sh"
- "tests/forge-connection.sh"
- "tests/failed-command-output.sh"
- "tests/exit-code-hints.sh"
- "tests/forge-release-json.sh"
- ".github/workflows/verify-split-files.yml"
push:
Expand All @@ -58,6 +59,7 @@ on:
- "tests/nodejs-key-refresh.sh"
- "tests/forge-connection.sh"
- "tests/failed-command-output.sh"
- "tests/exit-code-hints.sh"
- "tests/forge-release-json.sh"
- ".github/workflows/verify-split-files.yml"
workflow_dispatch:
Expand Down Expand Up @@ -113,6 +115,9 @@ jobs:
- name: Failed command output tests
run: bash tests/failed-command-output.sh

- name: Exit code hint tests
run: bash tests/exit-code-hints.sh

- name: Forge release lookup tests
run: bash tests/forge-release-json.sh

Expand Down
8 changes: 4 additions & 4 deletions api/exitcodes.func
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ explain_exit_code() {
# never reported success is someone backing out of a dialog. Every one of
# those records read "Unknown error".
0) echo "Cancelled before completion (no error)" ;;
1) echo "General error / Operation not permitted" ;;
2) echo "Misuse of shell builtins (e.g. syntax error)" ;;
3) echo "General syntax or argument error" ;;
1) echo "The command failed; its output says why" ;;
2) echo "The command failed with status 2 (a usage error for most tools); its output says why" ;;
3) echo "The command failed with status 3; its output says why" ;;
10) echo "Docker / privileged mode required (unsupported environment)" ;;

# --- curl / wget errors ---
Expand Down Expand Up @@ -133,7 +133,7 @@ explain_exit_code() {
125) echo "Command failed to start (Docker daemon or execution error)" ;;
126) echo "Command invoked cannot execute (permission problem?)" ;;
127) echo "Command not found" ;;
128) echo "Invalid argument to exit" ;;
128) echo "git: Fatal error (repository, network or permission problem)" ;;
129) echo "Killed by SIGHUP (terminal closed / hangup)" ;;
130) echo "Aborted by user (SIGINT)" ;;
131) echo "Killed by SIGQUIT (core dumped)" ;;
Expand Down
49 changes: 42 additions & 7 deletions core/error_handler.func
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ if ! declare -f explain_exit_code &>/dev/null; then
explain_exit_code() {
local code="$1"
case "$code" in
1) echo "General error / Operation not permitted" ;;
2) echo "Misuse of shell builtins (e.g. syntax error)" ;;
3) echo "General syntax or argument error" ;;
1) echo "The command failed; its output says why" ;;
2) echo "The command failed with status 2 (a usage error for most tools); its output says why" ;;
3) echo "The command failed with status 3; its output says why" ;;
10) echo "Docker / privileged mode required (unsupported environment)" ;;
4) echo "curl: Feature not supported or protocol error" ;;
5) echo "curl: Could not resolve proxy" ;;
Expand Down Expand Up @@ -123,7 +123,7 @@ if ! declare -f explain_exit_code &>/dev/null; then
125) echo "Command failed to start (Docker daemon or execution error)" ;;
126) echo "Command invoked cannot execute (permission problem?)" ;;
127) echo "Command not found" ;;
128) echo "Invalid argument to exit" ;;
128) echo "git: Fatal error (repository, network or permission problem)" ;;
129) echo "Killed by SIGHUP (terminal closed / hangup)" ;;
130) echo "Aborted by user (SIGINT)" ;;
131) echo "Killed by SIGQUIT (core dumped)" ;;
Expand Down Expand Up @@ -334,15 +334,39 @@ _eh_ok() {
# readings are only right if curl is what actually ran, so they are dropped when
# the failing command was something else.
# ------------------------------------------------------------------------------
# A tool-specific explanation only holds when the failing command is that tool:
# cargo's 101 is a panic, not an APT configuration error.
_eh_explanation() {
local exit_code="$1" command="$2" explanation
local exit_code="$1" command="$2" explanation tools
explanation="$(explain_exit_code "$exit_code")"
if [[ "$explanation" == curl:* && ! "$command" =~ (^|[[:space:]])([^[:space:]]*/)?curl([[:space:]]|$) ]]; then
tools="$(_eh_tools_for "${explanation%%:*}")"
if [[ -n "$tools" ]] && ! _eh_command_is "$command" "$tools"; then
explanation="Command failed with exit status ${exit_code}"
fi
printf '%s' "$explanation"
}

# The prefix of an explanation names the tool family it belongs to.
_eh_tools_for() {
case "$1" in
curl) echo "curl" ;;
APT | DPKG) echo "apt|apt-get|apt-cache|aptitude|dpkg|dpkg-query|dpkg-reconfigure" ;;
Node.js | npm/Node.js | npm/pnpm/yarn) echo "node|npm|npx|pnpm|yarn|corepack|bun" ;;
Python) echo "python|python3|pip|pip3|pipx|uv|uvx" ;;
PostgreSQL) echo "psql|pg_[a-z_]+|postgres" ;;
MySQL/MariaDB) echo "mysql|mariadb|mysqladmin|mariadb-admin" ;;
MongoDB) echo "mongosh|mongo|mongod" ;;
Systemd) echo "systemctl" ;;
git) echo "git" ;;
esac
}

# Does the command line run one of the tools (first word or any word, with or without a path)?
_eh_command_is() {
local command="$1" tools="$2"
[[ "$command" =~ (^|[[:space:]]|/)(${tools})([[:space:]]|$) ]]
}

# ------------------------------------------------------------------------------
# _eh_report() - the one terminal event for this execution
#
Expand Down Expand Up @@ -540,6 +564,10 @@ _eh_hint_from_log() {
_eh_warn "Hint: Run 'apt-get install -f' inside the container or check that all required repositories are configured for your distribution."
fi

elif grep -qE "ERR_PNPM_OUTDATED_LOCKFILE|ERR_PNPM_LOCKFILE_CONFIG_MISMATCH|npm ERR! code ELOCKVERIFY" <<<"$tail_txt"; then
_eh_warn "The release's lockfile does not match its package.json: an upstream packaging slip, not this container."
_eh_warn "Hint: the install script can fall back to 'pnpm install --no-frozen-lockfile' for this release."

elif grep -qE "sqv|KEYEXPIRED|NO_PUBKEY|key is not certified|signature verification failed|is not signed|Sub-process.*sqv" <<<"$tail_txt"; then
_eh_warn "APT repository signature error detected."
_eh_warn "Hint: A repository GPG key may be missing, expired, or the keyring file is not yet present (/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc etc.)."
Expand Down Expand Up @@ -637,10 +665,12 @@ error_handler() {
command="${command//\$STD/}"

# silent() knows the real failing command; the trap only sees the wrapper.
local from_silent=0
if [[ -n "${_SILENT_FAILED_RC:-}" ]]; then
exit_code="$_SILENT_FAILED_RC"
command="$_SILENT_FAILED_CMD"
line_number="$_SILENT_FAILED_LINE"
from_silent=1
unset _SILENT_FAILED_RC _SILENT_FAILED_CMD _SILENT_FAILED_LINE
fi

Expand Down Expand Up @@ -669,7 +699,12 @@ error_handler() {
_eh_write_debug_log "$exit_code" "$command" "$line_number" "$explanation"

_eh_resolve_log
_eh_print_log_tail "$_EH_ACTIVE_LOG"
# silent() captured exactly this command's output; the raw log tail is the fallback.
if ((from_silent)) && [[ -s "${_EH_ACTIVE_LOG}.errinfo" ]]; then
_eh_print_failed_output "${_EH_ACTIVE_LOG}.errinfo"
else
_eh_print_log_tail "$_EH_ACTIVE_LOG"
fi
_eh_hint_missing_command "$exit_code" "$command"
_eh_hint_node_oom "$exit_code" "$command" "$_EH_ACTIVE_LOG"
_eh_hint_from_log "$_EH_ACTIVE_LOG"
Expand Down
18 changes: 13 additions & 5 deletions incus/backend.func
Original file line number Diff line number Diff line change
Expand Up @@ -1220,12 +1220,21 @@ incus_run_install_script_with_recovery() {

echo ""
local is_oom=false is_network_issue=false is_apt_issue=false is_cmd_not_found=false
local error_explanation=""
declare -f explain_exit_code >/dev/null 2>&1 && error_explanation="$(explain_exit_code "$install_exit_code")"
local error_explanation="" failed_cmd=""
[[ -s "${TELEMETRY_ERRINFO:-}" ]] && failed_cmd="$(sed -n 's/^COMMAND=//p' "$TELEMETRY_ERRINFO" | head -n1)"
if declare -f _eh_explanation >/dev/null 2>&1; then
error_explanation="$(_eh_explanation "$install_exit_code" "$failed_cmd")"
elif declare -f explain_exit_code >/dev/null 2>&1; then
error_explanation="$(explain_exit_code "$install_exit_code")"
fi

[[ $install_exit_code -eq 134 || $install_exit_code -eq 137 || $install_exit_code -eq 243 ]] && is_oom=true
case "$install_exit_code" in
100 | 101 | 102) is_apt_issue=true ;;
100 | 101 | 102)
if [[ -z "$failed_cmd" ]] || ! declare -f _eh_command_is >/dev/null 2>&1 || _eh_command_is "$failed_cmd" "$(_eh_tools_for APT)"; then
is_apt_issue=true
fi
;;
255)
[[ -f "$combined_log" ]] && grep -qiE 'dpkg|apt-get|broken packages|unmet dependencies' "$combined_log" && is_apt_issue=true
;;
Expand All @@ -1240,12 +1249,11 @@ incus_run_install_script_with_recovery() {
if [[ $install_exit_code -eq 1 && -f "$combined_log" ]]; then
grep -qiE 'E: Unable to|broken packages|unmet dependencies|dpkg --configure' "$combined_log" && is_apt_issue=true
grep -qiE 'Cannot allocate memory|Out of memory|oom-killer|JavaScript heap' "$combined_log" && is_oom=true
grep -qiE 'Could not resolve|DNS|Network is unreachable|Failed to fetch' "$combined_log" && is_network_issue=true
grep -qiE 'Could not resolve|DNS resolution failed|Temporary failure resolving|Network is unreachable|Failed to fetch' "$combined_log" && is_network_issue=true
grep -qiE ': command not found' "$combined_log" && is_cmd_not_found=true
fi

[[ -n "$error_explanation" ]] && echo -e "${TAB}${RD}Error: ${error_explanation}${CL}" && echo ""
declare -f _eh_print_failed_output >/dev/null 2>&1 && _eh_print_failed_output "${TELEMETRY_ERRINFO:-}"
if [[ $install_exit_code -eq 10 ]]; then
echo -e "${TAB}${INFO} Privileged / nesting / device hint:"
echo -e "${TAB}${INFO} • Privileged: ${GN}security.privileged=true${CL} (USB, Docker-in-LXC)"
Expand Down
17 changes: 12 additions & 5 deletions pve/backend.func
Original file line number Diff line number Diff line change
Expand Up @@ -1497,7 +1497,7 @@ PROFILE
if grep -qiE 'Cannot allocate memory|Out of memory|oom-killer|Killed process|JavaScript heap' "$combined_log"; then
is_oom=true
fi
if grep -qiE 'Could not resolve|DNS|Connection refused|Network is unreachable|No route to host|Temporary failure resolving|Failed to fetch' "$combined_log"; then
if grep -qiE 'Could not resolve|DNS resolution failed|Connection refused|Network is unreachable|No route to host|Temporary failure resolving|Failed to fetch' "$combined_log"; then
is_network_issue=true
fi
if grep -qiE ': command not found|No such file or directory.*/s?bin/' "$combined_log"; then
Expand Down Expand Up @@ -1550,8 +1550,11 @@ PROFILE

echo ""

local error_explanation=""
if declare -f explain_exit_code >/dev/null 2>&1; then
local error_explanation="" failed_cmd=""
[[ -s "${TELEMETRY_ERRINFO:-}" ]] && failed_cmd="$(sed -n 's/^COMMAND=//p' "$TELEMETRY_ERRINFO" | head -n1)"
if declare -f _eh_explanation >/dev/null 2>&1; then
error_explanation="$(_eh_explanation "$install_exit_code" "$failed_cmd")"
elif declare -f explain_exit_code >/dev/null 2>&1; then
error_explanation="$(explain_exit_code "$install_exit_code")"
fi

Expand All @@ -1560,7 +1563,12 @@ PROFILE
fi

case "$install_exit_code" in
100 | 101 | 102) is_apt_issue=true ;;
100 | 101 | 102)
# cargo exits 101 on a panic; only an apt/dpkg command makes this an APT problem
if [[ -z "$failed_cmd" ]] || ! declare -f _eh_command_is >/dev/null 2>&1 || _eh_command_is "$failed_cmd" "$(_eh_tools_for APT)"; then
is_apt_issue=true
fi
;;
255)
if [[ -f "$combined_log" ]] && grep -qiE 'dpkg|apt-get|apt\.conf|broken packages|unmet dependencies|E: Sub-process|E: Failed' "$combined_log"; then
is_apt_issue=true
Expand Down Expand Up @@ -1597,7 +1605,6 @@ PROFILE
echo -e "${TAB}${RD}Error: ${error_explanation}${CL}"
echo ""
fi
declare -f _eh_print_failed_output >/dev/null 2>&1 && _eh_print_failed_output "${TELEMETRY_ERRINFO:-}"

if [[ $install_exit_code -eq 10 ]]; then
echo -e "${TAB}${INFO} This error usually means the container needs ${GN}privileged${CL} mode or Docker/nesting support."
Expand Down
57 changes: 57 additions & 0 deletions tests/exit-code-hints.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -uo pipefail

CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
eval "$(awk '/^explain_exit_code[(][)] [{]/,/^}/' "$CORE_DIR/api/exitcodes.func")"
eval "$(awk '/^_eh_(hint_from_log|explanation|tools_for|command_is)[(][)] [{]/,/^}/' "$CORE_DIR/core/error_handler.func")"

TEST_DIR="$(mktemp -d)"
trap 'rm -rf -- "$TEST_DIR"' EXIT
LOG="$TEST_DIR/log"
WARN="$TEST_DIR/warn"
passed=0 failed=0
_eh_warn() { echo "$1" >>"$WARN"; }

expect() {
if [[ "$2" == "$3" ]]; then
echo "PASS $1"
passed=$((passed + 1))
else
echo "FAIL $1: expected <$2>, got <$3>" >&2
failed=$((failed + 1))
fi
}

# Exit status 1 is the command's own verdict; errno 1 (EPERM) is something else.
expect "exit 1 is not explained as EPERM" 0 "$(explain_exit_code 1 | grep -c 'Operation not permitted')"
expect "the container fallback table agrees" 0 "$(grep -E '^ *1[)] echo' "$CORE_DIR/core/error_handler.func" | grep -c 'Operation not permitted')"

# A tool-specific meaning holds only for that tool; everything else gets the plain status.
expect "cargo 101 is not an APT error" "Command failed with exit status 101" "$(_eh_explanation 101 'cargo build --release')"
expect "apt-get 101 keeps the APT text" APT "$(_eh_explanation 101 'apt-get install -y jq' | cut -d: -f1)"
expect "a core wrapper returning 7 is not curl" "Command failed with exit status 7" "$(_eh_explanation 7 'fetch_and_deploy_gh_release app owner/repo')"
expect "curl 7 with a path keeps the curl text" curl "$(_eh_explanation 7 '/usr/bin/curl -fsSL https://x' | cut -d: -f1)"
expect "git 128 is a git fatal error" git "$(_eh_explanation 128 'git clone https://x' | cut -d: -f1)"
expect "pnpm 128 is not" "Command failed with exit status 128" "$(_eh_explanation 128 'pnpm build')"
expect "node 243 keeps the Node text" Node.js "$(_eh_explanation 243 'node server.js' | cut -d: -f1)"
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')"

: >"$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 <ROOT>/server/package.json' >"$LOG"
_eh_hint_from_log "$LOG"
expect "a stale pnpm lockfile is named" 1 "$(grep -c 'lockfile does not match' "$WARN")"
expect "the fallback is suggested" 1 "$(grep -c 'no-frozen-lockfile' "$WARN")"

: >"$WARN"
printf '%s\n' '[OK] Git DNS: github.com:(ok)' '[ERROR] in line 34: exit code 1' >"$LOG"
_eh_hint_from_log "$LOG"
expect "the network check line raises no hint" 0 "$(wc -l <"$WARN" | tr -d ' ')"

# The host classifies the combined log itself; a bare DNS used to match the network check line.
expect "pve host grep no longer matches the word DNS alone" 0 "$(grep -c 'resolve|DNS|' "$CORE_DIR/pve/backend.func")"
expect "incus host grep no longer matches the word DNS alone" 0 "$(grep -c 'resolve|DNS|' "$CORE_DIR/incus/backend.func")"

printf '%s passed, %s failed\n' "$passed" "$failed"
((failed == 0))
27 changes: 27 additions & 0 deletions tests/failed-command-output.sh
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,32 @@ expect "an empty output section prints nothing" "" "$(_eh_print_failed_output "$
expect "a missing errinfo prints nothing" "" "$(_eh_print_failed_output "$TEST_DIR/nope" 2>&1)"
expect "no errinfo path prints nothing" "" "$(_eh_print_failed_output "" 2>&1)"

# --- the container handler prefers the captured output over the raw log tail ---
eval "$(awk '/^(error_handler|_eh_resolve_log|_eh_print_log_tail)[(][)] [{]/,/^}/' "$CORE_DIR/core/error_handler.func")"
_eh_explanation() { echo "explained"; }
_eh_report() { :; }
_eh_write_debug_log() { :; }
_eh_hint_missing_command() { :; }
_eh_hint_node_oom() { :; }
_eh_hint_from_log() { :; }
_is_container_context() { return 0; }
msg_error() { echo "error: $*"; }
handler_output() {
# error_handler exits, so it runs in a subshell with the state silent() leaves behind.
(
export _SILENT_FAILED_RC=1 _SILENT_FAILED_CMD="pnpm install" _SILENT_FAILED_LINE=34 _SILENT_FAILED_LOG="$LOGFILE"
error_handler 1 "silent pnpm install" 2>&1
)
}
reset
printf '%s\n' "[INFO] Building" "cargo noise" "ERR_PNPM_OUTDATED_LOCKFILE" >"$LOGFILE"
printf 'EXIT_CODE=1\nLINE=34\nCOMMAND=pnpm install\n--- OUTPUT ---\nERR_PNPM_OUTDATED_LOCKFILE\n' >"$LOGFILE.errinfo"
out="$(handler_output)"
expect "with errinfo the handler shows the command output" 1 "$(printf '%s\n' "$out" | grep -c 'Output of the failing command')"
expect "and leaves out the raw log tail" 0 "$(printf '%s\n' "$out" | grep -c 'Last 20 lines of log')"
rm -f "$LOGFILE.errinfo"
out="$(handler_output)"
expect "without errinfo the raw tail remains" 1 "$(printf '%s\n' "$out" | grep -c 'Last 20 lines of log')"

printf '%s passed, %s failed\n' "$passed" "$failed"
((failed == 0))
2 changes: 2 additions & 0 deletions ui/API.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ _dev_step_end
_dev_step_key
_dev_step_start
_dev_timing_dir
_eh_command_is
_eh_destroy_container
_eh_explanation
_eh_hint_from_log
Expand All @@ -31,6 +32,7 @@ _eh_quiet_spinner
_eh_report
_eh_resolve_log
_eh_signal_exit
_eh_tools_for
_eh_warn
_eh_write_debug_log
_get_current_ip
Expand Down
Loading