Fix command-line tools hanging forever on a modal message box

`qelectrotech --resave examples/schema_indus.qet out.qet` never returns.
It is not slow -- ten minutes of wall clock consumed 0.16s of CPU, so it
is blocked, not working. The GUI opens the same project without
complaint, so the file is fine and the fault is in the headless path.

A backtrace of the stuck process:

    main
      CLIExport::run
        QETProject::QETProject(QString const&, QObject*)
          QETProject::openFile(QFile*)
            QETProject::readProjectXml(QDomDocument&)
              QET::QetMessageBox::warning(...)
                QDialog::exec()          <- waits forever

That project records version="0.3", so loading it raises the "partially
compatible with your version" warning. Interactively somebody presses
Open; with no display nobody can, and exec() spins its event loop
indefinitely. Any modal box reachable while loading does this -- the
version warning is just the one an example file happens to trigger.

Fixed at the wrapper all 52 call sites already go through rather than at
the one warning, so the whole class is closed: QetMessageBox gains a
non-interactive mode which writes the message to stderr and returns an
answer instead of constructing a dialog. main.cpp turns it on in the
CLI branch, beside the existing setBackupEnabled(false).

The answer is the caller's defaultButton when it gave one, otherwise the
first "carry on" button offered (Ok, Open, Yes, Save...), otherwise the
first button set. Both warnings in readProjectXml offer Open|Cancel and
abort on Cancel, so they resolve to Open and the project loads, which is
what a batch invocation wants. The text still reaches the user on
stderr, where previously it was lost inside an invisible dialog.

GUI behaviour is unchanged: the flag defaults to false and is set in
exactly one place, the command-line branch of main().

Verified: schema_indus.qet goes from hanging to resaving in 0.3s; all 23
example projects now complete a double-resave with element, conductor,
terminal and uuid sets intact; unit tests pass.
This commit is contained in:
ispyisail
2026-08-05 05:26:57 +12:00
parent 7307a59c10
commit e3d11a4992
3 changed files with 118 additions and 0 deletions
+6
View File
@@ -19,6 +19,7 @@
#include "machine_info.h"
#include "qet.h"
#include "qetapp.h"
#include "qetmessagebox.h"
#include "qetproject.h"
#include "singleapplication.h"
#include "utils/qetsettings.h"
@@ -249,6 +250,11 @@ QGuiApplication::setHighDpiScaleFactorRoundingPolicy(QetSettings::hdpiScaleFacto
// runs on a background thread referencing the project and races the
// process exit (intermittent segfault in QET::writeToFile).
QETProject::setBackupEnabled(false);
// Answer message boxes instead of showing them: opening a project
// saved by an older QElectroTech raises a warning from
// QETProject::readProjectXml(), and with nobody able to dismiss it
// QDialog::exec() would spin its event loop forever.
QET::QetMessageBox::setNonInteractive(true);
return CLIExport::run(export_app.arguments());
}
}
+90
View File
@@ -17,6 +17,84 @@
*/
#include "qetmessagebox.h"
#include <QTextStream>
namespace {
bool g_non_interactive = false;
/**
@brief autoAnswer
Report a message box on stderr and pick an answer, for use when there
is no user to click anything. @see QET::QetMessageBox::setNonInteractive
@param severity : short word naming the kind of box, for the log line
@param title
@param text
@param buttons : the buttons the caller offered
@param defaultButton : the caller's preferred answer, may be NoButton
@return the button to report as pressed
*/
QMessageBox::StandardButton autoAnswer(
const char *severity,
const QString &title,
const QString &text,
QMessageBox::StandardButtons buttons,
QMessageBox::StandardButton defaultButton)
{
//Honour the caller's own default when it named one.
if (defaultButton != QMessageBox::NoButton
&& (buttons & defaultButton)) {
QTextStream(stderr) << severity << ": " << title << " -- " << text
<< "\n(no display: answered with the caller's default button)\n";
return defaultButton;
}
//Otherwise prefer a "carry on" answer over one that cancels, so a
//batch run completes rather than silently doing nothing.
static const QMessageBox::StandardButton preference[] = {
QMessageBox::Ok, QMessageBox::Open, QMessageBox::Yes,
QMessageBox::Save, QMessageBox::Apply, QMessageBox::YesToAll,
QMessageBox::Retry, QMessageBox::Ignore, QMessageBox::Close
};
for (auto candidate : preference) {
if (buttons & candidate) {
QTextStream(stderr) << severity << ": " << title << " -- " << text
<< "\n(no display: continuing)\n";
return candidate;
}
}
//Nothing affirmative on offer -- fall back to whatever is set.
for (int bit = QMessageBox::Ok; bit <= QMessageBox::RestoreDefaults; bit <<= 1) {
auto candidate = static_cast<QMessageBox::StandardButton>(bit);
if (buttons & candidate) {
QTextStream(stderr) << severity << ": " << title << " -- " << text
<< "\n(no display: answered automatically)\n";
return candidate;
}
}
QTextStream(stderr) << severity << ": " << title << " -- " << text
<< "\n(no display: no button offered)\n";
return QMessageBox::NoButton;
}
}
/**
@brief QET::QetMessageBox::setNonInteractive
@param non_interactive
*/
void QET::QetMessageBox::setNonInteractive(bool non_interactive) {
g_non_interactive = non_interactive;
}
/**
@brief QET::QetMessageBox::isNonInteractive
@return true when message boxes are answered without a user
*/
bool QET::QetMessageBox::isNonInteractive() {
return g_non_interactive;
}
/**
@see Documentation Qt pour QMessageBox::critical
*/
@@ -27,6 +105,9 @@ QMessageBox::StandardButton QET::QetMessageBox::critical (
QMessageBox::StandardButtons buttons,
QMessageBox::StandardButton defaultButton)
{
if (g_non_interactive) {
return autoAnswer("Critical", title, text, buttons, defaultButton);
}
#ifdef Q_OS_MACOS
QMessageBox message_box(
QMessageBox::Critical,
@@ -59,6 +140,9 @@ QMessageBox::StandardButton QET::QetMessageBox::information(
QMessageBox::StandardButtons buttons,
QMessageBox::StandardButton defaultButton)
{
if (g_non_interactive) {
return autoAnswer("Information", title, text, buttons, defaultButton);
}
#ifdef Q_OS_MACOS
QMessageBox message_box(
QMessageBox::Information,
@@ -91,6 +175,9 @@ QMessageBox::StandardButton QET::QetMessageBox::question (
QMessageBox::StandardButtons buttons,
QMessageBox::StandardButton defaultButton)
{
if (g_non_interactive) {
return autoAnswer("Question", title, text, buttons, defaultButton);
}
#ifdef Q_OS_MACOS
QMessageBox message_box(
QMessageBox::Question,
@@ -123,6 +210,9 @@ QMessageBox::StandardButton QET::QetMessageBox::warning (
QMessageBox::StandardButtons buttons,
QMessageBox::StandardButton defaultButton)
{
if (g_non_interactive) {
return autoAnswer("Warning", title, text, buttons, defaultButton);
}
#ifdef Q_OS_MACOS
QMessageBox message_box(
QMessageBox::Warning,
+22
View File
@@ -27,6 +27,28 @@ namespace QET {
Qt:Sheet flag, thus enabling a better MacOS integration.
*/
namespace QetMessageBox {
/**
Enable non-interactive mode.
In non-interactive mode the functions below never construct a
dialog. They write the message to stderr and return an answer
immediately, so a headless run cannot block on a modal box that
nobody is there to dismiss.
This is needed because these are reachable from the command-line
tools: opening a project written by an older QElectroTech raises
a warning from QETProject::readProjectXml(), and with no display
to click it, QDialog::exec() spins its event loop forever.
The answer is chosen as: the caller's defaultButton when it gave
one, otherwise the first "carry on" button among those offered
(Ok, Open, Yes, Save, Apply...), otherwise the first button set.
So the two warnings above resolve to Open and the project loads,
which is what a batch invocation wants.
*/
void setNonInteractive(bool non_interactive);
bool isNonInteractive();
QMessageBox::StandardButton critical (
QWidget *,
const QString &,