|
4 | 4 | #include <QJsonParseError> |
5 | 5 | #include <QMetaObject> |
6 | 6 | #include <QVariantMap> |
| 7 | +#include <QtConcurrent> |
7 | 8 |
|
8 | 9 | #include <nlohmann/json.hpp> |
9 | 10 |
|
@@ -812,36 +813,32 @@ QJsonObject NotebookCoreService::listFolderChildren(const QString &p_notebookId, |
812 | 813 | return parseJsonObjectFromCStr(json); |
813 | 814 | } |
814 | 815 |
|
815 | | -// T6 (notebook-explorer-drag-reorder): reorder with hooks. |
| 816 | +// T6 (notebook-explorer-drag-reorder): async reorder with hooks + IoGate. |
816 | 817 | // |
817 | | -// Threading layout — ALL vxcore work runs SYNCHRONOUSLY on the caller (GUI) |
818 | | -// thread, exactly like every sibling folder mutation (createFolder / |
819 | | -// createFile / rename / delete). This is mandatory for correctness: vxcore's |
820 | | -// FolderManager config cache is NOT thread-safe and the model reads |
821 | | -// (NotebookNodeModel::fetchMore -> listFolderChildren) run on the GUI thread, |
822 | | -// so the reorder's read-modify-write MUST be one atomic GUI-thread unit with |
823 | | -// respect to those reads. An earlier design dispatched the write to a |
824 | | -// QtConcurrent worker that only held NotebookIoGate while marshalling the |
825 | | -// vxcore call back to the GUI thread; that left the snapshot read and the |
826 | | -// write as two separate GUI-thread events, with the QSignalSpy::wait nested |
827 | | -// event loop and queued model/folder events free to interleave between them — |
828 | | -// the source of a deterministic VXCORE_ERR_PERMUTATION_MISMATCH on 2-core CI. |
829 | | -// |
830 | | -// 1. Pre-validate args. |
831 | | -// 2. Snapshot current order via listFolderChildren. |
832 | | -// 3. No-op short-circuit (no hooks, no write). |
833 | | -// 4. Fire before_reorder synchronously (gives plugins a cancel slot |
834 | | -// BEFORE any disk work happens). |
835 | | -// 5. Perform the vxcore_folder_set_children_order write synchronously. |
836 | | -// 6. Deliver completion (after-hook + reorderCompleted signal) ASYNCHRONOUSLY |
837 | | -// via Qt::QueuedConnection, preserving the public async contract for |
838 | | -// callers/tests that wait for the signal after this function returns. |
| 818 | +// Threading layout: |
| 819 | +// Caller thread (typically GUI): |
| 820 | +// 1. Pre-validate args. |
| 821 | +// 2. Snapshot current order via listFolderChildren. |
| 822 | +// 3. No-op short-circuit (no hooks, no worker). |
| 823 | +// 4. Fire before_reorder synchronously (gives plugins a cancel slot |
| 824 | +// BEFORE any disk work is queued). |
| 825 | +// 5. Dispatch to QtConcurrent worker; return. |
| 826 | +// Worker thread: |
| 827 | +// 6. Hold NotebookIoGate::ScopedLock(notebookId) for the duration of |
| 828 | +// the vxcore_folder_set_children_order call ONLY. |
| 829 | +// 7. Release the gate by leaving the inner { } scope (RAII). |
| 830 | +// 8. Queue completion (after-hook + reorderCompleted signal) onto the |
| 831 | +// service's owning thread via QMetaObject::invokeMethod / |
| 832 | +// Qt::QueuedConnection. Releasing the gate BEFORE queuing the |
| 833 | +// after-hook is what makes it safe for plugins to re-enter the gate |
| 834 | +// from inside the after-hook callback (e.g., to stage a follow-up |
| 835 | +// write) without deadlocking. |
839 | 836 | void NotebookCoreService::reorderFolderChildren(const QString &p_notebookId, |
840 | 837 | const QString &p_folderRelPath, |
841 | 838 | const QStringList &p_orderedFolders, |
842 | 839 | const QStringList &p_orderedFiles) { |
843 | 840 | if (p_notebookId.isEmpty() || (p_orderedFolders.isEmpty() && p_orderedFiles.isEmpty()) || |
844 | | - !m_hookMgr) { |
| 841 | + !m_hookMgr || !m_ioGate) { |
845 | 842 | emit reorderCompleted(p_notebookId, p_folderRelPath, false, tr("Invalid arguments")); |
846 | 843 | return; |
847 | 844 | } |
@@ -884,48 +881,58 @@ void NotebookCoreService::reorderFolderChildren(const QString &p_notebookId, |
884 | 881 | return; |
885 | 882 | } |
886 | 883 |
|
887 | | - // THREADING (CI race fix): the snapshot read above (listFolderChildren), |
888 | | - // this write, and every model read (NotebookNodeModel::fetchMore -> |
889 | | - // listFolderChildren) all hit vxcore's FolderManager, whose config cache is |
890 | | - // NOT thread-safe. A correct reorder needs the read-modify-write to be a |
891 | | - // single ATOMIC unit with respect to those reads. The previous design ran a |
892 | | - // QtConcurrent worker that merely held NotebookIoGate while marshalling the |
893 | | - // sole vxcore call back to the UI thread via BlockingQueuedConnection — so |
894 | | - // the gate window and the actual vxcore-access window were disjoint, and the |
895 | | - // snapshot read (line above) and the write became two separate UI-thread |
896 | | - // events. The QSignalSpy::wait()/nested event loop and queued model+folder |
897 | | - // event delivery could run BETWEEN them, letting the write observe an |
898 | | - // inconsistent root config (current=0) -> VXCORE_ERR_PERMUTATION_MISMATCH on |
899 | | - // the 2-core CI scheduler. |
900 | | - // |
901 | | - // Fix: run the vxcore write SYNCHRONOUSLY on the calling (UI) thread, exactly |
902 | | - // like every sibling NotebookCoreService folder mutation (createFolder, |
903 | | - // createFile, rename, delete) already does. With no worker and no nested |
904 | | - // event loop between the snapshot and the write, the cache the write sees is |
905 | | - // the cache the snapshot saw. The completion is still delivered |
906 | | - // ASYNCHRONOUSLY (Qt::QueuedConnection) so the public reorderCompleted |
907 | | - // contract — and callers/tests that wait for the signal AFTER this returns — |
908 | | - // are unchanged. |
| 884 | + // Capture by value so the worker has stable copies (Qt strings are CoW so |
| 885 | + // this is cheap). Capturing `this` is safe — the service outlives all in- |
| 886 | + // flight reorders by construction (it is destroyed only at shutdown after |
| 887 | + // QThreadPool::waitForDone via ~QtConcurrent::run cleanup). |
909 | 888 | const QString notebookId = p_notebookId; |
910 | 889 | const QString folderRelPath = p_folderRelPath; |
911 | | - const std::string orderedJson = buildOrderedJson(p_orderedFolders, p_orderedFiles); |
912 | | - const int rc = static_cast<int>(vxcore_folder_set_children_order( |
913 | | - m_context, notebookId.toUtf8().constData(), |
914 | | - folderRelPath.isEmpty() ? "." : folderRelPath.toUtf8().constData(), orderedJson.c_str())); |
915 | | - |
916 | | - QMetaObject::invokeMethod( |
917 | | - this, |
918 | | - [this, notebookId, folderRelPath, event, rc]() { |
919 | | - if (rc == VXCORE_OK) { |
920 | | - if (m_hookMgr) { |
921 | | - m_hookMgr->doAction(HookNames::NodeAfterReorder, event); |
| 890 | + const QStringList orderedFolders = p_orderedFolders; |
| 891 | + const QStringList orderedFiles = p_orderedFiles; |
| 892 | + |
| 893 | + QtConcurrent::run([this, notebookId, folderRelPath, orderedFolders, orderedFiles, event]() { |
| 894 | + int rc = VXCORE_ERR_UNKNOWN; |
| 895 | + { |
| 896 | + // Acquire-release window: hold the gate for the working-tree write ONLY. |
| 897 | + // Mirrors the SyncOps::triggerSync stage-phase pattern. |
| 898 | + NotebookIoGate::ScopedLock lock(*m_ioGate, notebookId); |
| 899 | + const std::string orderedJson = buildOrderedJson(orderedFolders, orderedFiles); |
| 900 | + // THREADING: vxcore's FolderManager is NOT thread-safe — its folder-config |
| 901 | + // cache is shared with main-thread reads (NotebookNodeModel::fetchMore -> |
| 902 | + // listFolderChildren). Run the actual vxcore folder write on the MAIN |
| 903 | + // thread so it is serialized with all other main-thread vxcore folder |
| 904 | + // access; otherwise a concurrent main-thread cache reload can free the |
| 905 | + // cached FolderConfig under the worker, yielding the current=0 |
| 906 | + // PERMUTATION_MISMATCH that made this reorder spuriously fail. The |
| 907 | + // NotebookIoGate stays held HERE on the worker (async acquisition that |
| 908 | + // never blocks the UI thread) to keep serializing the working-tree write |
| 909 | + // against save/sync workers; BlockingQueuedConnection keeps the gate held |
| 910 | + // across the write without releasing it early. |
| 911 | + QMetaObject::invokeMethod( |
| 912 | + this, |
| 913 | + [this, &rc, ¬ebookId, &folderRelPath, &orderedJson]() { |
| 914 | + rc = static_cast<int>(vxcore_folder_set_children_order( |
| 915 | + m_context, notebookId.toUtf8().constData(), |
| 916 | + folderRelPath.isEmpty() ? "." : folderRelPath.toUtf8().constData(), |
| 917 | + orderedJson.c_str())); |
| 918 | + }, |
| 919 | + Qt::BlockingQueuedConnection); |
| 920 | + } // Gate released here BEFORE queuing the after-hook. |
| 921 | + |
| 922 | + QMetaObject::invokeMethod( |
| 923 | + this, |
| 924 | + [this, notebookId, folderRelPath, event, rc]() { |
| 925 | + if (rc == VXCORE_OK) { |
| 926 | + if (m_hookMgr) { |
| 927 | + m_hookMgr->doAction(HookNames::NodeAfterReorder, event); |
| 928 | + } |
| 929 | + emit reorderCompleted(notebookId, folderRelPath, true, QString()); |
| 930 | + } else { |
| 931 | + emit reorderCompleted(notebookId, folderRelPath, false, reorderErrorToString(rc)); |
922 | 932 | } |
923 | | - emit reorderCompleted(notebookId, folderRelPath, true, QString()); |
924 | | - } else { |
925 | | - emit reorderCompleted(notebookId, folderRelPath, false, reorderErrorToString(rc)); |
926 | | - } |
927 | | - }, |
928 | | - Qt::QueuedConnection); |
| 933 | + }, |
| 934 | + Qt::QueuedConnection); |
| 935 | + }); |
929 | 936 | } |
930 | 937 |
|
931 | 938 | std::string NotebookCoreService::buildOrderedJson(const QStringList &p_orderedFolders, |
|
0 commit comments