diff --git a/backend/src/realtime/realtime-note/realtime-note.spec.ts b/backend/src/realtime/realtime-note/realtime-note.spec.ts index 3685f0173..1635d519e 100644 --- a/backend/src/realtime/realtime-note/realtime-note.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-note.spec.ts @@ -108,4 +108,41 @@ describe('realtime note', () => { expect(sendMessage1Spy).toHaveBeenNthCalledWith(2, deletedMessage); expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, deletedMessage); }); + + describe('removeClient', () => { + it('destory if the number of connected clients reaches zero and the lifetime is exceeded', () => { + const sut = new RealtimeNote(mockedNote, 'nothing'); + const client1 = new MockConnectionBuilder(sut).withLoggedInUser().build(); + const docDestroy = jest.spyOn(sut, 'destroy'); + + sut.addClient(client1); + sut.removeClient(client1); + jest.advanceTimersByTime(5000); + + sut.addClient(client1); + sut.removeClient(client1); + jest.advanceTimersByTime(10500); + + expect(docDestroy).toHaveBeenCalledTimes(1); + }); + + it("doesn't destory when a client reconnects quickly", () => { + const sut = new RealtimeNote(mockedNote, 'nothing'); + const client1 = new MockConnectionBuilder(sut).withLoggedInUser().build(); + const docDestroy = jest.spyOn(sut, 'destroy'); + + // Assuming the case where the only connected user reloads the browser + sut.addClient(client1); + sut.removeClient(client1); + jest.advanceTimersByTime(5000); + + sut.addClient(client1); + sut.removeClient(client1); + jest.advanceTimersByTime(5000); + + sut.addClient(client1); + + expect(docDestroy).toHaveBeenCalledTimes(0); + }); + }); }); diff --git a/backend/src/realtime/realtime-note/realtime-note.ts b/backend/src/realtime/realtime-note/realtime-note.ts index 9c80b9238..c55a20415 100644 --- a/backend/src/realtime/realtime-note/realtime-note.ts +++ b/backend/src/realtime/realtime-note/realtime-note.ts @@ -5,7 +5,7 @@ */ import { Message, MessageType, RealtimeDoc } from '@hedgedoc/commons'; import { Logger } from '@nestjs/common'; -import { EventEmitter2, EventMap } from 'eventemitter2'; +import { EventEmitter2, EventMap, Listener } from 'eventemitter2'; import { Note } from '../../notes/note.entity'; import { RealtimeConnection } from './realtime-connection'; @@ -19,6 +19,8 @@ export interface RealtimeNoteEventMap extends EventMap { yDocUpdate: (update: number[], origin: unknown) => void; } +const LIFETIME_WITHOUT_CLIENTS = 10 * 1000; // 10 seconds + /** * Represents a note currently being edited by a number of clients. */ @@ -26,7 +28,9 @@ export class RealtimeNote extends EventEmitter2 { protected logger: Logger; private readonly doc: RealtimeDoc; private readonly clients = new Set(); + private readonly clientAddedListener: Listener; private isClosing = false; + private destroyEventTimer: NodeJS.Timeout | null = null; constructor( private readonly note: Note, @@ -40,6 +44,18 @@ export class RealtimeNote extends EventEmitter2 { this.logger.debug( `New realtime session for note ${note.id} created. Length of initial content: ${length} characters`, ); + this.clientAddedListener = this.on( + 'clientAdded', + () => { + if (this.destroyEventTimer) { + clearTimeout(this.destroyEventTimer); + this.destroyEventTimer = null; + } + }, + { + objectify: true, + }, + ) as Listener; } /** @@ -68,8 +84,13 @@ export class RealtimeNote extends EventEmitter2 { } clients left.`, ); this.emit('clientRemoved', client); - if (!this.hasConnections() && !this.isClosing) { - this.destroy(); + + if (this.canBeDestroyed()) { + this.destroyEventTimer = setTimeout(() => { + if (this.canBeDestroyed()) { + this.destroy(); + } + }, LIFETIME_WITHOUT_CLIENTS); } } @@ -87,6 +108,7 @@ export class RealtimeNote extends EventEmitter2 { this.isClosing = true; this.doc.destroy(); this.clients.forEach((value) => value.getTransporter().disconnect()); + this.clientAddedListener.off(); this.emit('destroy'); } @@ -150,4 +172,11 @@ export class RealtimeNote extends EventEmitter2 { connection.getTransporter().sendMessage(content); }); } + + /** + * Indicates if a realtime note is ready to get destroyed. + */ + private canBeDestroyed(): boolean { + return !this.hasConnections() && !this.isClosing; + } }