Revision control
Copy as Markdown
// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
#include <QTest>
#include <QtQuick3D/private/qquick3dnode_p.h>
#include <QtQuick3D/private/qquick3dobject_p.h>
#include <QtQuick3D/private/qquick3dscenemanager_p.h>
class tst_QQuick3DSceneManager : public QObject
{
Q_OBJECT
private slots:
void refSceneManagerSurvivesManagerDeletion();
void dirtyObjectSurvivesManagerDeletion();
void detachLeavesObjectReQueueable();
void reentrantDirtyDuringUpdateSpatialNodeIsNotDropped();
};
// A QML binding evaluated as a side effect of updateSpatialNode() (e.g. Model::instanceRoot
// bound to Model::bounds) can call dirty() on the very object that is currently being synced,
// from inside its own updateSpatialNode() call. This stand-in reproduces that reentrancy without
// needing a Model or any particular property.
//
// Plain QQuick3DObject (an "Unknown"-type, i.e. resource) is used rather than QQuick3DNode:
// the node sync path (updateDirtySpatialNode()) additionally wires the object into a scene
// graph rooted at a QQuick3DWindowAttachment, which this lightweight test has no need to set
// up. The resource sync path (updateDirtyResource()) exercises the exact same reentrancy-guard
// code and has none of that scaffolding.
class ReentrantDirtyObject : public QQuick3DObject
{
public:
int updateCount = 0;
QSSGRenderGraphObject *updateSpatialNode(QSSGRenderGraphObject *node) override
{
++updateCount;
QSSGRenderGraphObject *result = QQuick3DObject::updateSpatialNode(node);
if (updateCount == 1) {
// Simulate the reentrant re-dirty happening exactly once, on the first sync,
// the way a one-shot property-watcher registration or a binding tied to a
// property this very update just changed would.
QQuick3DObjectPrivate::get(this)->dirty(QQuick3DObjectPrivate::OpacityValue);
}
return result;
}
};
// An object can be referenced by more than one view. When the view owning the tracked scene
// manager is destroyed, the manager is deleted and the object's QPointer auto-clears, but the
// reference count is not decremented. A subsequent refSceneManager() with a new manager used to
// dereference the dead manager (sceneManager->window()) and crash when the count was already > 1.
// This verifies that the new manager is re-adopted instead, and that the object can still be
// torn down cleanly afterwards. See QTBUG-144962 and QTBUG-88320.
void tst_QQuick3DSceneManager::refSceneManagerSurvivesManagerDeletion()
{
auto *object = new QQuick3DNode;
auto *priv = QQuick3DObjectPrivate::get(object);
auto *managerA = new QQuick3DSceneManager;
// Reference the object twice from the same manager, This leaves sceneRefCount at 2.
QQuick3DObjectPrivate::refSceneManager(object, *managerA);
QQuick3DObjectPrivate::refSceneManager(object, *managerA);
QCOMPARE(priv->sceneManager.data(), managerA);
QCOMPARE(priv->sceneRefCount, 2);
// The owning view is destroyed: its scene manager is deleted, the QPointer auto-clears to
// null, but sceneRefCount stays at 2 (Expected).
delete managerA;
QVERIFY(priv->sceneManager.isNull());
QCOMPARE(priv->sceneRefCount, 2);
// A new view references the same object. It must now re-adopt the new, valid manager.
auto *managerB = new QQuick3DSceneManager;
QQuick3DObjectPrivate::refSceneManager(object, *managerB); // must not crash
QCOMPARE(priv->sceneManager.data(), managerB);
// Destroy the object while the manager is still alive. This exercises the destructor's
// sceneRefCount clamp and the final derefSceneManager() cleanup path; it must not crash.
delete object;
delete managerB;
}
// An object on a scene manager's dirty list keeps a prevDirtyItem pointer into the manager's
// dirty-list arrays. Deleting the manager must detach such objects, otherwise that pointer
// dangles and the next removeFromDirtyList() (e.g. when the object is later destroyed) writes
// through freed memory.
void tst_QQuick3DSceneManager::dirtyObjectSurvivesManagerDeletion()
{
auto *manager = new QQuick3DSceneManager;
auto *object = new QQuick3DNode;
auto *priv = QQuick3DObjectPrivate::get(object);
// Referencing the manager puts the object into the manager's dirty list.
QQuick3DObjectPrivate::refSceneManager(object, *manager);
object->update();
QVERIFY(priv->prevDirtyItem != nullptr);
// Deleting the manager must clear the dirty-list links that point into it.
delete manager;
QVERIFY(priv->prevDirtyItem == nullptr);
QVERIFY(priv->nextDirtyItem == nullptr);
// With the links cleared call removeFromDirtyList() should be a safe no-op.
priv->removeFromDirtyList();
delete object;
}
// Verifies that objects can be re-scheduled when the scene manager is destroyed.
// addToDirtyList() only re-adds an object when prevDirtyItem is null.
// A stale (non-null) prevDirtyItem would block any later re-add!
//
// NOTE: this drives refSceneManager() directly, so it does NOT exercise the
// QQuick3DViewport::updateSceneManagerForImportScene() / destroyed() contract,
// that is covered by tst_importscenelifetime's sharedSceneReQueuedAfterHomeViewDestroyed().
void tst_QQuick3DSceneManager::detachLeavesObjectReQueueable()
{
auto *managerA = new QQuick3DSceneManager;
auto *object = new QQuick3DNode;
auto *priv = QQuick3DObjectPrivate::get(object);
QQuick3DObjectPrivate::refSceneManager(object, *managerA);
object->update();
QVERIFY(priv->prevDirtyItem != nullptr); // dirty, on A's list
// Simulate the view, and its scene manager, being destroyed. Dirty objects needs
// to be detached from the list.
delete managerA;
QVERIFY(priv->prevDirtyItem == nullptr);
QVERIFY(priv->sceneManager.isNull());
// Verify that the object can be re-queued with a new manager. A stale prevDirtyItem would block this.
auto *managerB = new QQuick3DSceneManager;
QQuick3DObjectPrivate::refSceneManager(object, *managerB);
QCOMPARE(priv->sceneManager.data(), managerB);
QVERIFY(priv->prevDirtyItem != nullptr); // re-queued on B!
delete object;
delete managerB;
}
// If dirty() is re-entered on an object while that object is still inside its own
// updateSpatialNode() call, the pending change it flags must not be silently dropped: the
// object needs to end up back on a dirty list so a later sync actually revisits it. See
// QTBUG-149055.
void tst_QQuick3DSceneManager::reentrantDirtyDuringUpdateSpatialNodeIsNotDropped()
{
auto *manager = new QQuick3DSceneManager;
auto *object = new ReentrantDirtyObject;
auto *priv = QQuick3DObjectPrivate::get(object);
QQuick3DObjectPrivate::refSceneManager(object, *manager);
object->update();
QVERIFY(priv->prevDirtyItem != nullptr);
// First sync: updateSpatialNode() reentrantly re-dirties the object.
manager->updateDirtyResourceNodes();
QCOMPARE(object->updateCount, 1);
// The reentrant dirty() call must leave the object relinked into a dirty list so a later
// sync actually revisits it, instead of merely dirty-flagged with nowhere to go.
QVERIFY(priv->dirtyAttributes != 0);
QVERIFY(priv->prevDirtyItem != nullptr);
// A subsequent sync must actually process the pending change that was set reentrantly.
manager->updateDirtyResourceNodes();
QCOMPARE(object->updateCount, 2);
QCOMPARE(priv->dirtyAttributes, 0u);
QVERIFY(priv->prevDirtyItem == nullptr);
delete object;
delete manager;
}
QTEST_MAIN(tst_QQuick3DSceneManager)
#include "tst_qquick3dscenemanager.moc"