Skip to content

Commit fa106f9

Browse files
committed
test(sync): synchronize IO-gate phase observations
1 parent 71074e3 commit fa106f9

2 files changed

Lines changed: 44 additions & 95 deletions

File tree

‎changes.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Changes
22
## Unreleased
3-
* Fix macOS CI event-loop stalls while preserving native keychain test coverage
3+
* Fix macOS CI event-loop stalls while preserving native keychain coverage, and make sync IO-gate tests independent of worker scheduling
44
* Fix crashes when sync credential storage is destroyed with native keychain operations still in flight, and repair cross-platform CI test linking and teardown
55
* Generate transparent PlantUML UML PNG previews in light themes, preserving explicit diagram backgrounds and node fills
66
* Let light-theme graph canvases blend into the page, including PlantUML, Mermaid, Flowchart, WaveDrom and Graphviz SVGs

‎tests/core/test_syncops_gate_release.cpp‎

Lines changed: 43 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
#include <QAtomicInt>
2-
#include <QElapsedTimer>
31
#include <QSemaphore>
4-
#include <QThread>
52
#include <QtTest>
63

74
#include <thread>
@@ -12,134 +9,86 @@
129

1310
namespace tests {
1411

15-
// Fake ISyncNotebookService — sleeps in each phase so we can observe gate
16-
// hold windows from the outside via contention timing.
12+
// Hold each phase until the test has observed whether another save could acquire
13+
// the notebook gate. Scheduling delays must not change the observation window.
1714
class FakeSyncNotebookService : public vnotex::ISyncNotebookService {
1815
public:
1916
VxCoreError syncStageOnly(const QString &p_notebookId,
2017
VxCoreSyncCancellation *p_cancellationToken,
2118
bool *p_didCommit) override {
2219
Q_UNUSED(p_notebookId);
2320
Q_UNUSED(p_cancellationToken);
24-
m_stageEntered.storeRelaxed(1);
25-
QThread::msleep(50);
21+
m_stageEntered.release();
22+
m_continueStage.acquire();
2623
if (p_didCommit) {
2724
*p_didCommit = true;
2825
}
29-
m_stageExited.storeRelaxed(1);
3026
return VXCORE_OK;
3127
}
3228

3329
VxCoreError syncNetworkPhase(const QString &p_notebookId,
3430
VxCoreSyncCancellation *p_cancellationToken) override {
3531
Q_UNUSED(p_notebookId);
3632
Q_UNUSED(p_cancellationToken);
37-
m_networkEntered.storeRelaxed(1);
38-
QThread::msleep(200);
39-
m_networkExited.storeRelaxed(1);
33+
m_networkEntered.release();
34+
m_continueNetwork.acquire();
4035
return VXCORE_OK;
4136
}
4237

43-
QAtomicInt m_stageEntered{0};
44-
QAtomicInt m_stageExited{0};
45-
QAtomicInt m_networkEntered{0};
46-
QAtomicInt m_networkExited{0};
38+
QSemaphore m_stageEntered;
39+
QSemaphore m_continueStage;
40+
QSemaphore m_networkEntered;
41+
QSemaphore m_continueNetwork;
4742
};
4843

4944
class TestSyncOpsGateRelease : public QObject {
5045
Q_OBJECT
5146

5247
private slots:
5348
void testGateReleasedBeforeNetworkPhase();
54-
void testStagedPatternCompletesSuccessfully();
5549
};
5650

5751
void TestSyncOpsGateRelease::testGateReleasedBeforeNetworkPhase() {
5852
FakeSyncNotebookService fake;
5953
vnotex::NotebookIoGate gate;
6054
const QString notebookId = QStringLiteral("nb-1");
61-
62-
QSemaphore finishedSem(0);
63-
QAtomicInt finishedCode{-1};
64-
auto onFinished = [&](VxCoreError code) {
65-
finishedCode.storeRelaxed(static_cast<int>(code));
66-
finishedSem.release();
67-
};
68-
69-
// 1. Hold the gate on the main thread.
70-
auto mainLock = std::make_unique<vnotex::NotebookIoGate::ScopedLock>(gate, notebookId);
71-
72-
// 2. Dispatch triggerSync on a worker. It will block in stage acquiring the gate.
73-
std::thread worker(
74-
[&]() { vnotex::SyncOps::triggerSync(&fake, notebookId, nullptr, onFinished, &gate); });
75-
76-
// 3. Sleep so the worker is definitely blocked in gate acquire.
77-
QThread::msleep(100);
78-
QVERIFY2(fake.m_stageEntered.loadRelaxed() == 0,
79-
"Worker should still be blocked on gate acquire (stage not entered)");
80-
81-
// 4. Release main's lock so worker proceeds.
82-
QElapsedTimer sinceRelease;
83-
sinceRelease.start();
84-
mainLock.reset();
85-
86-
// 5. Wait long enough for worker to enter (and likely exit) stage, then
87-
// be inside the network phase (network sleeps 200ms).
88-
// Stage = 50ms. After ~120ms post-release we should be solidly in the
89-
// network phase.
90-
QThread::msleep(120);
91-
QVERIFY2(fake.m_networkEntered.loadRelaxed() == 1, "Worker should be in network phase by now");
92-
QVERIFY2(fake.m_networkExited.loadRelaxed() == 0,
93-
"Worker should NOT have finished network phase yet (sleeps 200ms)");
94-
95-
// 6. KEY ASSERTION: try to re-acquire the gate from the main thread.
96-
// If the gate were still held during network phase, this would block
97-
// ~80ms (remaining network time). If released, it's essentially instant.
98-
QElapsedTimer acquireTimer;
99-
acquireTimer.start();
100-
{
101-
vnotex::NotebookIoGate::ScopedLock probe(gate, notebookId);
102-
const qint64 acquireMs = acquireTimer.elapsed();
103-
QVERIFY2(acquireMs < 30,
104-
qPrintable(QStringLiteral("Gate re-acquire took %1ms; expected <30ms "
105-
"(gate should be released during network phase)")
106-
.arg(acquireMs)));
55+
VxCoreError finishedCode = VXCORE_ERR_UNKNOWN;
56+
int finishedCount = 0;
57+
std::thread worker([&]() {
58+
vnotex::SyncOps::triggerSync(
59+
&fake, notebookId, nullptr,
60+
[&](VxCoreError p_code) {
61+
finishedCode = p_code;
62+
++finishedCount;
63+
},
64+
&gate);
65+
});
66+
67+
const bool stageEntered = fake.m_stageEntered.tryAcquire(1, 5000);
68+
bool gateHeldDuringStage = false;
69+
if (stageEntered) {
70+
vnotex::NotebookIoGate::ScopedTryLock probe(gate, notebookId, 0);
71+
gateHeldDuringStage = !probe.isLocked();
10772
}
73+
fake.m_continueStage.release();
10874

109-
// 7. Wait for worker completion.
110-
QVERIFY(finishedSem.tryAcquire(1, 5000));
111-
QCOMPARE(static_cast<VxCoreError>(finishedCode.loadRelaxed()), VXCORE_OK);
112-
worker.join();
113-
}
114-
115-
void TestSyncOpsGateRelease::testStagedPatternCompletesSuccessfully() {
116-
FakeSyncNotebookService fake;
117-
vnotex::NotebookIoGate gate;
118-
const QString notebookId = QStringLiteral("nb-2");
119-
120-
QSemaphore finishedSem(0);
121-
QAtomicInt finishedCode{-1};
122-
auto onFinished = [&](VxCoreError code) {
123-
finishedCode.storeRelaxed(static_cast<int>(code));
124-
finishedSem.release();
125-
};
126-
127-
QElapsedTimer timer;
128-
timer.start();
129-
std::thread worker(
130-
[&]() { vnotex::SyncOps::triggerSync(&fake, notebookId, nullptr, onFinished, &gate); });
75+
const bool networkEntered = fake.m_networkEntered.tryAcquire(1, 5000);
76+
bool gateReleasedDuringNetwork = false;
77+
if (networkEntered) {
78+
vnotex::NotebookIoGate::ScopedTryLock probe(gate, notebookId, 0);
79+
gateReleasedDuringNetwork = probe.isLocked();
80+
}
81+
fake.m_continueNetwork.release();
13182

132-
QVERIFY(finishedSem.tryAcquire(1, 5000));
133-
const qint64 elapsed = timer.elapsed();
83+
// Join before asserting so a failed observation cannot destroy a joinable
84+
// std::thread and abort the test process instead of reporting the failure.
13485
worker.join();
135-
136-
QCOMPARE(static_cast<VxCoreError>(finishedCode.loadRelaxed()), VXCORE_OK);
137-
QCOMPARE(fake.m_stageExited.loadRelaxed(), 1);
138-
QCOMPARE(fake.m_networkExited.loadRelaxed(), 1);
139-
140-
// Stage 50ms + network 200ms = ~250ms; allow generous CI headroom.
141-
QVERIFY2(elapsed >= 240, qPrintable(QStringLiteral("Expected >=240ms, got %1ms").arg(elapsed)));
142-
QVERIFY2(elapsed < 600, qPrintable(QStringLiteral("Expected <600ms, got %1ms").arg(elapsed)));
86+
QVERIFY2(stageEntered, "Worker did not enter the staging phase");
87+
QVERIFY2(gateHeldDuringStage, "Staging must exclude concurrent notebook saves");
88+
QVERIFY2(networkEntered, "Worker did not enter the network phase");
89+
QVERIFY2(gateReleasedDuringNetwork, "Network work must not block notebook saves");
90+
QCOMPARE(finishedCode, VXCORE_OK);
91+
QCOMPARE(finishedCount, 1);
14392
}
14493

14594
} // namespace tests

0 commit comments

Comments
 (0)