Files
Andre Rummler ce8884394a Remove all code switches for Qt<6 with one exception: caching in titlebordertemplate. Function for caching not called since Qt4; might profit from complete removal.
Another exception: one non-converted code path in projectprintwindow.cpp to be followed-up.

One issue found in the Qt6 code path of diagramview.cpp which has been fixed.
2026-09-17 00:07:46 +02:00

342 lines
13 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
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