Skip to content

Commit 3d578e9

Browse files
RangerMauveMauve Signweavergmaclennan
authored
fix: don't delete project info on initial sync fail (#1055)
* fix: don't delete project info on initial sync fail * fix: invitee should set deviceInfo * chore: remove dead code * fix: Add timeout to inviter initial sync * chore: clean up try catch for setting up project on invitee * chore: run formatter * chore: Change name / approach for initial sync timeout * fix force close of peer connections The previous behaviour when stopping LocalDiscovery server with force: true (which is used in tests to disconnect peers) was to disconnect after a timeout of 1ms. This was causing a race condition in this test https://github.com/digidem/comapeo-core/blob/f7e59063deb32fef129eb2a6a06ec9ac8b28e4c3/test-e2e/manager-invite.js#L668 because the timers were being run before abort signals had been created. On my macbook this test was failing randomly about 75% of the time. This change results in calling localDiscovery.stop({ force: true }) immediately disconnecting sockets in the same tick. * fix: catch disconnect errors for tests --------- Co-authored-by: Mauve Signweaver <[email protected]> Co-authored-by: Gregor MacLennan <[email protected]>
1 parent 86afced commit 3d578e9

File tree

7 files changed

+69
-97
lines changed

7 files changed

+69
-97
lines changed

src/discovery/local-discovery.js

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -276,15 +276,25 @@ export class LocalDiscovery extends TypedEmitter {
276276
const { port } = getAddress(this.#server)
277277
this.#server.close()
278278
const closePromise = once(this.#server, 'close')
279-
await pTimeout(closePromise, {
280-
milliseconds: force ? (timeout === 0 ? 1 : timeout) : Infinity,
281-
fallback: () => {
282-
for (const socket of this.#noiseConnections.values()) {
283-
socket.destroy()
284-
}
285-
return pTimeout(closePromise, { milliseconds: 500 })
286-
},
287-
})
279+
280+
const forceClose = () => {
281+
for (const socket of this.#noiseConnections.values()) {
282+
socket.destroy()
283+
}
284+
return pTimeout(closePromise, { milliseconds: 500 })
285+
}
286+
287+
if (!force) {
288+
await closePromise
289+
} else if (timeout === 0) {
290+
// If timeout is 0, we force-close immediately
291+
await forceClose()
292+
} else {
293+
await pTimeout(closePromise, {
294+
milliseconds: timeout,
295+
fallback: forceClose,
296+
})
297+
}
288298
this.#log(`stopped for ${port}`)
289299
}
290300
}

src/errors.js

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,6 @@ export class ProjectDetailsSendFailError extends Error {
3737
}
3838
}
3939

40-
export class InviteInitialSyncFailError extends Error {
41-
/** @param {string} [message] */
42-
constructor(message = 'Failed to sync config for invite') {
43-
super(message)
44-
this.name = 'InviteInitialSyncFailError'
45-
}
46-
}
47-
4840
export class RPCDisconnectBeforeSendingError extends Error {
4941
/** @param {string} [message] */
5042
constructor(message = 'RPC disconnected before sending') {

src/mapeo-manager.js

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -643,23 +643,9 @@ export class MapeoManager extends TypedEmitter {
643643

644644
// Any errors from here we need to remove project from db because it has not
645645
// been fully added and synced
646+
let project = null
646647
try {
647-
// 4. Write device info into project
648-
const project = await this.getProject(projectPublicId)
649-
650-
try {
651-
const deviceInfo = this.getDeviceInfo()
652-
if (hasSavedDeviceInfo(deviceInfo)) {
653-
await project[kSetOwnDeviceInfo](deviceInfo)
654-
}
655-
} catch (e) {
656-
// Can ignore an error trying to write device info
657-
this.#l.log(
658-
'ERROR: failed to write project %h deviceInfo %o',
659-
projectKey,
660-
e
661-
)
662-
}
648+
project = await this.getProject(projectPublicId)
663649

664650
/** @type {import('drizzle-orm').InferInsertModel<typeof projectSettingsTable>} */
665651
const settingsDoc = {
@@ -677,21 +663,39 @@ export class MapeoManager extends TypedEmitter {
677663
}
678664

679665
await this.#db.insert(projectSettingsTable).values([settingsDoc])
680-
681-
// 5. Wait for initial project sync
682-
if (waitForSync) {
683-
await this.#waitForInitialSync(project)
684-
}
685-
686666
this.#activeProjects.set(projectPublicId, project)
687667
} catch (e) {
668+
// Only happens if getProject or the the DB insert fails
688669
this.#l.log('ERROR: could not add project', e)
689670
this.#db
690671
.delete(projectKeysTable)
691672
.where(eq(projectKeysTable.projectId, projectId))
692673
.run()
693674
throw e
694675
}
676+
677+
try {
678+
const deviceInfo = this.getDeviceInfo()
679+
if (hasSavedDeviceInfo(deviceInfo)) {
680+
await project[kSetOwnDeviceInfo](deviceInfo)
681+
}
682+
} catch (e) {
683+
// Can ignore an error trying to write device info
684+
this.#l.log(
685+
'ERROR: failed to write project %h deviceInfo %o',
686+
projectKey,
687+
e
688+
)
689+
}
690+
691+
// 5. Wait for initial project sync
692+
if (waitForSync) {
693+
try {
694+
await this.#waitForInitialSync(project)
695+
} catch (e) {
696+
this.#l.log('ERROR: could not do initial project sync', e)
697+
}
698+
}
695699
this.#l.log('Added project %h, public ID: %S', projectKey, projectPublicId)
696700
return projectPublicId
697701
}

src/mapeo-project.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ export class MapeoProject extends TypedEmitter {
376376
deviceInfo: this.#dataTypes.deviceInfo,
377377
project: this.#dataTypes.projectSettings,
378378
},
379+
logger: this.#l,
379380
})
380381

381382
this.#blobStore = new BlobStore({

src/member-api.js

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,16 @@ import {
1212
projectKeyToProjectInviteId,
1313
projectKeyToPublicId,
1414
} from './utils.js'
15+
import { Logger } from './logger.js'
1516
import { keyBy } from './lib/key-by.js'
1617
import { abortSignalAny } from './lib/ponyfills.js'
1718
import timingSafeEqual from 'string-timing-safe-equal'
1819
import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js'
1920
import { ErrorWithCode, getErrorMessage } from './lib/error.js'
20-
import {
21-
InviteAbortedError,
22-
InviteInitialSyncFailError,
23-
ProjectDetailsSendFailError,
24-
} from './errors.js'
21+
import { InviteAbortedError, ProjectDetailsSendFailError } from './errors.js'
2522
import { wsCoreReplicator } from './lib/ws-core-replicator.js'
2623
import {
2724
BLOCKED_ROLE_ID,
28-
FAILED_ROLE_ID,
2925
MEMBER_ROLE_ID,
3026
ROLES,
3127
isRoleIdForNewInvite,
@@ -71,6 +67,7 @@ export class MemberApi extends TypedEmitter {
7167
#getReplicationStream
7268
#waitForInitialSyncWithPeer
7369
#dataTypes
70+
#l
7471

7572
/** @type {Map<string, { abortController: AbortController }>} */
7673
#outboundInvitesByDevice = new Map()
@@ -90,6 +87,7 @@ export class MemberApi extends TypedEmitter {
9087
* @param {Object} opts.dataTypes
9188
* @param {Pick<DeviceInfoDataType, 'getByDocId' | 'getMany'>} opts.dataTypes.deviceInfo
9289
* @param {Pick<ProjectDataType, 'getByDocId'>} opts.dataTypes.project
90+
* @param {Logger} [opts.logger]
9391
*/
9492
constructor({
9593
deviceId,
@@ -103,8 +101,10 @@ export class MemberApi extends TypedEmitter {
103101
getReplicationStream,
104102
waitForInitialSyncWithPeer,
105103
dataTypes,
104+
logger,
106105
}) {
107106
super()
107+
this.#l = Logger.create('member-api', logger)
108108
this.#ownDeviceId = deviceId
109109
this.#roles = roles
110110
this.#coreOwnership = coreOwnership
@@ -128,6 +128,7 @@ export class MemberApi extends TypedEmitter {
128128
* @param {string} [opts.roleName]
129129
* @param {string} [opts.roleDescription]
130130
* @param {Buffer} [opts.__testOnlyInviteId] Hard-code the invite ID. Only for tests.
131+
* @param {number} [opts.initialSyncTimeoutMs=5000]
131132
* @returns {Promise<(
132133
* typeof InviteResponse_Decision.ACCEPT |
133134
* typeof InviteResponse_Decision.REJECT |
@@ -141,6 +142,7 @@ export class MemberApi extends TypedEmitter {
141142
roleName = ROLES[roleId]?.name,
142143
roleDescription,
143144
__testOnlyInviteId,
145+
initialSyncTimeoutMs = 5000,
144146
}
145147
) {
146148
assert(isRoleIdForNewInvite(roleId), 'Invalid role ID for new invite')
@@ -205,38 +207,37 @@ export class MemberApi extends TypedEmitter {
205207
case InviteResponse_Decision.DECISION_UNSPECIFIED:
206208
return InviteResponse_Decision.REJECT
207209
case InviteResponse_Decision.ACCEPT:
208-
// Technically we can assign after sending project details
209-
// This lets us test role removal
210-
await this.#roles.assignRole(deviceId, roleId)
211-
212210
try {
213211
await this.#rpc.sendProjectJoinDetails(deviceId, {
214212
inviteId,
215213
projectKey: this.#projectKey,
216214
encryptionKeys: this.#encryptionKeys,
217215
})
218216
} catch {
219-
try {
220-
// Mark them as "failed" so we can retry the flow
221-
await this.#roles.assignRole(deviceId, FAILED_ROLE_ID)
222-
} catch (e) {
223-
console.error(e)
224-
}
225217
throw new ProjectDetailsSendFailError()
226218
}
219+
await this.#roles.assignRole(deviceId, roleId)
227220

228221
try {
229-
await this.#waitForInitialSyncWithPeer(deviceId, abortSignal)
230-
} catch {
231-
// Mark them as "failed" so we can retry the flow
232-
await this.#roles.assignRole(deviceId, FAILED_ROLE_ID)
233-
throw new InviteInitialSyncFailError()
222+
let abortSync = new AbortController().signal
223+
if (initialSyncTimeoutMs) {
224+
abortSync = AbortSignal.timeout(initialSyncTimeoutMs)
225+
}
226+
227+
await this.#waitForInitialSyncWithPeer(deviceId, abortSync)
228+
} catch (e) {
229+
this.#l.log('ERROR: Could not initial sync with peer', e)
234230
}
235231

236232
return inviteResponse.decision
237233
default:
238234
throw new ExhaustivenessError(inviteResponse.decision)
239235
}
236+
} catch (err) {
237+
if (err instanceof Error && err.name === 'RPCDisconnectBeforeAckError') {
238+
throw new InviteAbortedError()
239+
}
240+
throw err
240241
} finally {
241242
this.#outboundInvitesByDevice.delete(deviceId)
242243
}

src/roles.js

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ export const COORDINATOR_ROLE_ID = 'f7c150f5a3a9a855'
1212
export const MEMBER_ROLE_ID = '012fd2d431c0bf60'
1313
export const BLOCKED_ROLE_ID = '9e6d29263cba36c9'
1414
export const LEFT_ROLE_ID = '8ced989b1904606b'
15-
export const FAILED_ROLE_ID = 'a24eaca65ab5d5d0'
1615
export const NO_ROLE_ID = '08e4251e36f6e7ed'
1716

1817
/**
@@ -28,7 +27,6 @@ const ROLE_IDS = new Set(
2827
MEMBER_ROLE_ID,
2928
BLOCKED_ROLE_ID,
3029
LEFT_ROLE_ID,
31-
FAILED_ROLE_ID,
3230
NO_ROLE_ID,
3331
])
3432
)
@@ -53,7 +51,6 @@ const ROLE_IDS_ASSIGNABLE_TO_ANYONE = new Set(
5351
MEMBER_ROLE_ID,
5452
BLOCKED_ROLE_ID,
5553
LEFT_ROLE_ID,
56-
FAILED_ROLE_ID,
5754
])
5855
)
5956
const isRoleIdAssignableToAnyone = setHas(ROLE_IDS_ASSIGNABLE_TO_ANYONE)
@@ -129,34 +126,6 @@ const BLOCKED_ROLE = {
129126
},
130127
}
131128

132-
/**
133-
* This role is used for devices that failed to sync after accepting an invite and being added
134-
* @type {Role<typeof FAILED_ROLE_ID>}
135-
*/
136-
const FAILED_ROLE = {
137-
roleId: FAILED_ROLE_ID,
138-
name: 'Failed',
139-
docs: mapObject(currentSchemaVersions, (key) => {
140-
return [
141-
key,
142-
{
143-
readOwn: false,
144-
writeOwn: false,
145-
readOthers: false,
146-
writeOthers: false,
147-
},
148-
]
149-
}),
150-
roleAssignment: [],
151-
sync: {
152-
auth: 'blocked',
153-
config: 'blocked',
154-
data: 'blocked',
155-
blobIndex: 'blocked',
156-
blob: 'blocked',
157-
},
158-
}
159-
160129
/**
161130
* This is the role assumed for a device when no role record can be found. This
162131
* can happen when an invited device did not manage to sync with the device that
@@ -250,7 +219,6 @@ export const ROLES = {
250219
},
251220
},
252221
[NO_ROLE_ID]: NO_ROLE,
253-
[FAILED_ROLE_ID]: FAILED_ROLE,
254222
}
255223

256224
/**
@@ -412,11 +380,6 @@ export class Roles extends TypedEmitter {
412380
if (deviceId !== this.#ownDeviceId) {
413381
throw new Error('Cannot assign LEFT role to another device')
414382
}
415-
} else if (roleId === FAILED_ROLE_ID) {
416-
const ownRole = await this.getRole(this.#ownDeviceId)
417-
if (!ownRole.roleAssignment.includes(COORDINATOR_ROLE_ID)) {
418-
throw new Error('Lacks permission to assign role ' + roleId)
419-
}
420383
} else {
421384
const ownRole = await this.getRole(this.#ownDeviceId)
422385
if (!ownRole.roleAssignment.includes(roleId)) {

test-e2e/sync.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,7 @@ test('no sync capabilities === no namespaces sync apart from auth', async (t) =>
901901
const disconnect1 = connectPeers(managers)
902902

903903
const projectId = await invitor.createProject({ name: 'Mapeo' })
904+
904905
await invite({
905906
invitor,
906907
invitees: [blocked],

0 commit comments

Comments
 (0)