From f7c3c95918b09e4aa75d71744132bae609ea52ee Mon Sep 17 00:00:00 2001 From: Brendan Tan Date: Fri, 8 Nov 2024 22:27:51 +0800 Subject: [PATCH 1/3] Add language synchronization in IDE --- .../app/session/code-editor/code-editor.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/app/session/code-editor/code-editor.tsx b/frontend/app/session/code-editor/code-editor.tsx index 00bd963e60..3d6ecea327 100644 --- a/frontend/app/session/code-editor/code-editor.tsx +++ b/frontend/app/session/code-editor/code-editor.tsx @@ -136,6 +136,21 @@ export default function CodeEditor({ sessionId, provider, setLanguage }: CodeEdi const [langOpen, setLangOpen] = useState(false) const [lang, setLang] = useState("javascript") + + const ymap = bindingRef.current?.doc.getMap('editorSettings'); + ymap?.observe((event) => { + if (event.keysChanged.has('lang')) { + const newLang = ymap.get('lang'); + monaco?.editor.setModelLanguage(editorRef.current!.getModel()!, newLang as string); + setLang(newLang as string); + } + }); + + function setLangPropagate(newLang: string) { + setLang(newLang); + ymap?.set('lang', newLang); + } + function langCombobox() { return @@ -164,7 +179,7 @@ export default function CodeEditor({ sessionId, provider, setLanguage }: CodeEdi value={l.value} onSelect={(currentValue) => { monaco?.editor.setModelLanguage(editorRef.current!.getModel()!, currentValue); - setLang(currentValue); + setLangPropagate(currentValue); setLanguage(currentValue); setLangOpen(false); }} From 221d34070c4c8bc0ca564849df6e46687fdc477c Mon Sep 17 00:00:00 2001 From: Brendan Tan Date: Fri, 8 Nov 2024 22:28:42 +0800 Subject: [PATCH 2/3] Rename signaling-service to collab-service --- backend/{signaling-service => collab-service}/Dockerfile.dev | 0 backend/{signaling-service => collab-service}/index.js | 0 .../{signaling-service => collab-service}/package-lock.json | 0 backend/{signaling-service => collab-service}/package.json | 0 docker-compose.yml | 4 ++-- 5 files changed, 2 insertions(+), 2 deletions(-) rename backend/{signaling-service => collab-service}/Dockerfile.dev (100%) rename backend/{signaling-service => collab-service}/index.js (100%) rename backend/{signaling-service => collab-service}/package-lock.json (100%) rename backend/{signaling-service => collab-service}/package.json (100%) diff --git a/backend/signaling-service/Dockerfile.dev b/backend/collab-service/Dockerfile.dev similarity index 100% rename from backend/signaling-service/Dockerfile.dev rename to backend/collab-service/Dockerfile.dev diff --git a/backend/signaling-service/index.js b/backend/collab-service/index.js similarity index 100% rename from backend/signaling-service/index.js rename to backend/collab-service/index.js diff --git a/backend/signaling-service/package-lock.json b/backend/collab-service/package-lock.json similarity index 100% rename from backend/signaling-service/package-lock.json rename to backend/collab-service/package-lock.json diff --git a/backend/signaling-service/package.json b/backend/collab-service/package.json similarity index 100% rename from backend/signaling-service/package.json rename to backend/collab-service/package.json diff --git a/docker-compose.yml b/docker-compose.yml index 92cdb1a85b..400139ae1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,9 +86,9 @@ services: kafka: condition: service_healthy - signaling-service: + collab-service: build: - context: ./backend/signaling-service + context: ./backend/collab-service dockerfile: Dockerfile.dev ports: - "${COLLAB_API_PORT}:3003" From 1ae5e0c3f394c31962b02b158fbd906e07c0ed5b Mon Sep 17 00:00:00 2001 From: Brendan Tan Date: Sat, 9 Nov 2024 00:10:02 +0800 Subject: [PATCH 3/3] Add reconnection and improve session end handling --- backend/collab-service/index.js | 75 +++++++++++++++++-- .../app/(authenticated)/questions/page.tsx | 3 +- frontend/app/session/[id]/page.tsx | 32 ++++---- 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/backend/collab-service/index.js b/backend/collab-service/index.js index 0685e37751..d458088725 100644 --- a/backend/collab-service/index.js +++ b/backend/collab-service/index.js @@ -1,6 +1,12 @@ import { Hocuspocus } from "@hocuspocus/server"; -const rooms = new Map(); +const disconnectTimers = new Map(); +const disconnectedClients = new Map(); + +const destroyedSessions = new Set(); +setInterval(() => { + destroyedSessions.clear(); +}, 24 * 60 * 60 * 1000); const server = new Hocuspocus({ port: 3003, @@ -12,6 +18,10 @@ const server = new Hocuspocus({ throw new Error("Not authorized!"); } + if (destroyedSessions.has(data.documentName)) { + throw new Error("Session has ended"); + } + return }, @@ -20,18 +30,71 @@ const server = new Hocuspocus({ }, onConnect: data => { console.log('Client connected: ', data.documentName); + + if (destroyedSessions.has(data.documentName)) { + data.document.broadcastStateless("sessionEnded"); + data.document.destroy(); + return; + } + + const currentCount = disconnectedClients.get(data.documentName) || 0; + if (currentCount > 0) { + disconnectedClients.set(data.documentName, currentCount - 1); + } + + if (disconnectTimers.has(data.documentName) && + disconnectedClients.get(data.documentName) === 0) { + clearTimeout(disconnectTimers.get(data.documentName)); + disconnectTimers.delete(data.documentName); + console.log('Reconnected within time window'); + } }, onDisconnect(data) { - console.log(data) + // network disconnects or last person leaves + console.log('Client disconnected from:', data.documentName); - if (data.clientsCount == 1) { - console.log('User disconnected'); + if (destroyedSessions.has(data.documentName)) { + return; + } - data.document.broadcastStateless("sessionEnded"); - data.document.destroy(); + const currentCount = disconnectedClients.get(data.documentName) || 0; + disconnectedClients.set(data.documentName, currentCount + 1); + + if (!disconnectTimers.has(data.documentName)) { + const timeoutId = setTimeout(() => { + // only destroy if clients are still disconnected after 1 min + if (disconnectedClients.get(data.documentName) > 0) { + console.log('Reconnection window expired'); + data.document.broadcastStateless("sessionEndedNetwork"); + data.document.destroy(); + disconnectTimers.delete(data.documentName); + disconnectedClients.delete(data.documentName); + destroyedSessions.add(data.documentName); + } + }, 60000); + + disconnectTimers.set(data.documentName, timeoutId); + console.log('Started reconnection window'); } }, + + onStateless: ({ payload, document, connection }) => { + // explicit session end + if (payload === "endSession") { + console.log('Session explicitly ended'); + if (disconnectTimers.has(document.name)) { + clearTimeout(disconnectTimers.get(document.name)); + disconnectTimers.delete(document.name); + } + disconnectedClients.delete(document.name); + destroyedSessions.add(document.name); + document.broadcastStateless("sessionEnded"); + setTimeout(() => { + document.destroy(); + }, 5000); + } + } }); server.listen(); \ No newline at end of file diff --git a/frontend/app/(authenticated)/questions/page.tsx b/frontend/app/(authenticated)/questions/page.tsx index 9f50a818ec..965265ce00 100644 --- a/frontend/app/(authenticated)/questions/page.tsx +++ b/frontend/app/(authenticated)/questions/page.tsx @@ -353,7 +353,8 @@ export default function Questions() { useEffect(() => { if (isMatchFoundDialogOpen) { - setRedirectTime(3); + router.prefetch('/session/[id]'); + setRedirectTime(2); const redirectTimer = setInterval(() => { setRedirectTime((prevTime) => { if (prevTime <= 1) { diff --git a/frontend/app/session/[id]/page.tsx b/frontend/app/session/[id]/page.tsx index 1c3be975a8..09be7a84cf 100644 --- a/frontend/app/session/[id]/page.tsx +++ b/frontend/app/session/[id]/page.tsx @@ -45,6 +45,8 @@ export default function Session() { const [controller, setController] = useState(null); const [timeElapsed, setTimeElapsed] = useState(0); const [isSessionEnded, setIsSessionEnded] = useState(false); + const [isSessionEndedPeer, setIsSessionEndedPeer] = useState(false); + const [isSessionEndedDisconnect, setIsSessionEndedDisconnect] = useState(false); const [isEndDialogOpen, setIsEndDialogOpen] = useState(false); const [language, setLanguage] = useState("javascript"); @@ -123,7 +125,7 @@ export default function Session() { }, [isHistoryApiCalled, language, questionId, timeElapsed]); useEffect(() => { - if (isSessionEnded && !isHistoryApiCalled) { + if ((isSessionEnded || isSessionEndedPeer || isSessionEndedDisconnect) && !isHistoryApiCalled) { const cleanup = async () => { await callUserHistoryAPI(); setTimeout(() => { @@ -132,7 +134,7 @@ export default function Session() { }; cleanup(); } - }, [isSessionEnded, isHistoryApiCalled, callUserHistoryAPI, router]); + }, [isSessionEnded, isHistoryApiCalled, callUserHistoryAPI, router, isSessionEndedPeer, isSessionEndedDisconnect]); useEffect(() => { setIsClient(true); @@ -179,8 +181,11 @@ export default function Session() { onStateless: ({ payload }) => { console.log('Received message:', payload); if (payload === 'sessionEnded') { - console.log("Session ended"); - setIsSessionEnded(true); + console.log("Session explicitly ended"); + setIsSessionEndedPeer(true); + } else if (payload === 'sessionEndedNetwork') { + console.log("Session ended due to network disconnect by peer"); + setIsSessionEndedDisconnect(true); } }, }); @@ -199,13 +204,8 @@ export default function Session() { notesProviderRef.current = notesProvider; if (isSessionEnded) { - socket.disconnect(); + codeProvider.sendStateless("endSession"); } - - return () => { - codeProvider.destroy(); - socket.disconnect(); - }; }, [isSessionEnded, params.id, questionId, router]); @@ -292,7 +292,7 @@ export default function Session() { setIsSessionEnded(true) setIsEndDialogOpen(false) }} - disabled={isSessionEnded} + disabled={isSessionEnded || isSessionEndedPeer || isSessionEndedDisconnect} > End session @@ -361,7 +361,7 @@ export default function Session() { - + @@ -372,7 +372,13 @@ export default function Session() {
-

Your session has ended.

+ {isSessionEnded ? ( +

Your session has ended.

+ ) : isSessionEndedPeer ? ( +

{peerUsername} has ended the session.

+ ) : isSessionEndedDisconnect ? ( +

{peerUsername} disconnected.

+ ) : null}

Redirecting you to the Questions page...