Skip to content

Instantly share code, notes, and snippets.

@flodolo
Created June 25, 2026 05:44
Show Gist options
  • Select an option

  • Save flodolo/b7dcdbc806be030d6de6ff4e1144fba7 to your computer and use it in GitHub Desktop.

Select an option

Save flodolo/b7dcdbc806be030d6de6ff4e1144fba7 to your computer and use it in GitHub Desktop.
Hang Firefox
#!/usr/bin/env bash
#
# Reproduce bug 2049845 (catastrophic AboutNewTabResourceMapping observer
# re-entrancy -> ballooning memory + main-thread freeze) on a LOCAL build.
#
# The hang needs THREE ingredients together:
# 1. newtab running as the train-hop XPI (not the built-in add-on), so the
# AboutNewTabResourceMapping observer is actually installed.
# 2. Many active langpacks (>=20) so there are enough locales for the
# L10nRegistry/LocaleService source churn to matter.
# 3. A RUNTIME locale negotiation change (switching language / live langpack
# install). Startup alone does NOT trigger it: the langpacks register
# before the observer is added, and the startup shadow registrations don't
# re-fire intl:app-locales-changed. This script forces the change via
# Marionette after launch.
#
# The profile is also set up with:
# - intl.locale.requested empty (forces OS-locale negotiation)
# - a batch of release langpacks pre-installed
# - the nimbus-devtools extension installed
set -euo pipefail
# --- Config ---------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Path to the Firefox source checkout (override with MOZ_REPO=...).
MOZ_REPO="${MOZ_REPO:-/Users/flodolo/mozilla/git/mozilla-firefox}"
LANGPACK_BASE="https://archive.mozilla.org/pub/firefox/releases/152.0.2/mac/xpi"
NIMBUS_URL="https://github.com/mozilla-extensions/nimbus-devtools/releases/download/release%2Fv0.3.0/nimbus-devtools.xpi"
NIMBUS_ID="nimbus-devtools@mozilla.com"
NUM_LANGPACKS=40
PROFILE_DIR="${PROFILE_DIR:-/tmp/fx-langpack-profile}"
# The newtab train-hop XPI. It is NOT on archive.mozilla.org (it ships via
# Nimbus), so we keep a persistent copy next to this script. Override with
# NEWTAB_XPI=/path/to/newtab@mozilla.org.xpi
NEWTAB_XPI="${NEWTAB_XPI:-${SCRIPT_DIR}/newtab@mozilla.org.xpi}"
NEWTAB_ID="newtab@mozilla.org"
# Set TRIGGER_HANG=0 to just launch the profile without firing the locale switch.
TRIGGER_HANG="${TRIGGER_HANG:-1}"
MARIONETTE_PORT="${MARIONETTE_PORT:-2828}"
# --- Preserve the newtab XPI before wiping the profile --------------------
# If we don't have a saved copy but a previous profile run does, rescue it so
# the script stays self-contained across runs (the profile is recreated below).
if [[ ! -f "${NEWTAB_XPI}" && -f "${PROFILE_DIR}/extensions/${NEWTAB_ID}.xpi" ]]; then
echo "Preserving newtab XPI from existing profile -> ${NEWTAB_XPI}"
cp "${PROFILE_DIR}/extensions/${NEWTAB_ID}.xpi" "${NEWTAB_XPI}"
fi
if [[ ! -f "${NEWTAB_XPI}" ]]; then
echo "ERROR: newtab train-hop XPI not found at:" >&2
echo " ${NEWTAB_XPI}" >&2
echo "Drop a newtab@mozilla.org.xpi there (or set NEWTAB_XPI=...)." >&2
exit 1
fi
# Read the XPI version from its manifest so the train-hop pref matches it.
NEWTAB_VERSION="$(python3 -c 'import json,sys,zipfile; print(json.loads(zipfile.ZipFile(sys.argv[1]).read("manifest.json"))["version"])' "${NEWTAB_XPI}")"
echo "Using newtab train-hop XPI version ${NEWTAB_VERSION}"
# --- Clean profile --------------------------------------------------------
echo "Recreating clean profile at ${PROFILE_DIR}"
# macOS Finder can recreate .DS_Store mid-delete, making rm -rf fail with
# "Directory not empty". Retry a few times to win the race.
for _ in 1 2 3 4 5; do
rm -rf "${PROFILE_DIR}" 2>/dev/null && break
sleep 0.3
done
if [[ -d "${PROFILE_DIR}" ]]; then
echo "ERROR: could not remove ${PROFILE_DIR} (something is recreating files in it)" >&2
exit 1
fi
mkdir -p "${PROFILE_DIR}/extensions"
# --- Pick langpacks -------------------------------------------------------
echo "Fetching langpack list..."
LANGPACKS=()
while IFS= read -r line; do
LANGPACKS+=("$line")
done < <(
curl -sf "${LANGPACK_BASE}/" \
| grep -oE 'href="[^"]+/([A-Za-z-]+)\.xpi"' \
| sed -E 's#.*/([A-Za-z-]+)\.xpi"#\1#' \
| sort -u \
| head -n "${NUM_LANGPACKS}"
)
if [[ "${#LANGPACKS[@]}" -lt 30 ]]; then
echo "ERROR: only found ${#LANGPACKS[@]} langpacks at:" >&2
echo " ${LANGPACK_BASE}/" >&2
exit 1
fi
echo "Downloading ${#LANGPACKS[@]} langpacks..."
for loc in "${LANGPACKS[@]}"; do
echo " ${loc}"
addon_id="langpack-${loc}@firefox.mozilla.org"
curl -sf -o "${PROFILE_DIR}/extensions/${addon_id}.xpi" "${LANGPACK_BASE}/${loc}.xpi"
done
# --- nimbus-devtools ------------------------------------------------------
echo "Downloading nimbus-devtools..."
curl -sfL -o "${PROFILE_DIR}/extensions/${NIMBUS_ID}.xpi" "${NIMBUS_URL}"
# --- newtab train-hop XPI -------------------------------------------------
echo "Installing newtab train-hop XPI..."
cp "${NEWTAB_XPI}" "${PROFILE_DIR}/extensions/${NEWTAB_ID}.xpi"
# --- Profile prefs --------------------------------------------------------
cat > "${PROFILE_DIR}/user.js" <<'EOF'
// Empty requested locale -> Firefox negotiates against installed langpacks.
user_pref("intl.locale.requested", "");
user_pref("intl.multilingual.enabled", true);
user_pref("intl.multilingual.liveReload", true);
// Auto-enable side-loaded add-ons dropped into the profile's extensions dir.
user_pref("extensions.autoDisableScopes", 0);
// nimbus-devtools is a privileged add-on that uses experiment APIs.
user_pref("extensions.experiments.enabled", true);
// Skip first-run noise.
user_pref("browser.aboutwelcome.enabled", false);
user_pref("datareporting.policy.dataSubmissionEnabled", false);
EOF
# Make newtab load from the train-hop XPI instead of the built-in add-on.
# The version must be non-empty and higher than the built-in newtab version,
# otherwise getPreferredMapping() falls back to the built-in resources.
cat >> "${PROFILE_DIR}/user.js" <<EOF
// Force newtab to run from the train-hop XPI (matches the XPI's version).
user_pref("browser.newtabpage.trainhopAddon.version", "${NEWTAB_VERSION}");
EOF
# --- Locate the built binary ----------------------------------------------
BIN="$(ls -td "${MOZ_REPO}"/obj-*/dist/*.app/Contents/MacOS/firefox 2>/dev/null | head -n 1 || true)"
if [[ -z "${BIN}" || ! -x "${BIN}" ]]; then
echo "ERROR: no built Firefox binary found under ${MOZ_REPO}/obj-*/dist/*.app" >&2
echo "Build first with: (cd ${MOZ_REPO} && ./mach build)" >&2
exit 1
fi
echo "Using binary: ${BIN}"
# --- Launch ---------------------------------------------------------------
if [[ "${TRIGGER_HANG}" != "1" ]]; then
echo "Launching local build (no auto-trigger; switch language manually to hang)..."
exec env MOZ_DISABLE_CONTENT_SANDBOX=1 "${BIN}" \
-profile "${PROFILE_DIR}" -no-remote about:newtab
fi
echo "Launching local build with Marionette to auto-trigger the hang..."
env MOZ_DISABLE_CONTENT_SANDBOX=1 "${BIN}" \
-profile "${PROFILE_DIR}" -no-remote \
-marionette -remote-allow-system-access about:newtab &
FX_PID=$!
# Wait for the Marionette socket to come up.
echo "Waiting for Marionette on port ${MARIONETTE_PORT}..."
for _ in $(seq 1 60); do
if lsof -iTCP:"${MARIONETTE_PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
break
fi
sleep 0.5
done
# Give the browser a moment to finish startup (langpacks + shadow sources).
sleep 5
# Drive a runtime locale negotiation change to ignite the observer cascade.
TRIGGER_PY="$(mktemp -t fx-langpack-trigger.XXXXXX.py)"
trap 'rm -f "${TRIGGER_PY}"' EXIT
cat > "${TRIGGER_PY}" <<'PYEOF'
import sys, time
from marionette_driver.marionette import Marionette
port = int(sys.argv[1])
m = Marionette(host="127.0.0.1", port=port, socket_timeout=20)
m.start_session()
m.set_context(m.CONTEXT_CHROME)
available = set(m.execute_script("return Services.locale.availableLocales;"))
current = m.execute_script("return Services.locale.appLocalesAsBCP47;")
print("available locales:", len(available), "current app locale:", current)
# Pick a few installed langpack locales to switch between. The first switch to
# a different, available locale forces intl:app-locales-changed -> observe() ->
# the synchronous re-entrant cascade. That execute_script will then block
# (main thread pinned), so we treat a socket timeout as "hang triggered".
candidates = [c for c in ["de", "fr", "it", "es-ES", "ja", "pl", "ru"]
if c in available and c != (current[0] if current else None)]
print("switching through:", candidates)
for loc in candidates:
try:
print("setting requestedLocales =", [loc], flush=True)
m.execute_script("Services.locale.requestedLocales = arguments[0];",
script_args=[[loc]])
time.sleep(1.0)
except Exception as e:
print("no response after switch -> cascade triggered (browser is "
"hanging):", type(e).__name__)
sys.exit(0)
print("fired all switches without an obvious hang; check memory/CPU")
PYEOF
echo "Firing runtime locale switch (this should make Firefox beachball)..."
set +e
"${MOZ_REPO}/mach" python "${TRIGGER_PY}" "${MARIONETTE_PORT}"
set -e
echo
echo "Trigger fired. The browser (pid ${FX_PID}) should now be hung:"
echo " - main process CPU pinned near 100%, RSS climbing past several GB"
echo " - watch with: ps -o pid,rss,%cpu -p ${FX_PID}"
echo "Press Ctrl-C to kill it, or run: kill -9 ${FX_PID}"
wait "${FX_PID}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment