testsAndMisc/linux_configuration/scripts/periodic_background/system-maintenance/bin/browser-preexec-wrapper.sh
Krzysztof kuhy Rudnicki b0c7baf22e
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Log why Chromium browsers die; whitelist librewolf
Thorium was "closing and dying randomly" with no evidence anywhere.
Two independent structural causes, both now fixed:

  1. Crashpad was never enabled -- no crash_reporting consent key and an
     empty crash DB, so no minidump was ever written.
  2. stderr from a dmenu/.desktop launch reaches no terminal and no
     journal. The only Thorium lines ever seen in the journal came from a
     systemd-launched instance, which is why they survived.

browser-preexec-wrapper.sh now gives every Chromium-family launch its own
log under $XDG_STATE_HOME/browser-crash-logs: a VRAM/RAM/kernel/driver
snapshot before start, the browser's stderr, and on exit the exit code,
decoded signal, and a SECOND VRAM snapshot. Those two snapshots plus the
signal are the discriminator between the live hypotheses -- a VRAM death
shows NVRM/NV_ERR_NO_MEMORY or near-full VRAM at exit, a stale-binary
death shows SIGSEGV with the GPU idle. Root cause is deliberately NOT
asserted here; the next crash decides it from evidence.

Three details that are load-bearing rather than incidental:

  - The exit record is written from a signal trap, not inline after
    `wait`. If the wrapper itself is killed, an inline block never runs
    and the log ends mid-stream with no verdict -- indistinguishable
    from a browser that is still running.
  - The browser is a direct redirect, NOT a pipe into a size filter: in
    a pipeline `wait` reports the last element's status, discarding the
    browser's signal, which is the one fact worth capturing.
  - Logs are capped by bytes as well as count. Measured: ~2 MB per 45s
    of navigation (65% VERBOSE1 net/TLS chatter), ~0 while idle. Count
    alone would not bound disk use, and a full disk causes exactly the
    random deaths under investigation.

setup_periodic_system.sh: rewrite Chromium "app mode" .desktop entries
(Exec=/opt/<browser>/...) to go through /usr/local/bin. These are
auto-generated and bypass the wrapper entirely, so neither the flags file
nor the logging applied to them -- five Thorium entries were found still
launching without the --disable-gpu-compositing mitigation added on
2026-07-25, two weeks after the fact. Runs on every install because
Chromium regenerates the files.

pacman_whitelist.txt: allow the exact package name `librewolf`. The
`librewolf` keyword stays blocked, as do all other Firefox forks. It is
LeechBlock-enforced via /etc/librewolf/policies (force_installed), so
this is not an unfiltered escape hatch; it is needed because Chromium's
MV2 sunset ends full uBlock Origin with all filter lists, which the Gecko
build keeps.

Verified: shellcheck clean, no lint suppressions; fake-browser SIGSEGV
and wrapper-SIGTERM both produce a verdict; trim watchdog bounds a
runaway log; kill -SEGV on the real /opt/thorium-browser/thorium wrote
"exit code: 139 / died on signal: 11 (SEGV)"; librewolf installs and
loads with uBO + LeechBlock active.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ME5ZkgWjFvPZrUwMpVZM3e
2026-08-09 22:27:16 +02:00

192 lines
7.7 KiB
Bash
Executable File

#!/bin/bash
# Generic pre-exec wrapper for browsers: ensures /etc/hosts is (re)installed
# before launching the actual browser binary, and — for Chromium-family
# browsers — captures a crash log for every launch.
#
# Why the crash logging exists (diagnosed 2026-08-09):
# Thorium was "closing and dying randomly" with NO evidence anywhere:
# Crashpad was never enabled (no crash_reporting consent key, empty crash DB),
# and stderr from a dmenu/.desktop launch goes to no terminal and no journal.
# The only Thorium lines ever seen in the journal came from a systemd-launched
# instance. So the browser could die repeatedly and leave nothing behind.
#
# Each launch now gets its own log holding: a VRAM/RAM snapshot before start,
# the browser's own stderr (--enable-logging=stderr --v=1), and — on exit —
# the exit code, decoded signal, and a SECOND VRAM snapshot. That last pair is
# the discriminator between the two live hypotheses: a GPU/VRAM death shows
# NVRM/NV_ERR_NO_MEMORY or near-full VRAM at exit, whereas a stale-binary
# death shows SIGSEGV/SIGILL with the GPU idle.
set -euo pipefail
HOSTS_INSTALL_SCRIPT="__HOSTS_INSTALL_SCRIPT__"
# Where per-launch logs go, and how many to keep.
readonly LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/browser-crash-logs"
readonly LOG_KEEP=20
# Per-log byte cap. Measured 2026-08-09: Thorium writes ~2 MB in the first 45s
# (65% of it VERBOSE1 net/TLS chatter) and ~0 while idle, so cost scales with
# navigation, not wall-clock. Capping COUNT alone is therefore not enough — a
# heavy browsing day could fill the disk, and a full disk causes exactly the
# kind of random death this logging exists to diagnose.
#
# Enforced by the watchdog below, which keeps the NEWEST bytes: the lines
# immediately before death are the diagnostic ones, so trimming must drop the
# head, never the tail.
readonly LOG_MAX_BYTES=$((32 * 1024 * 1024))
prog_name="$(basename "$0")"
real_bin="/usr/bin/${prog_name}"
# If run directly (not via a browser symlink) or if the target binary doesn't exist,
# allow passing the real browser command as the first argument for testing:
if [[ ! -x $real_bin || $prog_name == "browser-preexec-wrapper.sh" ]]; then
if [[ $# -ge 1 ]]; then
real_bin="$1"
shift
else
echo "Error: could not resolve real browser binary for '$prog_name'." >&2
echo "Usage (testing): $0 <real-browser-command> [args...]" >&2
echo "Typical install: symlink this script as /usr/local/bin/<browser> so it wraps /usr/bin/<browser>." >&2
exit 127
fi
fi
# Best-effort: install hosts file quietly; don't block browser startup
if command -v sudo >/dev/null 2>&1; then
sudo -n "$HOSTS_INSTALL_SCRIPT" >/dev/null 2>&1 || true
else
"$HOSTS_INSTALL_SCRIPT" >/dev/null 2>&1 || true
fi
# Only Chromium-family browsers understand --enable-logging/--v; everything else
# (firefox, librewolf) is exec'd unchanged so this wrapper stays generic.
is_chromium_family() {
case "$prog_name" in
thorium-browser | google-chrome | google-chrome-stable | chromium | brave | brave-browser | vivaldi-stable)
return 0
;;
*) return 1 ;;
esac
}
# GPU + RAM state. Written before launch and again at exit so the two can be
# compared; nvidia-smi may be absent (or the GPU wedged), so never fail here.
emit_resource_snapshot() {
local label="$1"
echo "--- ${label} $(date '+%Y-%m-%d %H:%M:%S') ---"
if command -v nvidia-smi >/dev/null 2>&1; then
echo -n "VRAM used/total: "
nvidia-smi --query-gpu=memory.used,memory.total --format=csv,noheader 2>&1 || echo "nvidia-smi failed"
else
echo "VRAM: nvidia-smi unavailable"
fi
echo -n "RAM: "
free -m | awk '/^Mem:/ {printf "%s MiB used / %s MiB total\n", $3, $2}'
echo "kernel: $(uname -r)"
# Match the dotted version anywhere on the line rather than by field index:
# the surrounding wording differs between open/proprietary driver builds.
# Worth logging because a module/userspace version skew (nvidia-utils upgraded
# without a reboot) is a known cause of GL crashes in Chromium.
if [[ -r /proc/driver/nvidia/version ]]; then
echo -n "nvidia module: "
grep -oE '[0-9]+\.[0-9]+\.[0-9]+' /proc/driver/nvidia/version 2>/dev/null | head -1 || echo "unreadable"
fi
}
# Keep only the newest $keep logs. Uses -printf/sort -z so filenames containing
# spaces or newlines can't split a record. Called with LOG_KEEP-1 before a new
# log is created, so the directory settles at exactly LOG_KEEP afterwards.
rotate_logs() {
local keep="$1"
local old
while IFS= read -r -d '' old; do
rm -f -- "$old"
done < <(
find "$LOG_DIR" -maxdepth 1 -type f -name '*.log' -printf '%T@\t%p\0' 2>/dev/null |
sort -zrn |
tail -zn "+$((keep + 1))" |
cut -zf2-
)
}
if ! is_chromium_family; then
exec "$real_bin" "$@"
fi
mkdir -p "$LOG_DIR"
rotate_logs "$((LOG_KEEP - 1))"
log_file="${LOG_DIR}/${prog_name}-$(date +%Y%m%d-%H%M%S)-$$.log"
{
echo "=== launch $(date '+%Y-%m-%d %H:%M:%S') ==="
echo "binary: $real_bin"
echo "args: $*"
emit_resource_snapshot "PRE-LAUNCH"
echo "=== browser stderr follows ==="
} >"$log_file"
# Written from a trap rather than inline: if the wrapper itself is killed (the
# session ends, the process group is torn down, the OOM killer picks it), an
# inline block after `wait` would never run and the log would end mid-stream
# with no verdict — indistinguishable from a browser still running.
record_exit() {
local code="$1"
# Fire once; EXIT still runs after a signal trap has already reported.
[[ -n ${exit_recorded:-} ]] && return 0
exit_recorded=1
{
echo "=== exit ==="
echo "exit code: $code"
if ((code > 128)); then
local signal_num=$((code - 128))
echo "died on signal: ${signal_num} ($(kill -l "$signal_num" 2>/dev/null || echo unknown))"
fi
emit_resource_snapshot "POST-EXIT"
} >>"$log_file"
}
# Run in the background rather than exec'ing, so this shell survives to record
# how the browser died. `wait` reports 128+N for a signal death, which is the
# single most useful fact the old setup threw away.
#
# Deliberately a direct redirect, NOT a pipe into a size-limiting filter: in a
# pipeline `wait` reports the LAST element's status, which would report the
# filter's exit and discard the browser's signal — the one fact this whole
# wrapper exists to capture. Size is bounded by the watchdog below instead.
"$real_bin" --enable-logging=stderr --v=1 "$@" >>"$log_file" 2>&1 &
browser_pid=$!
# Watchdog: if a browsing session makes the log exceed the cap, keep the most
# recent half and continue appending. Checks once a minute — the measured burst
# rate (~2 MB/45s of navigation, ~0 idle) can't overshoot 32 MB meaningfully in
# that window, and a 1/min poll costs nothing next to the browser itself.
(
while kill -0 "$browser_pid" 2>/dev/null; do
sleep 60
size="$(stat -c %s "$log_file" 2>/dev/null || echo 0)"
if ((size > LOG_MAX_BYTES)); then
tail -c "$((LOG_MAX_BYTES / 2))" "$log_file" >"${log_file}.trim" 2>/dev/null &&
mv "${log_file}.trim" "$log_file" &&
echo "=== log trimmed to newest $((LOG_MAX_BYTES / 2)) bytes at $(date '+%H:%M:%S') ===" >>"$log_file"
fi
done
) &
watchdog_pid=$!
# A signal aimed at the wrapper is recorded as the wrapper's own death, and the
# browser is taken down with it so no orphan outlives the log describing it.
# Written inline (not via a helper) so shellcheck can see the calls; each then
# re-raises with the default handler so the exit status stays truthful.
trap 'record_exit 129; kill "$browser_pid" "$watchdog_pid" 2>/dev/null || true; trap - HUP; kill -s HUP $$' HUP
trap 'record_exit 130; kill "$browser_pid" "$watchdog_pid" 2>/dev/null || true; trap - INT; kill -s INT $$' INT
trap 'record_exit 143; kill "$browser_pid" "$watchdog_pid" 2>/dev/null || true; trap - TERM; kill -s TERM $$' TERM
exit_code=0
wait "$browser_pid" || exit_code=$?
kill "$watchdog_pid" 2>/dev/null || true
record_exit "$exit_code"
exit "$exit_code"