Skip to content

Commit 093ac09

Browse files
committed
Return value of a lock not it's reference
When a lock is retrieved by using locks.get, a reference to an object is returned. By mutating this reference, a developer, could in theory, mutate the lock held by Spaces. This could lead to subtle and hard to debug issues. This commit changes the .get and .getAll methods to return copies of objects instead. This breaks some of the internal mutations, so they are replaced by the already present .setLock method which will override the lock with a new object, severing any dependecies on references.
1 parent ab6179a commit 093ac09

File tree

2 files changed

+35
-11
lines changed

2 files changed

+35
-11
lines changed

src/Locks.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,22 @@ describe('Locks', () => {
380380
expect(lock3).toBeDefined();
381381
});
382382

383+
it<SpaceTestContext>('returns the lock by value, not reference', ({ space }) => {
384+
const lockGet1 = space.locks.get('lock1');
385+
const lockGet2 = space.locks.get('lock1');
386+
expect(lockGet1).toEqual(expect.objectContaining({ status: 'locked' }));
387+
// Note we are using toBe here on purpose, to check the reference, not value
388+
expect(lockGet1).not.toBe(lockGet2);
389+
});
390+
391+
it<SpaceTestContext>('returns locks by value, not reference', async ({ space }) => {
392+
const lockGetAll1 = await space.locks.getAll();
393+
const lockGetAll2 = await space.locks.getAll();
394+
expect(lockGetAll1).toEqual(expect.arrayContaining([expect.objectContaining({ status: 'locked' })]));
395+
// Note we are using toBe here on purpose, to check the reference, not value
396+
expect(lockGetAll1[0]).not.toBe(lockGetAll2[0]);
397+
});
398+
383399
describe('getSelf', () => {
384400
it<SpaceTestContext>('returns all locks in the LOCKED state that belong to self', async ({ space }) => {
385401
const member1 = await space.members.getByConnectionId('1')!;
@@ -398,6 +414,13 @@ describe('Locks', () => {
398414
}
399415
}
400416
});
417+
418+
it<SpaceTestContext>('returns locks by value not reference', async ({ space }) => {
419+
const locksGetSelf1 = await space.locks.getSelf();
420+
const locksGetSelf2 = await space.locks.getSelf();
421+
// Note we are using toBe here on purpose, to check the reference, not value
422+
expect(locksGetSelf1[0]).not.toBe(locksGetSelf2[0]);
423+
});
401424
});
402425

403426
describe('getOthers', () => {

src/Locks.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ export default class Locks extends EventEmitter<LocksEventMap> {
133133
if (!locks) return;
134134
for (const lock of locks.values()) {
135135
if (lock.status === 'locked') {
136-
return lock;
136+
// Return a copy instead of a reference to prevent mutations
137+
return { ...lock };
137138
}
138139
}
139140
}
@@ -211,7 +212,8 @@ export default class Locks extends EventEmitter<LocksEventMap> {
211212
for (const locks of this.locks.values()) {
212213
for (const lock of locks.values()) {
213214
if (lock.status === 'locked') {
214-
allLocks.push(lock);
215+
// Return a copy instead of a reference to prevent mutations
216+
allLocks.push({ ...lock });
215217
}
216218
}
217219
}
@@ -387,8 +389,7 @@ export default class Locks extends EventEmitter<LocksEventMap> {
387389
const lock = this.getLock(id, self.connectionId);
388390
if (!lock) return;
389391

390-
lock.status = 'unlocked';
391-
lock.reason = undefined;
392+
this.setLock({ ...lock, status: 'unlocked', reason: undefined });
392393
// Send presence update with the updated lock, but delete afterwards so when the
393394
// message is processed an update event is fired
394395
this.updatePresence(self);
@@ -563,9 +564,9 @@ export default class Locks extends EventEmitter<LocksEventMap> {
563564
const lock = locks.get(member.connectionId);
564565

565566
if (lock) {
566-
lock.status = 'unlocked';
567-
lock.reason = undefined;
568-
this.emit('update', lock);
567+
const updatedLock = { ...lock, status: 'unlocked' as const, reason: undefined };
568+
this.setLock(updatedLock);
569+
this.emit('update', updatedLock);
569570
locks.delete(member.connectionId);
570571
}
571572
}
@@ -638,9 +639,9 @@ export default class Locks extends EventEmitter<LocksEventMap> {
638639
(pendingLock.timestamp == lock.timestamp && member.connectionId < lock.member.connectionId)
639640
) {
640641
pendingLock.status = 'locked';
641-
lock.status = 'unlocked';
642-
lock.reason = ERR_LOCK_INVALIDATED();
643-
this.emit('update', lock);
642+
const updatedLock = { ...lock, status: 'unlocked' as const, reason: ERR_LOCK_INVALIDATED() };
643+
this.setLock(updatedLock);
644+
this.emit('update', updatedLock);
644645
return;
645646
}
646647

@@ -684,7 +685,7 @@ export default class Locks extends EventEmitter<LocksEventMap> {
684685
const lock = locks.get(connectionId);
685686

686687
if (lock) {
687-
requests.push(lock);
688+
requests.push({ ...lock });
688689
}
689690
}
690691

0 commit comments

Comments
 (0)