Skip to content

Commit 0b20e0b

Browse files
authored
Merge pull request #4486 from nextcloud/backport/4474/stable27
[stable27] Fix sync errors after network issues
2 parents 631f924 + fa7197a commit 0b20e0b

22 files changed

Lines changed: 287 additions & 54 deletions
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/*
2+
* @copyright Copyright (c) 2023 Max <max@nextcloud.com>
3+
*
4+
* @author Max <max@nextcloud.com>
5+
*
6+
* @license AGPL-3.0-or-later
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU Affero General Public License as
10+
* published by the Free Software Foundation, either version 3 of the
11+
* License, or (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU Affero General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License
19+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
20+
*
21+
*/
22+
23+
import { randUser } from '../../utils/index.js'
24+
import { SyncService } from '../../../src/services/SyncService.js'
25+
import createSyncServiceProvider from '../../../src/services/SyncServiceProvider.js'
26+
import { Doc } from 'yjs'
27+
28+
const user = randUser()
29+
30+
describe('Sync service provider', function() {
31+
let fileId
32+
33+
before(function() {
34+
cy.createUser(user)
35+
window.OC = {
36+
config: { modRewriteWorking: false },
37+
webroot: '',
38+
}
39+
})
40+
41+
beforeEach(function() {
42+
cy.login(user)
43+
cy.prepareSessionApi()
44+
cy.uploadTestFile('test.md')
45+
.then(id => {
46+
fileId = id
47+
})
48+
cy.wrap(new Doc()).as('source')
49+
cy.wrap(new Doc()).as('target')
50+
cy.get('@source').then(source => createProvider(source)).as('sourceProvider')
51+
cy.get('@target').then(target => createProvider(target)).as('targetProvider')
52+
})
53+
54+
afterEach(function() {
55+
this.sourceProvider?.destroy()
56+
this.targetProvider?.destroy()
57+
})
58+
59+
/**
60+
* @param {object} ydoc Yjs document
61+
*/
62+
function createProvider(ydoc) {
63+
const syncService = new SyncService({
64+
serialize: () => 'Serialized',
65+
getDocumentState: () => null,
66+
})
67+
syncService.on('opened', () => syncService.startSync())
68+
return createSyncServiceProvider({
69+
ydoc,
70+
syncService,
71+
fileId,
72+
initialSession: null,
73+
disableBc: true,
74+
})
75+
}
76+
77+
it('recovers from a dropped message', function() {
78+
const sourceMap = this.source.getMap()
79+
const targetMap = this.target.getMap()
80+
cy.intercept({ method: 'POST', url: '**/apps/text/session/push' })
81+
.as('push')
82+
cy.intercept({ method: 'POST', url: '**/apps/text/session/sync' })
83+
.as('sync')
84+
cy.wait('@push')
85+
cy.then(() => {
86+
sourceMap.set('keyA', 'valueA')
87+
expect(targetMap.get('keyB')).to.be.eq(undefined)
88+
})
89+
cy.wait('@sync')
90+
cy.wait('@sync')
91+
// eslint-disable-next-line cypress/no-unnecessary-waiting
92+
cy.wait(1000)
93+
cy.then(() => {
94+
expect(targetMap.get('keyA')).to.be.eq('valueA')
95+
})
96+
cy.intercept({
97+
method: 'POST',
98+
url: '**/apps/text/session/push',
99+
}, req => {
100+
if (req.body.steps) {
101+
req.reply({ forceNetworkError: true })
102+
req.alias = 'dead'
103+
} else {
104+
req.continue()
105+
}
106+
})
107+
cy.then(() => {
108+
sourceMap.set('keyB', 'valueB')
109+
expect(targetMap.get('keyB')).to.be.eq(undefined)
110+
})
111+
cy.wait('@dead')
112+
cy.then(() => {
113+
expect(targetMap.get('keyB')).to.be.eq(undefined)
114+
})
115+
cy.intercept({
116+
method: 'POST',
117+
url: '**/apps/text/session/push',
118+
}, req => {
119+
if (req.body.steps) {
120+
req.alias = 'alive'
121+
req.continue()
122+
} else {
123+
req.continue()
124+
}
125+
})
126+
cy.then(() => {
127+
sourceMap.set('keyC', 'valueC')
128+
expect(targetMap.get('keyB')).to.be.eq(undefined)
129+
})
130+
cy.wait('@alive')
131+
// eslint-disable-next-line cypress/no-unnecessary-waiting
132+
cy.wait(1000)
133+
cy.then(() => {
134+
expect(targetMap.get('keyC')).to.be.eq('valueC')
135+
expect(targetMap.get('keyB')).to.be.eq('valueB')
136+
})
137+
})
138+
139+
/*
140+
* Counts the amount of push and sync requests in one minute.
141+
* Skipped per default, useful for comparison before/after changes to SyncProvider or PollingBackend.
142+
*/
143+
it.skip('is not too chatty', function() {
144+
const sourceMap = this.source.getMap()
145+
const targetMap = this.target.getMap()
146+
cy.intercept({ method: 'POST', url: '**/apps/text/session/push' })
147+
.as('push')
148+
cy.intercept({ method: 'POST', url: '**/apps/text/session/sync' })
149+
.as('sync')
150+
cy.wait('@push')
151+
cy.then(() => {
152+
sourceMap.set('keyA', 'valueA')
153+
expect(targetMap.get('keyB')).to.be.eq(undefined)
154+
})
155+
// eslint-disable-next-line cypress/no-unnecessary-waiting
156+
cy.wait(60000)
157+
cy.then(() => {
158+
expect(targetMap.get('keyA')).to.be.eq('valueA')
159+
})
160+
// 2 clients push awareness updates every 15 seconds -> 2*5 = 10. Actual 15.
161+
cy.get('@push.all').its('length').should('be.lessThan', 30)
162+
// 2 clients sync fast first and then every 5 seconds -> 2*12 = 24. Actual 32.
163+
cy.get('@sync.all').its('length').should('be.lessThan', 60)
164+
})
165+
})

β€Žcypress/e2e/api/Yjs.spec.jsβ€Ž

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* @copyright Copyright (c) 2023 Jonas <jonas@nextcloud.com>
3+
*
4+
* @author Jonas <jonas@nextcloud.com>
5+
*
6+
* @license AGPL-3.0-or-later
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU Affero General Public License as
10+
* published by the Free Software Foundation, either version 3 of the
11+
* License, or (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU Affero General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License
19+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
20+
*
21+
*/
22+
23+
import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'
24+
25+
describe('Yjs', function() {
26+
// Only tests that Yjs allows to apply steps in wrong order
27+
it('applies step in wrong order', function() {
28+
const source = new Doc()
29+
const target = new Doc()
30+
const sourceMap = source.getMap()
31+
const targetMap = target.getMap()
32+
33+
target.on('afterTransaction', (tr, doc) => {
34+
// console.log('afterTransaction', tr)
35+
})
36+
37+
// Add keyA to source and apply to target
38+
sourceMap.set('keyA', 'valueA')
39+
const update0A = encodeStateAsUpdate(source)
40+
const sourceVectorA = encodeStateVector(source)
41+
applyUpdate(target, update0A)
42+
expect(targetMap.get('keyA')).to.be.eq('valueA')
43+
44+
// Add keyB to source, don't apply to target yet
45+
sourceMap.set('keyB', 'valueB')
46+
const updateAB = encodeStateAsUpdate(source, sourceVectorA)
47+
const sourceVectorB = encodeStateVector(source)
48+
49+
// Add keyC to source, apply to target
50+
sourceMap.set('keyC', 'valueC')
51+
const updateBC = encodeStateAsUpdate(source, sourceVectorB)
52+
applyUpdate(target, updateBC)
53+
expect(targetMap.get('keyB')).to.be.eq(undefined)
54+
expect(targetMap.get('keyC')).to.be.eq(undefined)
55+
56+
// Apply keyB to target
57+
applyUpdate(target, updateAB)
58+
expect(targetMap.get('keyB')).to.be.eq('valueB')
59+
expect(targetMap.get('keyC')).to.be.eq('valueC')
60+
})
61+
})

β€Žcypress/e2e/sync.spec.jsβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe('Sync', () => {
7777
}
7878
}).as('sessionRequests')
7979
cy.wait('@dead', { timeout: 30000 })
80-
cy.get('#editor-container .document-status', { timeout: 10000 })
80+
cy.get('#editor-container .document-status', { timeout: 30000 })
8181
.should('contain', 'File could not be loaded')
8282
.then(() => {
8383
count = 4

β€Žjs/editor.jsβ€Ž

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žjs/editor.js.mapβ€Ž

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žjs/files-modal.jsβ€Ž

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žjs/files-modal.js.mapβ€Ž

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žjs/text-editors.jsβ€Ž

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žjs/text-editors.js.mapβ€Ž

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žjs/text-files.jsβ€Ž

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
Β (0)