From e9c26fbebfa9f1d824c83649e1a0dbc12baa8dab Mon Sep 17 00:00:00 2001 From: Wang Zichong Date: Mon, 10 Aug 2026 10:19:18 +0800 Subject: [PATCH] fix: possible proxy model desync bug on model reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (RoleCombineModel 缺少 modelReset 转发) — 可触发 AbstractWindowMonitor::clearTrackedWindows() 会调用 beginResetModel() / endResetModel(),在以下时机被调用: treelandwindowmonitor.cpp:105 和 x11windowmonitor.cpp:94 — 当显示服务器重连或 compositor 重启时,窗口监视器会清空所有跟踪窗口并复位模型 这个 modelReset 会穿过 BoolFilterModel 到达 RoleCombineModel 的 major model。修复前,复位后 m_indexMap 残留旧映射且下游收不到 reset 信号,导致新旧数据冲突。这是一个生产环境可触发的 bug。 Bug 2 (RoleGroupModel rowsRemoved 跳过后续分组) — 当前不可触发,属于防御性修复 RoleGroupModel 的源模型是 DockGlobalElementModel,其所有删除路径(destroyWindow 单窗口关闭、DockGlobalElementModel 内部循环)都是逐个调用 beginRemoveRows(pos, pos) / endRemoveRows(),每次只有 first == last。因此 range 分支在当前代码中永远不会被执行,--i 缺失不会造成问题。但 handler 的接口签名支持 first..last 范围,LargeDataMemoryStabilityTest 测试也直接传入了范围,若未来有代码路径直接传入多行范围,就会触发。这是一个防御性修复。 Log: --- panels/dock/taskmanager/rolecombinemodel.cpp | 17 +++ panels/dock/taskmanager/rolegroupmodel.cpp | 1 + .../panels/dock/taskmanager/combinemodela.cpp | 8 ++ tests/panels/dock/taskmanager/combinemodela.h | 1 + .../panels/dock/taskmanager/combinemodelb.cpp | 8 ++ tests/panels/dock/taskmanager/combinemodelb.h | 1 + .../taskmanager/rolecombinemodeltests.cpp | 94 ++++++++++++++++ .../dock/taskmanager/rolegroupmodeltests.cpp | 102 ++++++++++++++++++ 8 files changed, 232 insertions(+) diff --git a/panels/dock/taskmanager/rolecombinemodel.cpp b/panels/dock/taskmanager/rolecombinemodel.cpp index bb5345180..b19efefe1 100644 --- a/panels/dock/taskmanager/rolecombinemodel.cpp +++ b/panels/dock/taskmanager/rolecombinemodel.cpp @@ -158,6 +158,23 @@ RoleCombineModel::RoleCombineModel(QAbstractItemModel* major, QAbstractItemModel endRemoveColumns(); }); + // forward modelReset from major source + connect(sourceModel(), &QAbstractItemModel::modelReset, this, [this, majorRoles, func]() { + beginResetModel(); + m_indexMap.clear(); + int rowCount = sourceModel()->rowCount(); + int columnCount = sourceModel()->columnCount(); + for (int i = 0; i < rowCount; i++) { + for (int j = 0; j < columnCount; j++) { + QModelIndex majorIndex = sourceModel()->index(i, j); + QModelIndex minorIndex = func(majorIndex.data(majorRoles), m_minor); + if (majorIndex.isValid() && minorIndex.isValid()) + m_indexMap[qMakePair(i, j)] = qMakePair(minorIndex.row(), minorIndex.column()); + } + } + endResetModel(); + }); + // connect changedSignal connect(major, &QAbstractItemModel::dataChanged, this, [this, majorRoles, func](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList &roles){ diff --git a/panels/dock/taskmanager/rolegroupmodel.cpp b/panels/dock/taskmanager/rolegroupmodel.cpp index 6ae68d032..f6360e71a 100644 --- a/panels/dock/taskmanager/rolegroupmodel.cpp +++ b/panels/dock/taskmanager/rolegroupmodel.cpp @@ -88,6 +88,7 @@ void RoleGroupModel::setSourceModel(QAbstractItemModel *model) m_rowMap.removeOne(sourceRows); delete sourceRows; endRemoveRows(); + --i; } } adjustMap(first, -((last - first) + 1)); diff --git a/tests/panels/dock/taskmanager/combinemodela.cpp b/tests/panels/dock/taskmanager/combinemodela.cpp index 9db1915b6..101ab5d2d 100644 --- a/tests/panels/dock/taskmanager/combinemodela.cpp +++ b/tests/panels/dock/taskmanager/combinemodela.cpp @@ -93,3 +93,11 @@ void TestModelA::removeData(DataA *data) delete data; endRemoveRows(); } + +void TestModelA::resetModel() +{ + beginResetModel(); + qDeleteAll(m_list); + m_list.clear(); + endResetModel(); +} diff --git a/tests/panels/dock/taskmanager/combinemodela.h b/tests/panels/dock/taskmanager/combinemodela.h index 8722e8a3d..b62cdca2a 100644 --- a/tests/panels/dock/taskmanager/combinemodela.h +++ b/tests/panels/dock/taskmanager/combinemodela.h @@ -41,6 +41,7 @@ class TestModelA : public QAbstractListModel void addData(DataA *data); void removeData(DataA *data); + void resetModel(); private: QList m_list; diff --git a/tests/panels/dock/taskmanager/combinemodelb.cpp b/tests/panels/dock/taskmanager/combinemodelb.cpp index b0cbd66b0..255886c90 100644 --- a/tests/panels/dock/taskmanager/combinemodelb.cpp +++ b/tests/panels/dock/taskmanager/combinemodelb.cpp @@ -93,3 +93,11 @@ void TestModelB::removeData(DataB *data) delete data; endRemoveRows(); } + +void TestModelB::clear() +{ + beginResetModel(); + qDeleteAll(m_list); + m_list.clear(); + endResetModel(); +} diff --git a/tests/panels/dock/taskmanager/combinemodelb.h b/tests/panels/dock/taskmanager/combinemodelb.h index c836daa0d..2b39af1a6 100644 --- a/tests/panels/dock/taskmanager/combinemodelb.h +++ b/tests/panels/dock/taskmanager/combinemodelb.h @@ -40,6 +40,7 @@ class TestModelB : public QAbstractListModel void addData(DataB *data); void removeData(DataB *data); + void clear(); private: diff --git a/tests/panels/dock/taskmanager/rolecombinemodeltests.cpp b/tests/panels/dock/taskmanager/rolecombinemodeltests.cpp index e14630150..f68089e25 100644 --- a/tests/panels/dock/taskmanager/rolecombinemodeltests.cpp +++ b/tests/panels/dock/taskmanager/rolecombinemodeltests.cpp @@ -356,3 +356,97 @@ TEST(RoleCombineModel, ParentParameterHandlingFix) EXPECT_EQ(model.rowCount(), 1); EXPECT_TRUE(model.index(0, 0).isValid()); } + +// ---- 验证 Bug: RoleCombineModel 不处理 source modelReset ---- +// 当 major 源模型(如窗口 monitor)调用 beginResetModel/endResetModel 时, +// RoleCombineModel 应当重建 m_indexMap 并发出 modelReset 通知下游视图。 +// 当前实现缺少 modelReset/layoutChanged 连接,导致 m_indexMap 残留旧映射、 +// 下游 DockGlobalElementModel 不清理过期条目 -> 任务栏出现幽灵/重复图标。 +TEST(RoleCombineModel, MajorModelResetForwarding) +{ + TestModelA modelA; + TestModelB modelB; + + auto combineFunc = [](QVariant data, QAbstractItemModel *model) -> QModelIndex { + auto matches = model->match(model->index(0, 0), TestModelB::idRole, data); + return matches.isEmpty() ? QModelIndex() : matches.first(); + }; + + RoleCombineModel model(&modelA, &modelB, TestModelA::idRole, combineFunc); + + modelA.addData(new DataA(0, "a0", &modelA)); + modelA.addData(new DataA(1, "a1", &modelA)); + modelB.addData(new DataB(0, "b0", &modelB)); + modelB.addData(new DataB(1, "b1", &modelB)); + + ASSERT_EQ(model.rowCount(), 2); + + QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); + + // 模拟窗口 monitor 的 clear() 操作:beginResetModel + 清空 + endResetModel + modelA.resetModel(); + + // 源已清空,rowCount 应归零 + EXPECT_EQ(model.rowCount(), 0); + + // 必须发出 modelReset 信号,否则下游视图永远不知道行已消失 + // BUG: 当前实现不会发出此信号,此断言会 FAIL + EXPECT_EQ(resetSpy.count(), 1) << "FAIL: RoleCombineModel did not forward modelReset from major source"; + + // 重新添加数据后,新行应正确映射,不应残留旧行 + modelA.addData(new DataA(2, "a2", &modelA)); + modelB.addData(new DataB(2, "b2", &modelB)); + + EXPECT_EQ(model.rowCount(), 1); + auto roleNames = model.roleNames(); + auto roleNamesB = modelB.roleNames(); + QHash names2Role; + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + names2Role.insert(it.value(), it.key()); + } + int bDataRole = names2Role.value(roleNamesB.value(TestModelB::dataRole)); + EXPECT_EQ(model.index(0, 0).data(bDataRole).toString(), "b2"); +} + +// ---- 验证 Bug: RoleCombineModel 不处理 minor modelReset ---- +// 当 minor 源模型(apps 模型)复位时,RoleCombineModel 同样应重建映射。 +TEST(RoleCombineModel, MinorModelResetForwarding) +{ + TestModelA modelA; + TestModelB modelB; + + auto combineFunc = [](QVariant data, QAbstractItemModel *model) -> QModelIndex { + auto matches = model->match(model->index(0, 0), TestModelB::idRole, data); + return matches.isEmpty() ? QModelIndex() : matches.first(); + }; + + RoleCombineModel model(&modelA, &modelB, TestModelA::idRole, combineFunc); + + modelA.addData(new DataA(0, "a0", &modelA)); + modelA.addData(new DataA(1, "a1", &modelA)); + modelB.addData(new DataB(0, "b0", &modelB)); + modelB.addData(new DataB(1, "b1", &modelB)); + + ASSERT_EQ(model.rowCount(), 2); + + // 模拟 apps 模型复位:清除所有数据并重新添加 + // 当前实现没有连接 minor 的 modelReset,m_indexMap 中的映射会残留 + // 这里只验证不会崩溃,以及重新添加后映射仍能正确工作 + modelB.clear(); + modelB.addData(new DataB(0, "b0_new", &modelB)); + modelB.addData(new DataB(1, "b1_new", &modelB)); + modelB.addData(new DataB(2, "b2_new", &modelB)); + + auto roleNames = model.roleNames(); + auto roleNamesB = modelB.roleNames(); + QHash names2Role; + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + names2Role.insert(it.value(), it.key()); + } + int bDataRole = names2Role.value(roleNamesB.value(TestModelB::dataRole)); + + // 重新添加后,映射应能正确找到新数据 + // BUG: m_indexMap 中的旧映射没有重建,数据和索引可能不匹配 + EXPECT_EQ(model.index(0, 0).data(bDataRole).toString(), "b0_new"); + EXPECT_EQ(model.index(1, 0).data(bDataRole).toString(), "b1_new"); +} diff --git a/tests/panels/dock/taskmanager/rolegroupmodeltests.cpp b/tests/panels/dock/taskmanager/rolegroupmodeltests.cpp index f02870535..4e07ecd2f 100644 --- a/tests/panels/dock/taskmanager/rolegroupmodeltests.cpp +++ b/tests/panels/dock/taskmanager/rolegroupmodeltests.cpp @@ -634,3 +634,105 @@ TEST(RoleGroupModel, ScrollingBoundaryTest) EXPECT_FALSE(negativeChild.isValid()); } } + +// ---- 验证 Bug: RoleGroupModel::rowsRemoved 批量删除时跳过后续分组 ---- +// 当一次 rowsRemoved 覆盖多行、且中间某个分组被删空时,该分组从 m_rowMap 移除后 +// 循环下标前移,导致后续分组被跳过、其成员行未被移除, +// 随后 adjustMap 甚至可能把残留的源行号修正为负数。 +TEST(RoleGroupModel, RowsRemovedRangeSkip) +{ + QStandardItemModel model; + auto role = Qt::UserRole + 1; + RoleGroupModel groupModel(&model, role); + + // 构造 3 个分组:g0=[0], g1=[2], g2=[3,4](行 1 为空数据, 不分组) + QStandardItem *g0a = new QStandardItem; + g0a->setData(QString("g0"), role); + model.appendRow(g0a); // source row 0 + + model.appendRow(new QStandardItem); // source row 1, empty -> not grouped + + QStandardItem *g1a = new QStandardItem; + g1a->setData(QString("g1"), role); + model.appendRow(g1a); // source row 2 + + QStandardItem *g2a = new QStandardItem; + g2a->setData(QString("g2"), role); + model.appendRow(g2a); // source row 3 + + QStandardItem *g2b = new QStandardItem; + g2b->setData(QString("g2"), role); + model.appendRow(g2b); // source row 4 + + ASSERT_EQ(groupModel.rowCount(), 3); // g0, g1, g2 + ASSERT_EQ(groupModel.rowCount(groupModel.index(0, 0)), 1); // g0: 1 child + ASSERT_EQ(groupModel.rowCount(groupModel.index(1, 0)), 1); // g1: 1 child + ASSERT_EQ(groupModel.rowCount(groupModel.index(2, 0)), 2); // g2: 2 children + + // 删除 source rows 0..2:g0 的唯一子项被删(g0 变空),g1 的唯一子项被删 + // BUG: g0 变空后从 m_rowMap 移除,循环下标前移,g2 中的 row 3 不会被处理 + // 而且 g2 中的 row 2 残留,adjustMap 后变成负数。 + model.removeRows(0, 3); + + // g2 应保留,有 2 个子项(原 row 3,4 调整后变为 row 0,1) + EXPECT_EQ(groupModel.rowCount(), 1) << "FAIL: expected 1 group (g2), got " << groupModel.rowCount(); + + if (groupModel.rowCount() > 0) { + auto g2Idx = groupModel.index(0, 0); + int childCount = groupModel.rowCount(g2Idx); + EXPECT_EQ(childCount, 2) << "FAIL: g2 should have 2 children, got " << childCount; + + // 验证 mapToSource 返回的索引都在有效范围内 + for (int i = 0; i < childCount; ++i) { + auto child = groupModel.index(i, 0, g2Idx); + auto src = groupModel.mapToSource(child); + EXPECT_TRUE(src.isValid()) << "FAIL: child " << i << " maps to invalid source row"; + EXPECT_LT(src.row(), model.rowCount()) << "FAIL: child " << i << " source row out of range"; + } + } +} + +// ---- 验证 Bug: RoleGroupModel 发出带有效 parent 的子级 dataChanged ---- +// 当源模型中分组内某行的非去重角色数据改变时,RoleGroupModel 发出的 dataChanged +// 是一个子级索引(parent 有效)。下游的 DockItemModel 在分组模式下直接取 +// topLeft.row() 作为顶层行号,导致刷新到错误的行或越界。 +TEST(RoleGroupModel, ChildDataChangedHasValidParent) +{ + QStandardItemModel model; + auto role = Qt::UserRole + 1; + RoleGroupModel groupModel(&model, role); + + // 构造一个分组 "app" 包含 2 个子项 + QStandardItem *a = new QStandardItem; + a->setData(QString("app"), role); + model.appendRow(a); + + QStandardItem *b = new QStandardItem; + b->setData(QString("app"), role); + model.appendRow(b); + + ASSERT_EQ(groupModel.rowCount(), 1); + auto groupIdx = groupModel.index(0, 0); + ASSERT_EQ(groupModel.rowCount(groupIdx), 2); + + // 监听 dataChanged 信号,检查 child 索引的 parent 是否有效 + QSignalSpy spy(&groupModel, &QAbstractItemModel::dataChanged); + + // 改变非去重角色(Qt::DisplayRole),不触发去重重建 + model.setData(model.index(1, 0), QVariant("new-title"), Qt::DisplayRole); + + bool sawChildWithValidParent = false; + for (const auto &args : spy) { + if (args.size() >= 1) { + QModelIndex tl = args[0].value(); + if (tl.parent().isValid()) { + sawChildWithValidParent = true; + break; + } + } + } + + // BUG: 子级 dataChanged 会被下游 DockItemModel 错误地转发为顶层行号 + EXPECT_TRUE(sawChildWithValidParent) + << "FAIL: RoleGroupModel should emit child dataChanged with valid parent"; +}