From 3fa5e0a4758f882e406b1172fd3102cabd73fad3 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 23 Sep 2026 04:16:21 +1200 Subject: [PATCH] Stop a query in a project file from hanging QElectroTech for ever A 's is stored in the .qet and executed when the project loads. SQLite produces rows lazily, so the cost of that query is not bounded by anything the project contains -- it is bounded by how long the loop reading the rows is willing to run. A recursive CTE takes one line to make that forever: WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) SELECT n ... Put that in the of any project's summary table and opening the file pins a core at 100% and grows ProjectDBModel::m_record until memory runs out. Measured on examples/industrial.qet with the query swapped, built from master: clean --export-bom 3.6 s, 396 rows, exit 0 poisoned --export-bom killed at 90 s, still going, no output No scripting, no MCP, no flag beyond an ordinary export. Opening the file in the editor is the same code path. QetScriptApi::query() has the identical loop, and the script engine's own 30 s interrupt does not reach it: that aborts JavaScript, and this is C++ inside a single call. Left alone it hung a --run for 45 s until the harness killed it. Both loops now stop at projectDataBase::MaxResultRows (100000) and say so. That is a backstop, not a page size: the largest table in the shipped examples is 396 rows, and a caller that reaches 100000 has been handed something it should not run to completion. It is not silent either way -- the model logs the offending query text, and qet.query() sets queryError(), so a truncated result is never mistaken for a complete one. clean --export-bom 3.6 s, 396 rows, exit 0 (unchanged) poisoned --export-bom 20.2 s, 396 rows, exit 0, warning names the query qet.query(recursive CTE) 3.8 s, 100000 rows, queryError() set Reverting each cap restores the hang, so both checks discriminate. Related to #983, which fixes a different flaw reachable through the same stored query. Neither depends on the other. Co-Authored-By: Claude Opus 5 (1M context) --- sources/dataBase/projectdatabase.h | 20 +++++++++++++++++++ .../ViewItem/projectdbmodel.cpp | 15 ++++++++++++++ sources/scripting/qetscriptapi.cpp | 19 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/sources/dataBase/projectdatabase.h b/sources/dataBase/projectdatabase.h index abd5e966c..feef342d2 100644 --- a/sources/dataBase/projectdatabase.h +++ b/sources/dataBase/projectdatabase.h @@ -61,6 +61,26 @@ class projectDataBase : public QObject QETProject *project() const; QSqlQuery newQuery(const QString &query = QString(), QString *error = nullptr); static bool isReadOnlySelect(const QString &query, QString *error = nullptr); + + /** + The most rows any caller reads out of one query result. + + A SELECT is not bounded by how much data the project holds: + SQLite produces rows lazily, so a query that never stops + producing them makes the loop that reads them never stop + either. A recursive CTE does exactly that in one line, and + a 's is stored in the .qet and run + on load -- so the text can arrive from a file rather than + from the person at the keyboard, and opening that file is + the whole attack. + + 100000 is far above any real result: the largest table in + the shipped examples is 396 rows. It is a backstop, not a + page size -- a caller that hits it has almost certainly + been handed something it should not run to completion, and + says so rather than truncating quietly. + */ + static constexpr int MaxResultRows = 100000; QSqlDatabase database() const {return m_data_base;} int excludedConductorCount() const; diff --git a/sources/qetgraphicsitem/ViewItem/projectdbmodel.cpp b/sources/qetgraphicsitem/ViewItem/projectdbmodel.cpp index 1fe814b6a..ca07e5ba8 100644 --- a/sources/qetgraphicsitem/ViewItem/projectdbmodel.cpp +++ b/sources/qetgraphicsitem/ViewItem/projectdbmodel.cpp @@ -379,6 +379,21 @@ void ProjectDBModel::fillValue() while (query_.next()) { + //This query text comes out of the project file, so its result is + //not bounded by anything the project actually contains: a + //recursive CTE produces rows for as long as anyone reads them. + //Without this, opening such a file hangs QElectroTech at 100% CPU + //while m_record grows until memory runs out. @see + //projectDataBase::MaxResultRows. + if (m_record.size() >= projectDataBase::MaxResultRows) { + qWarning().noquote() + << "ProjectDBModel: query stopped after" + << projectDataBase::MaxResultRows + << "rows, which is far more than a folio table can show." + << "The table is incomplete. Query:" << m_query; + break; + } + QStringList record_; auto i=0; while (query_.value(i).isValid()) diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index c8f30b417..895f1a492 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -1217,6 +1217,9 @@ QStringList QetScriptApi::tables() const @return the rows; empty on refusal or SQL error, with queryError() saying which. An empty result and a failure are not the same thing. + A result that ran past projectDataBase::MaxResultRows is cut there and + queryError() says so, so a truncated list is never mistaken for a + complete one. */ QVariantList QetScriptApi::query(const QString &sql) { @@ -1243,6 +1246,22 @@ QVariantList QetScriptApi::query(const QString &sql) const QSqlRecord record = q.record(); while (q.next()) { + //SQLite produces rows lazily, so a query that never stops + //producing them makes this loop never stop either -- "WITH + //RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) SELECT n + //FROM c" is one line and runs until memory is gone. The script + //engine's own 30 s interrupt does not reach here: that aborts + //JavaScript execution, and this is C++ inside a single call. + //@see projectDataBase::MaxResultRows. + if (rows.size() >= projectDataBase::MaxResultRows) { + m_query_error = QStringLiteral( + "result truncated at %1 rows; add a LIMIT or a " + "WHERE clause") + .arg(projectDataBase::MaxResultRows); + log(QStringLiteral("qet.query: %1").arg(m_query_error)); + break; + } + QVariantMap row; for (int i = 0 ; i < record.count() ; ++i) { row.insert(record.fieldName(i), q.value(i));