Stop a query in a project file from hanging QElectroTech for ever

A <graphics_table>'s <query> 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 <query> 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) <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-23 04:16:21 +12:00
parent 1212f48c6d
commit 3fa5e0a475
3 changed files with 54 additions and 0 deletions
+19
View File
@@ -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));