fix a few multisync issues

This commit is contained in:
Ross
2026-06-28 21:51:39 +01:00
parent d32e2a5231
commit d07d88bdbf
5 changed files with 291 additions and 183 deletions
@@ -58,7 +58,7 @@
<span class="input-group-text bg-secondary text-white border-secondary">
<i class="bi bi-search"></i>
</span>
<input type="text" id="multiuserCaseSearch" class="form-control bg-dark text-white border-secondary"
<input type="text" id="multiuserCaseSearch" name="q" class="form-control bg-dark text-white border-secondary"
placeholder="Search cases to load..."
hx-get="{% url 'atlas:multiuser_search_cases' session_id=session.id %}"
hx-trigger="input delay:300ms, focus"
@@ -161,6 +161,7 @@
container.scrollTop = container.scrollHeight;
}
}
window.logDebug = logDebug;
function clearDebugLogs() {
const container = document.getElementById("syncDebugLogsContainer");
@@ -264,12 +265,16 @@
logDebug("Session WS event received (type=" + data.type + ")", data);
if (data.type === 'change_case') {
{% if not is_manager %}
logDebug("Session host changed case to " + data.case_id + ". Fetching new case via HTMX...");
// Fetch new case content via HTMX
htmx.ajax('GET', "{% url 'atlas:multiuser_case_content_partial' session_id=session.id %}", {
target: "#multiuser-case-container",
swap: "innerHTML"
});
{% else %}
logDebug("Host ignored change_case broadcast to avoid double-swap");
{% endif %}
} else if (data.type === 'room_status') {
// Update user count
const userCount = Object.keys(data.users || {}).length;
+17 -2
View File
@@ -129,8 +129,23 @@ def test_multiuser_load_case_action(client, make_case):
# Manager loads case
client.force_login(manager)
response = client.post(reverse("atlas:multiuser_load_case", kwargs={"session_id": session.id}), data={"case_id": case.pk})
assert response.status_code == 200
from unittest.mock import patch, MagicMock, AsyncMock
with patch("channels.layers.get_channel_layer") as mock_get_channel_layer:
mock_layer = MagicMock()
mock_layer.group_send = AsyncMock()
mock_get_channel_layer.return_value = mock_layer
response = client.post(reverse("atlas:multiuser_load_case", kwargs={"session_id": session.id}), data={"case_id": case.pk})
assert response.status_code == 200
# Verify Channels group_send was called to notify the group about the case change
mock_layer.group_send.assert_called_once_with(
f"multiuser_{session.id}",
{
"type": "change_case",
"case_id": case.id
}
)
session.refresh_from_db()
assert session.active_case == case
+81
View File
@@ -0,0 +1,81 @@
# 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.
```mermaid
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](file:///home/ross/penracourses/rad/atlas/templates/atlas/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](file:///home/ross/dv3d/dicom-viewer/src/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](file:///home/ross/dv3d/dicom-viewer/src/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.
+7
View File
@@ -36,3 +36,10 @@ TASKS = {
MEDIA_ROOT = os.path.join(BASE_DIR, "media_test") + "/"
os.makedirs(MEDIA_ROOT, exist_ok=True)
# Remove debug_toolbar from installed apps and middleware to avoid NoReverseMatch errors when settings.DEBUG is False
if "debug_toolbar" in INSTALLED_APPS:
INSTALLED_APPS.remove("debug_toolbar")
if "debug_toolbar.middleware.DebugToolbarMiddleware" in MIDDLEWARE:
MIDDLEWARE.remove("debug_toolbar.middleware.DebugToolbarMiddleware")
+180 -180
View File
File diff suppressed because one or more lines are too long