Skip to content

Commit 8f9d3c2

Browse files
committed
Revert "fix(reorder): run reorder synchronously on UI thread to fix CI race"
This reverts commit b331214.
1 parent b331214 commit 8f9d3c2

5 files changed

Lines changed: 120 additions & 134 deletions

File tree

src/core/services/notebookcoreservice.cpp

Lines changed: 70 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include <QJsonParseError>
55
#include <QMetaObject>
66
#include <QVariantMap>
7+
#include <QtConcurrent>
78

89
#include <nlohmann/json.hpp>
910

@@ -812,36 +813,32 @@ QJsonObject NotebookCoreService::listFolderChildren(const QString &p_notebookId,
812813
return parseJsonObjectFromCStr(json);
813814
}
814815

815-
// T6 (notebook-explorer-drag-reorder): reorder with hooks.
816+
// T6 (notebook-explorer-drag-reorder): async reorder with hooks + IoGate.
816817
//
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.
839836
void NotebookCoreService::reorderFolderChildren(const QString &p_notebookId,
840837
const QString &p_folderRelPath,
841838
const QStringList &p_orderedFolders,
842839
const QStringList &p_orderedFiles) {
843840
if (p_notebookId.isEmpty() || (p_orderedFolders.isEmpty() && p_orderedFiles.isEmpty()) ||
844-
!m_hookMgr) {
841+
!m_hookMgr || !m_ioGate) {
845842
emit reorderCompleted(p_notebookId, p_folderRelPath, false, tr("Invalid arguments"));
846843
return;
847844
}
@@ -884,48 +881,58 @@ void NotebookCoreService::reorderFolderChildren(const QString &p_notebookId,
884881
return;
885882
}
886883

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).
909888
const QString notebookId = p_notebookId;
910889
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, &notebookId, &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));
922932
}
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+
});
929936
}
930937

931938
std::string NotebookCoreService::buildOrderedJson(const QStringList &p_orderedFolders,

src/core/services/notebookcoreservice.h

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,11 @@ class NotebookCoreService : public QObject, public ISyncNotebookService, private
3737
// Called from main() after both services are constructed.
3838
void setHookManager(HookManager *p_hookMgr);
3939

40-
// Set NotebookIoGate (the per-notebook working-tree serializer shared with
41-
// BufferSaveQueue / SyncOps). Retained for dependency wiring; NOT consulted by
42-
// reorderFolderChildren, which — like its sibling folder mutations
43-
// (createFolder/createFile/rename/delete) — now runs synchronously on the UI
44-
// thread (see reorderFolderChildren in the .cpp for the rationale). Called
45-
// from main() with the SAME instance injected into the other actors.
40+
// Set NotebookIoGate for serializing reorder writes against save / sync work
41+
// on the same notebook's working tree. T6: required by reorderFolderChildren.
42+
// Called from main() with the SAME instance already injected into
43+
// BufferSaveQueue / SyncOps — sharing the gate is what makes per-notebook
44+
// serialization actually serialize across actors.
4645
void setNotebookIoGate(NotebookIoGate *p_ioGate);
4746

4847
// Notebook operations (7 methods).
@@ -201,19 +200,17 @@ class NotebookCoreService : public QObject, public ISyncNotebookService, private
201200
QJsonObject listFolderChildren(const QString &p_notebookId, const QString &p_folderPath) const;
202201

203202
// Atomically rewrite the order of a folder's children (subfolders / files)
204-
// in its vx.json. The vxcore write runs SYNCHRONOUSLY on the calling (UI)
205-
// thread — like every sibling folder mutation — because vxcore's FolderManager
206-
// cache is not thread-safe and is read on the UI thread (model fetchMore);
207-
// the read-modify-write must therefore be one atomic UI-thread step. The
208-
// result is delivered ASYNCHRONOUSLY via the reorderCompleted signal
209-
// (Qt::QueuedConnection), so callers may wait() for it after this returns.
203+
// in its vx.json. Returns immediately; the actual disk write happens on a
204+
// worker thread holding NotebookIoGate::ScopedLock(notebookId), and the
205+
// result is delivered via the reorderCompleted signal.
210206
//
211207
// Hook contract:
212208
// - vnote.node.before_reorder fires SYNCHRONOUSLY on the calling thread.
213209
// A hook that calls ctx.cancel() short-circuits with success=false
214210
// and errorMessage="Cancelled by hook"; the vxcore call is NOT made.
215211
// - vnote.node.after_reorder fires on this service's owning thread AFTER
216-
// a successful write, just before reorderCompleted(true).
212+
// the worker has released the IoGate, so handlers can safely re-enter
213+
// the gate without deadlock risk.
217214
//
218215
// No-op contract:
219216
// If both presented sub-arrays match (or are empty), the method emits

tests/core/test_notebook_core_service_reorder.cpp

Lines changed: 18 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ private slots:
4646
void testNoOpSkipsHooksAndVxcore();
4747
void testVxcoreErrorSkipsAfterHookAndReportsFailure();
4848
void testHookCancelSkipsVxcoreAndReportsCancelled();
49-
void testReorderIsIndependentOfIoGate();
49+
void testIoGateSerializesWithExternalHolder();
5050
void testAfterHookFiresAfterLockRelease();
5151

5252
private:
@@ -369,34 +369,16 @@ void TestNotebookCoreServiceReorder::testHookCancelSkipsVxcoreAndReportsCancelle
369369
m_hookMgr->removeAction(afterId);
370370
}
371371

372-
// ===== 5. Reorder is INDEPENDENT of the NotebookIoGate =====
373-
//
374-
// CONTRACT CHANGE (CI race fix): reorderFolderChildren now runs the vxcore
375-
// write SYNCHRONOUSLY on the calling (UI) thread, exactly like its sibling
376-
// folder mutations (createFolder/createFile/rename/delete). It deliberately
377-
// does NOT acquire the per-notebook NotebookIoGate. Rationale: vxcore's
378-
// FolderManager cache is not thread-safe and is read on the UI thread (model
379-
// fetchMore -> listFolderChildren); the only correct serialization is
380-
// UI-thread atomicity. A gate held on a worker thread (the retired design)
381-
// could never serialize against those UI-thread reads — it guarded the wrong
382-
// resource on the wrong thread and produced a deterministic PERMUTATION_MISMATCH
383-
// on 2-core CI runners.
384-
//
385-
// This test pins the new contract: a foreign thread holding the gate does NOT
386-
// block reorder. (The acknowledged, pre-existing flip-side — a structural
387-
// vx.json write can race a concurrent git-stage, exactly as the ungated
388-
// siblings already can — is tracked as a future "serialize all FolderManager
389-
// writes" refactor. Do NOT "fix" this test back to gate-serialization.)
390-
void TestNotebookCoreServiceReorder::testReorderIsIndependentOfIoGate() {
372+
// ===== 5. IoGate serialization =====
373+
void TestNotebookCoreServiceReorder::testIoGateSerializesWithExternalHolder() {
391374
QStringList seedFolders, seedFiles;
392375
const QString nbId = seedNotebook(QStringLiteral("nb_iogate"), &seedFolders, &seedFiles);
393376
QVERIFY(!nbId.isEmpty());
394377

395378
QStringList newFolders = seedFolders;
396379
std::reverse(newFolders.begin(), newFolders.end());
397380

398-
// Acquire the gate from a foreign thread BEFORE invoking reorder and hold it
399-
// for the entire reorder. Reorder must NOT depend on it.
381+
// Acquire the gate from a foreign thread BEFORE invoking reorder.
400382
std::atomic<bool> holderHasLock{false};
401383
std::atomic<bool> releaseRequested{false};
402384
QThread *holder = QThread::create([&]() {
@@ -414,17 +396,24 @@ void TestNotebookCoreServiceReorder::testReorderIsIndependentOfIoGate() {
414396
QSignalSpy spy(m_service, &NotebookCoreService::reorderCompleted);
415397
m_service->reorderFolderChildren(nbId, QString(), newFolders, QStringList());
416398

417-
// Reorder completes (and persists) even while the external holder owns the
418-
// gate — proving the gate is not on reorder's path.
419-
QVERIFY(spy.wait(5000));
420-
QCOMPARE(spy.size(), 1);
421-
QCOMPARE(spy.takeFirst().at(2).toBool(), true);
422-
QCOMPARE(listFolders(nbId), newFolders);
399+
// While the external holder owns the gate, reorder must NOT progress.
400+
bool finishedEarly = spy.wait(400);
401+
QVERIFY2(!finishedEarly, "reorder completed while external IoGate holder still owned the lock");
402+
QCOMPARE(spy.size(), 0);
423403

424-
// Release the foreign holder.
404+
// Release the gate.
425405
releaseRequested.store(true);
426406
holder->wait();
427407
delete holder;
408+
409+
// Now reorder should drain.
410+
if (spy.isEmpty()) {
411+
QVERIFY(spy.wait(5000));
412+
}
413+
QCOMPARE(spy.size(), 1);
414+
QCOMPARE(spy.takeFirst().at(2).toBool(), true);
415+
416+
QCOMPARE(listFolders(nbId), newFolders);
428417
}
429418

430419
// ===== 6. After-hook fires AFTER lock release =====

tests/integration/test_reorder_edge_cases.cpp

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -914,22 +914,13 @@ void TestReorderEdgeCases::EC11_performance100Children() {
914914
}
915915

916916
// =============================================================================
917-
// EC12: Reorder runs concurrently with a save (gate-independent)
917+
// EC12: Concurrent save + reorder serialization
918918
// =============================================================================
919-
// CONTRACT CHANGE (CI race fix): reorderFolderChildren now performs its vxcore
920-
// write SYNCHRONOUSLY on the calling (UI) thread — like every sibling folder
921-
// mutation (createFolder/createFile/rename/delete) — and deliberately does NOT
922-
// acquire the per-notebook NotebookIoGate. The gate could only ever be held on
923-
// a worker thread, which cannot serialize against the UI-thread FolderManager
924-
// reads (model fetchMore) that actually race the write; the retired gated
925-
// design produced a deterministic PERMUTATION_MISMATCH on 2-core CI.
926-
//
927-
// This test pins the new contract: a foreign thread holding the gate (a stand-in
928-
// for a concurrent buffer-save / sync-stage) does NOT block reorder; reorder
929-
// completes and persists regardless. (The acknowledged flip-side — a structural
930-
// vx.json write racing a concurrent git-stage, which the ungated siblings ALSO
931-
// permit — is tracked as a future "serialize all FolderManager writes" refactor.
932-
// Do NOT "fix" this back to gate-serialization.)
919+
// Hold the per-notebook IoGate from a worker thread for ~150ms, then trigger
920+
// reorder. The reorder MUST block until the external holder releases the
921+
// gate — proving the gate's per-notebook serialization contract still
922+
// protects the working tree from interleaved writes. Mirrors the IoGate
923+
// integration test in tests/core/test_notebook_core_service_reorder.cpp.
933924
void TestReorderEdgeCases::EC12_concurrentSaveAndReorder() {
934925
const QString nbId = seedNotebook(QStringLiteral("nb_ec12"),
935926
QStringList{QStringLiteral("sub_a"), QStringLiteral("sub_b")},
@@ -957,16 +948,22 @@ void TestReorderEdgeCases::EC12_concurrentSaveAndReorder() {
957948
QSignalSpy spy(m_service, &vnotex::NotebookCoreService::reorderCompleted);
958949
m_service->reorderFolderChildren(nbId, QString(), newFolders, QStringList());
959950

960-
// Reorder completes and persists even while the external holder owns the
961-
// gate — the gate is not on reorder's path.
962-
QVERIFY(spy.wait(5000));
963-
QCOMPARE(spy.size(), 1);
964-
QCOMPARE(spy.takeFirst().at(2).toBool(), true);
965-
QCOMPARE(listFolders(nbId), newFolders);
951+
// While the external holder owns the gate, the reorder worker must NOT
952+
// complete. 400ms of polling proves the wait.
953+
const bool finishedEarly = spy.wait(400);
954+
QVERIFY2(!finishedEarly, "reorder completed while external IoGate holder still owned the lock");
955+
QCOMPARE(spy.size(), 0);
966956

967957
releaseRequested.store(true);
968958
holder->wait();
969959
delete holder;
960+
961+
if (spy.isEmpty()) {
962+
QVERIFY(spy.wait(5000));
963+
}
964+
QCOMPARE(spy.size(), 1);
965+
QCOMPARE(spy.takeFirst().at(2).toBool(), true);
966+
QCOMPARE(listFolders(nbId), newFolders);
970967
}
971968

972969
// =============================================================================

tests/widgets/test_two_columns_node_explorer_sort.cpp

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -264,12 +264,9 @@ void TestTwoColumnsNodeExplorerSort::
264264
explorer->requestReorderNodes(rootId, {QStringLiteral("beta"), QStringLiteral("alpha")},
265265
/*orderedFileNames=*/QStringList());
266266

267-
// nodesReordered is emitted via QueuedConnection after the synchronous
268-
// reorder write. QTRY_COMPARE is order-robust: it passes whether the signal
269-
// already arrived or arrives within the timeout, unlike QSignalSpy::wait
270-
// which only catches a NEXT emission and spuriously fails if completion
271-
// already landed before the wait was entered.
272-
QTRY_COMPARE_WITH_TIMEOUT(reorderedSpy.count(), 1, 5000);
267+
// nodesReordered is emitted via QueuedConnection from the reorder worker.
268+
QVERIFY(reorderedSpy.wait(5000));
269+
QCOMPARE(reorderedSpy.count(), 1);
273270
QCOMPARE(errorSpy.count(), 0);
274271

275272
// Verify the emit carried the original parent identifier (notebookId
@@ -312,8 +309,7 @@ void TestTwoColumnsNodeExplorerSort::testRequestReorderNodesUsesSharedController
312309
explorer->requestReorderNodes(rootId, {QStringLiteral("beta"), QStringLiteral("alpha")},
313310
/*orderedFileNames=*/QStringList());
314311

315-
// Order-robust wait for the first service-level completion (see T12b note).
316-
QTRY_VERIFY_WITH_TIMEOUT(serviceReorderedSpy.count() >= 1, 5000);
312+
QVERIFY(serviceReorderedSpy.wait(5000));
317313

318314
// Drain any pending events so a hypothetical double-dispatch would have
319315
// had a chance to surface as a second service emit. 100ms is generous

0 commit comments

Comments
 (0)