mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
All three were in code paths the original tests happened to miss. 1. A .txt.gz mapping passed positionally was rejected with "unexpected argument" — the documented way to use a downloaded release asset. The detection used `gunzip -c "$f" | head -c 200 | grep -q '^# compiler'`; `grep -q` exits at the first match, gunzip takes SIGPIPE, and `set -o pipefail` then reports the whole pipeline as failed. So a real mapping was classified as a report, and the actual report became the stray argument. Now reads the header into a variable and pattern-matches the string, with no pipeline status to misread. (The plain .txt form worked only by luck: grep consumed all 200 bytes before matching, so nothing got SIGPIPE.) 2. Every run leaked its temp directory. `exec java ...` replaces the shell, so the EXIT trap never fired — confirmed by finding five /tmp/tmp.* dirs left over from testing. Dropped the exec. 3. Reading `# compiler_version` streamed all ~500 MB of the mapping to find a line in the first hundred bytes (0.486s vs 0.003s), and left the pipeline's status at head's SIGPIPE — surviving only because sed block-buffers. Both header fields now come from one 4 KiB read. Also: cache the expanded copy under a key derived from the source path, so a hand-gzipped playRelease and fdroidRelease mapping (both "mapping.txt.gz") cannot collide; report the mapping name the user actually typed in the mismatch error rather than the cache path; and document that the cache grows by ~500 MB per release retraced. Regression matrix, all passing: positional .txt, positional .txt.gz, auto from file, auto from stdin, wrong map-id refused (exit 2), --force override, branch build rejected, missing release asset reported, --help, and zero leaked temp dirs across the run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEoxktEZyTrAS33vVZBiwm
260 lines
11 KiB
Bash
Executable File
260 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Turn an obfuscated Amethyst crash report back into real class, method, file
|
|
# and line names.
|
|
#
|
|
# scripts/retrace.sh report.txt # figures out the release by itself
|
|
# pbpaste | scripts/retrace.sh # ... or straight off the clipboard
|
|
#
|
|
# A report produced by ReportAssembler names its own build on line 1:
|
|
#
|
|
# java.lang.IllegalStateException: 1.16.0-PLAY
|
|
#
|
|
# so that is all this needs to fetch the right mapping from the matching GitHub
|
|
# Release (amethyst-googleplay-mapping-v1.16.0.txt.gz) and cache it.
|
|
#
|
|
# If you only have a bare stack trace with no such header, name the build:
|
|
#
|
|
# scripts/retrace.sh --release v1.16.0 --flavor play trace.txt
|
|
#
|
|
# ... or point at a mapping yourself, e.g. for a build you made locally:
|
|
#
|
|
# scripts/retrace.sh amethyst/build/outputs/mapping/playRelease/mapping.txt trace.txt
|
|
#
|
|
# Mappings are never guessed at. Every frame of an obfuscated trace carries
|
|
# `r8-map-id-<hash>`, which is the `pg_map_id` of the one mapping that produced
|
|
# that build, so the mapping is checked against the report before any output is
|
|
# printed — a wrong mapping produces plausible, wrong answers, which is worse
|
|
# than no answer. --force overrides.
|
|
#
|
|
# The R8 that does the work is fetched at the version recorded in the mapping's
|
|
# own header, so there is no tooling to pin and this keeps working across AGP
|
|
# bumps.
|
|
#
|
|
# Everything is cached under ~/.cache/amethyst-retrace/, and that cache is not
|
|
# small: a mapping is ~29 MB compressed and ~500 MB expanded, per release you
|
|
# retrace. `rm -rf ~/.cache/amethyst-retrace` whenever you want it back; the
|
|
# next run re-downloads.
|
|
set -euo pipefail
|
|
|
|
REPO="${AMETHYST_REPO:-vitorpamplona/amethyst}"
|
|
CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/amethyst-retrace"
|
|
|
|
MAPPING=""
|
|
RELEASE=""
|
|
FLAVOR=""
|
|
REPORT=""
|
|
FORCE=0
|
|
|
|
die() { echo "error: $*" >&2; exit 2; }
|
|
|
|
usage() {
|
|
sed -n '3,30p' "$0" | sed 's/^# \{0,1\}//'
|
|
exit "${1:-2}"
|
|
}
|
|
|
|
# The first 4 KiB of a mapping, which is where R8 puts its whole header. Read
|
|
# once, into a variable: `sed ... "$MAPPING" | head -1` would stream all ~500 MB
|
|
# looking for a line that is always in the first hundred bytes, and would also
|
|
# leave the pipeline's status at head's SIGPIPE.
|
|
mapping_header() {
|
|
case "$1" in
|
|
*.gz) gunzip -c "$1" 2>/dev/null | head -c 4096 || true ;;
|
|
*) head -c 4096 "$1" 2>/dev/null || true ;;
|
|
esac
|
|
}
|
|
|
|
# A mapping file, or the report? Decide by content, not by extension, so both
|
|
# documented argument orders keep working.
|
|
#
|
|
# Deliberately NOT `... | grep -q`: `grep -q` exits at the first match, the
|
|
# producer takes SIGPIPE, and `set -o pipefail` then reports the whole pipeline
|
|
# as failed — so a real mapping.txt.gz was classified as a report and the actual
|
|
# report came back as "unexpected argument".
|
|
looks_like_mapping() {
|
|
[ -f "$1" ] || return 1
|
|
case "$1" in
|
|
*.prt) return 0 ;;
|
|
esac
|
|
case "$(mapping_header "$1")" in
|
|
"# compiler"*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--release) RELEASE="${2:-}"; shift 2 ;;
|
|
--flavor) FLAVOR="${2:-}"; shift 2 ;;
|
|
--mapping) MAPPING="${2:-}"; shift 2 ;;
|
|
--force) FORCE=1; shift ;;
|
|
-h|--help) usage 0 ;;
|
|
-*) die "unknown option $1 (try --help)" ;;
|
|
*)
|
|
if [ -z "$MAPPING" ] && [ -z "$RELEASE" ] && looks_like_mapping "$1"; then
|
|
MAPPING="$1"
|
|
elif [ -z "$REPORT" ]; then
|
|
REPORT="$1"
|
|
else
|
|
die "unexpected argument $1"
|
|
fi
|
|
shift ;;
|
|
esac
|
|
done
|
|
|
|
mkdir -p "$CACHE"
|
|
TMP="$(mktemp -d)"
|
|
trap 'rm -rf "$TMP"' EXIT
|
|
|
|
# ---- materialise the report ------------------------------------------------
|
|
# Always to a file: we have to read it twice (header, map id) before retracing,
|
|
# and that is not possible on a pipe.
|
|
REPORT_FILE="$TMP/report.txt"
|
|
if [ -n "$REPORT" ]; then
|
|
[ -f "$REPORT" ] || die "no such file: $REPORT"
|
|
cp "$REPORT" "$REPORT_FILE"
|
|
else
|
|
[ -t 0 ] && echo "reading the report from stdin (ctrl-D when done) ..." >&2
|
|
cat > "$REPORT_FILE"
|
|
fi
|
|
[ -s "$REPORT_FILE" ] || die "the report is empty"
|
|
|
|
# Retrace only rewrites frames it recognises, and it recognises them by the
|
|
# leading `at`. Reports from Amethyst builds before the ReportAssembler fix
|
|
# wrote bare " com.foo.Bar.baz(File.kt:12)" lines, which would otherwise be
|
|
# passed through still obfuscated and look like a mapping problem. Put the `at`
|
|
# back on any frame-shaped line that is missing it. Anything that is not
|
|
# <indent><dotted.name>(<something>:<digits>) is left exactly as it is.
|
|
sed -E 's/^([[:space:]]+)([A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z0-9_$<>]+)+\([^()]*:[0-9]+\))[[:space:]]*$/\1at \2/' \
|
|
"$REPORT_FILE" > "$TMP/normalised.txt"
|
|
mv "$TMP/normalised.txt" "$REPORT_FILE"
|
|
|
|
# ---- work out which mapping --------------------------------------------------
|
|
if [ -z "$MAPPING" ]; then
|
|
if [ -z "$RELEASE" ]; then
|
|
# --auto: parse "<exception>: <version>-<FLAVOR>" off line 1.
|
|
header="$(head -1 "$REPORT_FILE" | tr -d '\r')"
|
|
case "$header" in
|
|
*": "*) ;;
|
|
*) die "line 1 is not an Amethyst crash-report header, so the build is unknown.
|
|
Pass --release <tag> [--flavor play|fdroid], or a mapping file." ;;
|
|
esac
|
|
vf="${header#*: }"
|
|
vf="$(echo "$vf" | tr -d '[:space:]')"
|
|
[ -n "$vf" ] || die "line 1 has no '<version>-<FLAVOR>' after the exception name."
|
|
parsed_flavor="${vf##*-}"
|
|
version="${vf%-*}"
|
|
[ -n "$parsed_flavor" ] && [ -n "$version" ] && [ "$version" != "$vf" ] \
|
|
|| die "cannot read '<version>-<FLAVOR>' out of line 1: $header"
|
|
[ -n "$FLAVOR" ] || FLAVOR="$parsed_flavor"
|
|
case "$version" in
|
|
[0-9]*.[0-9]*.[0-9]*) ;;
|
|
*) die "line 1 reports version '$version', which is not a release version." ;;
|
|
esac
|
|
# generateVersionName() appends the git branch on non-main builds, so a
|
|
# dev build reads e.g. 1.16.0-my-branch-PLAY. There is no release for it.
|
|
case "$version" in
|
|
*[!0-9.]*) die "version '$version' carries a branch suffix, so this is a local/CI
|
|
build, not a release — its mapping was never published. Retrace against
|
|
that build's own amethyst/build/outputs/mapping/<variant>/mapping.txt." ;;
|
|
esac
|
|
RELEASE="v$version"
|
|
fi
|
|
|
|
case "$RELEASE" in v*) ;; *) RELEASE="v$RELEASE" ;; esac
|
|
|
|
case "$(echo "${FLAVOR:-play}" | tr '[:upper:]' '[:lower:]')" in
|
|
play|googleplay) channel=googleplay ;;
|
|
fdroid) channel=fdroid ;;
|
|
*) die "unknown flavor '$FLAVOR' (expected play or fdroid)" ;;
|
|
esac
|
|
|
|
asset="amethyst-${channel}-mapping-${RELEASE}.txt.gz"
|
|
MAPPING="$CACHE/$asset"
|
|
if [ ! -s "$MAPPING" ]; then
|
|
url="https://github.com/${REPO}/releases/download/${RELEASE}/${asset}"
|
|
echo "fetching $asset ..." >&2
|
|
curl -fsSL -o "$MAPPING.tmp" "$url" || {
|
|
rm -f "$MAPPING.tmp"
|
|
die "could not download $url
|
|
Either that release predates mapping publication (builds up to v1.16.0
|
|
were not obfuscated — read those traces as-is), or the flavor is wrong.
|
|
Assets: gh release view $RELEASE --json assets --jq '.assets[].name'"
|
|
}
|
|
mv "$MAPPING.tmp" "$MAPPING"
|
|
fi
|
|
fi
|
|
|
|
[ -f "$MAPPING" ] || die "no such mapping: $MAPPING"
|
|
# Keep the name the user actually gave us for error messages — once a .gz is
|
|
# expanded, $MAPPING points at a cache path they never typed.
|
|
MAPPING_LABEL="$(basename "$MAPPING")"
|
|
|
|
# ---- decompress if needed --------------------------------------------------
|
|
PARTITION=0
|
|
case "$MAPPING" in
|
|
*.prt) PARTITION=1 ;;
|
|
*.gz)
|
|
# Keyed on the full source path, not just the basename: two different
|
|
# mappings are both called mapping.txt.gz if you gzip a playRelease and
|
|
# an fdroidRelease by hand. (The map-id check below would catch the mixup
|
|
# anyway, but "wrong release" is a much clearer error than a stale cache.)
|
|
key="$(printf '%s' "$(cd "$(dirname "$MAPPING")" && pwd)/$(basename "$MAPPING")" | cksum | cut -d' ' -f1)"
|
|
plain="$CACHE/$(basename "${MAPPING%.gz}").$key"
|
|
if [ ! -s "$plain" ] || [ "$MAPPING" -nt "$plain" ]; then
|
|
echo "decompressing $(basename "$MAPPING") ..." >&2
|
|
gunzip -c "$MAPPING" > "$plain"
|
|
fi
|
|
MAPPING="$plain"
|
|
;;
|
|
esac
|
|
|
|
# ---- make sure this mapping really built this report ------------------------
|
|
if [ "$PARTITION" -eq 0 ]; then
|
|
HEADER="$(mapping_header "$MAPPING")"
|
|
trace_id="$(grep -om1 'r8-map-id-[0-9a-f]\{16,\}' "$REPORT_FILE" | sed 's/^r8-map-id-//' || true)"
|
|
map_id="$(printf '%s\n' "$HEADER" | sed -n 's/^# pg_map_id: *//p' | head -1 || true)"
|
|
if [ -z "$trace_id" ]; then
|
|
echo "note: no r8-map-id in the report (an un-obfuscated build, or a trimmed" >&2
|
|
echo " trace) — cannot confirm the mapping matches." >&2
|
|
elif [ "$trace_id" != "$map_id" ]; then
|
|
msg="this mapping did not build this report.
|
|
report: $trace_id
|
|
mapping: $map_id ($MAPPING_LABEL)
|
|
Retracing anyway yields wrong names that look right. Check the release
|
|
tag and the flavor (play vs fdroid are separate R8 runs)."
|
|
[ "$FORCE" -eq 1 ] && echo "warning: $msg" >&2 || die "$msg"
|
|
fi
|
|
fi
|
|
|
|
# ---- fetch the R8 that wrote it ---------------------------------------------
|
|
if [ "$PARTITION" -eq 1 ]; then
|
|
R8_VERSION="${R8_VERSION:-$(ls "$CACHE"/r8-*.jar 2>/dev/null | sed 's/.*r8-\(.*\)\.jar/\1/' | sort -V | tail -1 || true)}"
|
|
[ -n "$R8_VERSION" ] || die "a .prt partition map does not expose its R8 version.
|
|
Set R8_VERSION=<x.y.z> (the 'compiler_version' of the matching
|
|
mapping.txt), or retrace against the .txt.gz instead."
|
|
else
|
|
R8_VERSION="$(printf '%s\n' "${HEADER:-$(mapping_header "$MAPPING")}" | sed -n 's/^# compiler_version: *//p' | head -1 || true)"
|
|
[ -n "$R8_VERSION" ] || die "no '# compiler_version:' header in $MAPPING — not an R8 mapping?"
|
|
fi
|
|
|
|
R8_JAR="$CACHE/r8-${R8_VERSION}.jar"
|
|
if [ ! -s "$R8_JAR" ]; then
|
|
echo "fetching R8 ${R8_VERSION} ..." >&2
|
|
curl -fsSL -o "$R8_JAR.tmp" \
|
|
"https://maven.google.com/com/android/tools/r8/${R8_VERSION}/r8-${R8_VERSION}.jar" \
|
|
|| { rm -f "$R8_JAR.tmp"; die "could not download R8 ${R8_VERSION} from Google's Maven"; }
|
|
mv "$R8_JAR.tmp" "$R8_JAR"
|
|
fi
|
|
|
|
# ---- retrace ---------------------------------------------------------------
|
|
# Not `exec`: exec replaces this shell, so the EXIT trap never runs and $TMP is
|
|
# left behind on every single invocation.
|
|
if [ "$PARTITION" -eq 1 ]; then
|
|
java -cp "$R8_JAR" com.android.tools.r8.retrace.Retrace \
|
|
--partition-map "$MAPPING" "$REPORT_FILE"
|
|
else
|
|
java -cp "$R8_JAR" com.android.tools.r8.retrace.Retrace \
|
|
"$MAPPING" "$REPORT_FILE"
|
|
fi
|