Files
penracourses/multiuser_sync_docs.md
T
2026-06-28 21:51:39 +01:00

5.9 KiB

RAD Project — Multiuser Sync Architecture Documentation

This document explains the design, WebSocket protocol, and lifecycle synchronization of the Django-based real-time multiuser workspace in the RAD project.


1. Architectural Overview

The multiuser synchronization system enables a Session Host (the creator or staff member) to lead a review of DICOM cases, where all connected Participants view the same cases and replicate the host's viewport changes (scrolling, zooming, panning, window/level adjustments) and annotations in real time.

graph TD
    HostBrowser["Host (Browser)"] <--> |client_type=viewer / session| RedisChannel["Channels / Redis Layer"]
    ParticipantBrowser["Participant (Browser)"] <--> |client_type=viewer / session| RedisChannel
    HostBrowser --> |POST /load-case/| DjangoView["Django View (multiuser_load_case)"]
    DjangoView --> |group_send change_case| RedisChannel

2. Dual-WebSocket Connection Design

To cleanly separate structural page transitions from intensive viewer state replication, each connected client establishes up to two separate WebSocket connections to the same room:

  1. Session WebSocket (client_type=session):

    • Source: Initialized in multiuser_session_detail.html.
    • Scope: Structural room orchestration (case loading, room-level user counts).
    • Lifecycle: Stays connected throughout the session page duration. It is unaffected by HTMX swaps of the viewer container.
  2. Viewer WebSocket (client_type=viewer):

    • Source: Initialized inside the React DICOM Viewer (App.tsx).
    • Scope: High-frequency viewport state updates (camera coords, windowing range, slice indexing) and annotation changes.
    • Lifecycle: Connects when the React viewer mounts, and is cleanly unmounted when the case changes or viewer container is swapped.

3. Core Synchronization Flows

A. Case Loading

When the Host changes or loads a case:

  1. The host submits a case selection in the toolbar, triggering an HTMX POST request to multiuser_load_case view in Django.
  2. The view updates session.active_case in the database and broadcasts a "change_case" event to the room's group via Django Channels.
  3. The view returns the rendered _multiuser_case_content.html partial, which swaps in the new interactive viewer element.
  4. Participants' Session WebSockets receive the "change_case" message. Their Javascript executes an HTMX AJAX call htmx.ajax('GET', ...) to fetch the new case's partial content, updating their own container.
  5. Note: The host ignores the "change_case" broadcast ({% if not is_manager %}) to prevent double-swaps and race conditions.

B. Viewport & Camera Sync (State Replication)

The system synchronizes the DICOM viewer viewports by transmitting the full viewport state instead of fine-grained delta operations.

  1. Triggering: The active controller's viewer listens to Cornerstone3D viewport events (CAMERA_MODIFIED, VOI_MODIFIED, IMAGE_RENDERED, VOLUME_NEW_IMAGE).
  2. Throttling: To prevent overloading the network, state broadcasts are throttled to a 120ms cycle.
  3. Transmission: The controller exports a minimized state JSON (including layout grid dimensions, active stacks, camera position, slice indexes, orientation, and VOI properties) and sends a "viewer_state" WebSocket message.
  4. Restoration: Non-controller participants receive the state and invoke importViewerState.
  5. Optimization (Short-circuiting): If the stack's image IDs match the current state, importViewerState skips image reloading entirely and immediately applies the camera, slice indexes, and properties (e.g. contrast leveling) in under 1ms. If the stack is different, it asynchronously loads the new stack/volume first.

C. Annotation Sync

  1. Triggering: The controller's viewer listens to CornerstoneTools annotation events (ANNOTATION_ADDED, ANNOTATION_MODIFIED, ANNOTATION_REMOVED, ANNOTATION_COMPLETED).
  2. Throttling: Annotation events are throttled to a 300ms transmission cycle.
  3. Transmission: The controller exports allowed tools (Length, Arrow, ROIs, Probe, etc.) and sends an "annotations" message.
  4. Restoration: Participants receive the JSON payload, clear all existing annotations, and call addAnnotation to restore the matching drawings. An internal queue (__pendingImportAnnotations) buffers incoming annotations if the annotation state is not yet ready.

4. Control Delegation

  • Control determines who is allowed to broadcast viewer states and annotations.
  • Auto-assignment: The first user to join is auto-assigned control.
  • Requesting Control: Any participant can click "Request Control" which sends a "request_control" signal to the room.
  • Granting/Taking Control: Managers or staff can immediately execute "take_control" or grant control to another participant via the room status menu.
  • Non-controllers reject any local viewport/annotation modifications and keep their viewer read-only, matching the controller's view.

5. Lifecycle & Memory Leak Prevention

When HTMX swaps elements in the DOM, React roots are not automatically garbage collected because their unmount handlers are never called. To prevent multiple old WebSocket connections from remaining active in memory:

  • The registry of active React viewer roots in main.tsx uses a standard Map registry.
  • At the start of mountDicomViewers, a cleanup routine scans the registry and verifies if each container is still attached to the document (document.body.contains(container)).
  • If a container is detached (meaning HTMX deleted it), the script explicitly calls unmount() on the old React root and deletes it from the registry. This cleanly closes the associated WebSocket connection.