mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-29 05:44:14 +02:00
9363e9bc2e
Covers the crash fixed in #868: a file forwarded from a second instance was opened inside SingleApplication's socket handler, so the backup prompt's nested event loop ran while that handler was still on the stack. Run by hand; no build system or CI changes. tests/ipc-regression/run.sh --binary build/qelectrotech Validated in both directions on Qt 6.10.2:ceda1e082(before the fix) crashes 3 times in 3 with exit 139,199444b6(after) survives 3 times in 3. Three requirements are not obvious and are documented in the script: - Qt 6 only. An unfixed Qt 5 build survives every attempt, so the script refuses to run on a Qt 5 binary rather than report a pass that cannot fail. - A Debug build. The same unfixed commit survives every attempt built -O3 -DNDEBUG; whether a use-after-free faults depends on what the allocator does with the freed block. - Dismissing the backup prompt is the step that triggers it. Left open, the stack never unwinds and nothing fails, which is why the bug was twice reported as not reproducible. The test runs in its own sandbox on its own X display, works on a copy of the project so backup files do not land in examples/, and cleans up after itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
357 lines
14 KiB
Bash
Executable File
357 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# IPC open-forwarding regression gate -- upstream PR #868.
|
|
#
|
|
# tests/ipc-regression/run.sh --binary build-fast/qelectrotech
|
|
#
|
|
# WHAT IT GUARDS
|
|
#
|
|
# With QET already running, opening a .qet from a file manager forwards the
|
|
# path to the running instance over SingleApplication's local socket. Before
|
|
# #868, QETApp::receiveMessage() called openFiles() directly -- inside the
|
|
# socket's readyRead handler. openFiles() loads the project (seconds, on a
|
|
# large one) and puts up a modal BackupDialog whose exec() runs a NESTED event
|
|
# loop while the socket handler is still on the stack. During that nested loop
|
|
# the secondary exits, the connection closes, the QLocalSocket is deleted, and
|
|
# when the dialog is dismissed and the stack unwinds QMetaObject::activate()
|
|
# carries on emitting on the freed sender. Segfault.
|
|
#
|
|
# #868 defers openFiles() with a zero-timer so the socket stack unwinds first.
|
|
# The comment there is long on purpose; this test is the other half of making
|
|
# sure nobody "simplifies" it away.
|
|
#
|
|
# THE STEP EVERYONE MISSES
|
|
#
|
|
# Leaving the backup dialog OPEN never crashes -- the stack never unwinds.
|
|
# The bug was twice reported unreproducible for exactly this reason. DISMISSING
|
|
# the dialog is the load-bearing step, not opening the project. That is why
|
|
# this test needs a window manager and synthetic input rather than being a
|
|
# headless CLI check.
|
|
#
|
|
# WHY A COPIED BINARY (the isolation that makes this safe to run)
|
|
#
|
|
# SingleApplication hashes the socket name from app/org name, version, and --
|
|
# on Linux, unless ExcludeAppPath is set -- applicationFilePath()
|
|
# (SingleApplication/singleapplication_p.cpp:156-171). QET constructs it as
|
|
# `SingleApplication app(argc, argv, true)` (sources/main.cpp:160), so the
|
|
# path IS in the hash.
|
|
#
|
|
# That matters twice over:
|
|
# * Qt's local sockets live in the abstract namespace, which is scoped to the
|
|
# network namespace, so a container sharing the host's network namespace
|
|
# and running the same binary path will silently capture native launches.
|
|
# * Copying the binary to a unique path therefore gives this test its OWN
|
|
# socket. It cannot talk to, or be hijacked by, a QElectroTech you already
|
|
# have open -- and two runs of this test cannot collide with each other.
|
|
#
|
|
# Do not "optimise" the copy into a symlink: applicationFilePath() resolves
|
|
# through /proc/self/exe, so a symlink lands back on the real path and the
|
|
# isolation is silently lost.
|
|
#
|
|
set -uo pipefail
|
|
|
|
BINARY=""
|
|
PROJECT=""
|
|
KEEP_OPEN=0
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--binary) BINARY="$2"; shift 2 ;;
|
|
--project) PROJECT="$2"; shift 2 ;;
|
|
--keep-open) KEEP_OPEN=1; shift ;;
|
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
[ -n "$BINARY" ] || { echo "usage: $0 --binary <qet> [--project <file.qet>]" >&2; exit 2; }
|
|
[ -x "$BINARY" ] || { echo "not executable: $BINARY" >&2; exit 2; }
|
|
BINARY="$(readlink -f "$BINARY")"
|
|
|
|
# A big project is required, not incidental: the load has to take long enough
|
|
# that the modal is up while the socket handler is still on the stack.
|
|
# industrial.qet is 2.6 MB and loads in ~4 s.
|
|
if [ -z "$PROJECT" ]; then
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
for cand in "$(dirname "$BINARY")/../examples/industrial.qet" \
|
|
"$SCRIPT_DIR/../../examples/industrial.qet"; do
|
|
[ -f "$cand" ] && { PROJECT="$(readlink -f "$cand")"; break; }
|
|
done
|
|
fi
|
|
[ -f "$PROJECT" ] || { echo "no project found; pass --project" >&2; exit 2; }
|
|
|
|
for tool in Xvfb openbox xdotool; do
|
|
command -v "$tool" >/dev/null || { echo "missing required tool: $tool" >&2; exit 2; }
|
|
done
|
|
|
|
# Qt 5 cannot prove anything here. An unfixed Qt 5 build of master survives
|
|
# this scenario every time -- measured, not assumed -- so a Qt 5 run reports
|
|
# PASS whether or not the bug is present. Refusing is the only honest answer:
|
|
# a green that cannot go red is worse than no test.
|
|
#
|
|
# Reproduced on Qt 6.10.2, which is also the version in the original report.
|
|
# Whether older Qt 6 reproduces it is UNVERIFIED -- if you run this on Qt 6.2
|
|
# or 6.4 and it passes, confirm against a deliberately unfixed build before
|
|
# believing it.
|
|
if ldd "$BINARY" 2>/dev/null | grep -q "libQt5Core"; then
|
|
echo "INCONCLUSIVE: $BINARY links Qt 5." >&2
|
|
echo " This crash only reproduces on Qt 6; an unfixed Qt 5 build" >&2
|
|
echo " survives every attempt. Build against Qt 6 to use this gate." >&2
|
|
exit 2
|
|
fi
|
|
|
|
SANDBOX="$(mktemp -d /tmp/qet-ipc-regression.XXXXXX)"
|
|
LOG_DIR="$SANDBOX/logs"; mkdir -p "$LOG_DIR"
|
|
PRIMARY_PID=""; SECONDARY_PID=""; XVFB_PID=""; OPENBOX_PID=""
|
|
|
|
cleanup() {
|
|
if [ "$KEEP_OPEN" = "1" ]; then
|
|
echo "--keep-open: DISPLAY=$DISPLAY sandbox=$SANDBOX (nothing killed)"
|
|
return
|
|
fi
|
|
for pid in "$SECONDARY_PID" "$PRIMARY_PID" "$OPENBOX_PID" "$XVFB_PID"; do
|
|
[ -n "$pid" ] && kill "$pid" 2>/dev/null
|
|
done
|
|
sleep 0.3
|
|
for pid in "$SECONDARY_PID" "$PRIMARY_PID" "$OPENBOX_PID" "$XVFB_PID"; do
|
|
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null
|
|
done
|
|
[ "${KEEP_LOGS:-0}" = "1" ] || rm -rf "$SANDBOX"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# Unique binary path == private SingleApplication socket. See header.
|
|
TEST_BINARY="$SANDBOX/qelectrotech-ipctest"
|
|
cp "$BINARY" "$TEST_BINARY" || { echo "could not copy binary" >&2; exit 2; }
|
|
|
|
# Work on a COPY of the project, inside the sandbox.
|
|
#
|
|
# Answering "yes" to the backup prompt is the whole point of this test, and
|
|
# QElectroTech writes that backup next to the project it opened. Pointed at
|
|
# examples/ directly, a three-attempt run drops three dated .qet files into the
|
|
# source tree and leaves them there. Copying first keeps the repository clean
|
|
# and means the teardown takes the backups with it.
|
|
SANDBOX_PROJECT="$SANDBOX/$(basename "$PROJECT")"
|
|
cp "$PROJECT" "$SANDBOX_PROJECT" || { echo "could not copy project" >&2; exit 2; }
|
|
PROJECT="$SANDBOX_PROJECT"
|
|
|
|
# Isolated HOME so the run cannot inherit or leave behind real settings.
|
|
export HOME="$SANDBOX/home"
|
|
export XDG_CONFIG_HOME="$HOME/.config"
|
|
export XDG_DATA_HOME="$HOME/.local/share"
|
|
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME"
|
|
|
|
# Xvfb -displayfd atomically picks a free display; hardcoding :99 races.
|
|
Xvfb -displayfd 9 -screen 0 1600x1000x24 >"$LOG_DIR/xvfb.log" 2>&1 9>"$SANDBOX/dispnum" &
|
|
XVFB_PID=$!
|
|
for _ in $(seq 1 50); do
|
|
DISP_NUM="$(cat "$SANDBOX/dispnum" 2>/dev/null | tr -d '[:space:]')"
|
|
[ -n "$DISP_NUM" ] && break
|
|
sleep 0.1
|
|
done
|
|
[ -n "${DISP_NUM:-}" ] || { echo "FAIL: Xvfb never reported a display" >&2; exit 1; }
|
|
export DISPLAY=":$DISP_NUM"
|
|
|
|
# openbox is mandatory -- without a WM, xdotool cannot activate windows
|
|
# ("your windowmanager claims not to support _NET_ACTIVE_WINDOW").
|
|
openbox >"$LOG_DIR/openbox.log" 2>&1 &
|
|
OPENBOX_PID=$!
|
|
sleep 1
|
|
|
|
# Only count real windows: Qt/QPA leaves a HIDDEN 1x1 helper window whose
|
|
# title matches the main window exactly, so name-only matching is a coin flip.
|
|
real_window() {
|
|
local geo w h
|
|
geo="$(xdotool getwindowgeometry --shell "$1" 2>/dev/null)" || return 1
|
|
w="$(echo "$geo" | sed -n 's/^WIDTH=//p')"
|
|
h="$(echo "$geo" | sed -n 's/^HEIGHT=//p')"
|
|
[ -n "$w" ] && [ -n "$h" ] && [ "$w" -gt 50 ] && [ "$h" -gt 50 ]
|
|
}
|
|
|
|
main_window() {
|
|
local w
|
|
for w in $(xdotool search --onlyvisible --name "QElectroTech" 2>/dev/null); do
|
|
real_window "$w" && { echo "$w"; return 0; }
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Any real window that is not the main window. NOT filtered by title -- see
|
|
# below for why that matters more than it looks.
|
|
modal_window() {
|
|
local main="$1" w
|
|
for w in $(xdotool search --onlyvisible --name "." 2>/dev/null); do
|
|
[ "$w" = "$main" ] && continue
|
|
real_window "$w" && { echo "$w"; return 0; }
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Every non-main real window, newest last (X window ids increase).
|
|
modal_windows() {
|
|
local main="$1" w out=""
|
|
for w in $(xdotool search --onlyvisible --name "." 2>/dev/null | sort -n); do
|
|
[ "$w" = "$main" ] && continue
|
|
real_window "$w" && out="$out $w"
|
|
done
|
|
echo "$out"
|
|
}
|
|
|
|
# WHY DIALOGS ARE NOT IDENTIFIED BY TITLE
|
|
#
|
|
# Opening a project raises a progress dialog several seconds BEFORE the backup
|
|
# prompt, and both stay up. Answering the progress dialog looks exactly like a
|
|
# successful run: a modal was found, a key was sent, the app survived. PASS,
|
|
# against a build that crashes reliably.
|
|
#
|
|
# The first version of this test filtered the progress dialog out by title.
|
|
# That worked locally ("Merci de patienter") and silently broke in Docker,
|
|
# where the same dialog is "Thank you for your patience" -- a green that could
|
|
# not go red, which is the exact failure this test exists to prevent. A title
|
|
# list is a locale list, and there is no reason to believe anyone will keep it
|
|
# in sync with QET's translations.
|
|
#
|
|
# So: dismiss every dialog that appears, repeatedly, and identify nothing.
|
|
# The progress dialog ignores Return harmlessly; the backup prompt takes it.
|
|
# The run is only meaningful if at least TWO distinct dialogs were seen (the
|
|
# progress dialog plus the prompt) -- one means the prompt never appeared and
|
|
# the crash path was never exercised, which is INCONCLUSIVE, not a pass.
|
|
|
|
ATTEMPTS="${QET_IPC_ATTEMPTS:-3}"
|
|
|
|
echo "── QElectroTech IPC open-forwarding regression (PR #868) ──"
|
|
echo "binary : $BINARY"
|
|
echo "project : $(basename "$PROJECT")"
|
|
echo "display : $DISPLAY"
|
|
echo "attempts : $ATTEMPTS"
|
|
echo
|
|
|
|
# WHY THIS REPEATS
|
|
#
|
|
# The crash is a race: it needs the secondary's disconnect to be processed
|
|
# while the modal's nested event loop is still on the stack. Measured on an
|
|
# unfixed Qt 6 build of master, a single attempt reproduced it 2 times in 3.
|
|
# One attempt is therefore not a gate -- it would wave a reintroduced
|
|
# use-after-free through about a third of the time.
|
|
#
|
|
# Attempts are independent, so N runs miss it with probability (1/3)^N:
|
|
# 3 attempts ~= 96% detection, 5 ~= 99.6%. Any single crash fails the run;
|
|
# survival must be unanimous.
|
|
#
|
|
# A fixed build passes every attempt, so the repetition costs nothing but
|
|
# time on a green build.
|
|
|
|
# Runs the scenario once. 0 = survived, 1 = crashed, 2 = inconclusive.
|
|
run_once() {
|
|
local attempt="$1"
|
|
local primary_pid="" secondary_pid="" main="" modal=""
|
|
local log="$LOG_DIR/attempt-$attempt"
|
|
mkdir -p "$log"
|
|
|
|
# Each attempt gets a FRESH HOME. A crashed attempt leaves a backup file
|
|
# behind, and the next run then opens with "Restore file" instead of --
|
|
# or before -- "Create a backup copy?". Observed directly: attempt 2 of a
|
|
# Docker run saw a restore prompt and never saw the backup prompt at all,
|
|
# so it exercised a different path and proved nothing. Attempts have to be
|
|
# independent or the repetition is not buying what it claims to.
|
|
export HOME="$SANDBOX/home-$attempt"
|
|
export XDG_CONFIG_HOME="$HOME/.config"
|
|
export XDG_DATA_HOME="$HOME/.local/share"
|
|
rm -rf "$HOME"
|
|
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME"
|
|
|
|
"$TEST_BINARY" >"$log/primary.log" 2>&1 &
|
|
primary_pid=$!
|
|
|
|
for _ in $(seq 1 100); do
|
|
main="$(main_window)" && [ -n "$main" ] && break
|
|
kill -0 "$primary_pid" 2>/dev/null || { echo " primary died during startup"; return 2; }
|
|
sleep 0.2
|
|
done
|
|
[ -n "$main" ] || { kill "$primary_pid" 2>/dev/null; echo " primary never showed a window"; return 2; }
|
|
|
|
for _ in $(seq 1 10); do
|
|
m="$(modal_window "$main")" || break
|
|
xdotool windowactivate --sync "$m" 2>/dev/null
|
|
xdotool key --clearmodifiers Escape 2>/dev/null
|
|
sleep 0.3
|
|
done
|
|
|
|
"$TEST_BINARY" "$PROJECT" >"$log/secondary.log" 2>&1 &
|
|
secondary_pid=$!
|
|
|
|
local seen_ids="" distinct=0 crashed=0
|
|
for _ in $(seq 1 200); do
|
|
if ! kill -0 "$primary_pid" 2>/dev/null; then crashed=1; break; fi
|
|
for w in $(modal_windows "$main"); do
|
|
case " $seen_ids " in
|
|
*" $w "*) ;;
|
|
*) seen_ids="$seen_ids $w"
|
|
distinct=$((distinct + 1))
|
|
echo " dialog $distinct: '$(xdotool getwindowname "$w" 2>/dev/null)'" ;;
|
|
esac
|
|
xdotool windowactivate --sync "$w" 2>/dev/null
|
|
xdotool key --clearmodifiers Return 2>/dev/null
|
|
done
|
|
[ "$distinct" -ge 2 ] && [ -z "$(modal_windows "$main")" ] && break
|
|
sleep 0.2
|
|
done
|
|
|
|
if [ "$crashed" = "0" ] && [ "$distinct" -lt 2 ]; then
|
|
kill "$primary_pid" "$secondary_pid" 2>/dev/null
|
|
echo " only $distinct dialog(s) seen -- the backup prompt never appeared,"
|
|
echo " so the crash path was not exercised"
|
|
return 2
|
|
fi
|
|
|
|
sleep 2
|
|
local result=0
|
|
if ! kill -0 "$primary_pid" 2>/dev/null; then
|
|
wait "$primary_pid" 2>/dev/null; local rc=$?
|
|
echo " CRASH: primary died after dismissal (exit $rc)"
|
|
result=1
|
|
elif grep -qiE "segmentation fault|SIGSEGV|AddressSanitizer" "$log/primary.log" 2>/dev/null; then
|
|
echo " CRASH: primary logged a crash signature"
|
|
result=1
|
|
else
|
|
echo " survived"
|
|
fi
|
|
|
|
kill "$primary_pid" "$secondary_pid" 2>/dev/null
|
|
sleep 0.5
|
|
kill -9 "$primary_pid" "$secondary_pid" 2>/dev/null
|
|
return $result
|
|
}
|
|
|
|
crashes=0
|
|
inconclusive=0
|
|
for attempt in $(seq 1 "$ATTEMPTS"); do
|
|
echo "attempt $attempt/$ATTEMPTS"
|
|
run_once "$attempt"
|
|
case $? in
|
|
1) crashes=$((crashes + 1)) ;;
|
|
2) inconclusive=$((inconclusive + 1)) ;;
|
|
esac
|
|
done
|
|
|
|
echo
|
|
if [ "$crashes" -gt 0 ]; then
|
|
echo "FAIL: primary crashed in $crashes of $ATTEMPTS attempts."
|
|
echo " This is the PR #868 use-after-free -- forwarded files are being"
|
|
echo " opened inside the SingleApplication socket handler again."
|
|
echo " Logs: $LOG_DIR (preserved)"
|
|
KEEP_LOGS=1
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$inconclusive" -eq "$ATTEMPTS" ]; then
|
|
echo "INCONCLUSIVE: every attempt failed to exercise the crash path."
|
|
echo " Nothing was proven. Check the binary forwards at all (needs"
|
|
echo " upstream #861) and that the project is large enough to raise"
|
|
echo " the backup prompt."
|
|
exit 2
|
|
fi
|
|
|
|
echo "PASS: primary survived $((ATTEMPTS - inconclusive)) of $ATTEMPTS attempts, none crashed."
|
|
[ "$inconclusive" -gt 0 ] && echo " ($inconclusive attempt(s) inconclusive.)"
|
|
exit 0
|