diff --git a/frontend/src/components/features/chat/action-suggestions.tsx b/frontend/src/components/features/chat/action-suggestions.tsx
index 28feab537b83..2a7e51479cb7 100644
--- a/frontend/src/components/features/chat/action-suggestions.tsx
+++ b/frontend/src/components/features/chat/action-suggestions.tsx
@@ -2,7 +2,7 @@ import posthog from "posthog-js";
import React from "react";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import { useAuth } from "#/context/auth-context";
-import { downloadWorkspace } from "#/utils/download-workspace";
+import { DownloadModal } from "#/components/shared/download-modal";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
@@ -16,19 +16,17 @@ export function ActionSuggestions({
const [isDownloading, setIsDownloading] = React.useState(false);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
- const handleDownloadWorkspace = async () => {
- setIsDownloading(true);
- try {
- await downloadWorkspace();
- } catch (error) {
- // TODO: Handle error
- } finally {
- setIsDownloading(false);
- }
+ const handleDownloadClose = () => {
+ setIsDownloading(false);
};
return (
+
{gitHubToken ? (
{!hasPullRequest ? (
@@ -75,13 +73,15 @@ export function ActionSuggestions({
{
posthog.capture("download_workspace_button_clicked");
- handleDownloadWorkspace();
+ if (!isDownloading) {
+ setIsDownloading(true);
+ }
}}
/>
)}
diff --git a/frontend/src/components/features/project-menu/ProjectMenuCard.tsx b/frontend/src/components/features/project-menu/ProjectMenuCard.tsx
index bb4074907682..54512bd141e9 100644
--- a/frontend/src/components/features/project-menu/ProjectMenuCard.tsx
+++ b/frontend/src/components/features/project-menu/ProjectMenuCard.tsx
@@ -1,16 +1,14 @@
import React from "react";
-import toast from "react-hot-toast";
import posthog from "posthog-js";
import EllipsisH from "#/icons/ellipsis-h.svg?react";
import { createChatMessage } from "#/services/chat-service";
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
import { ProjectMenuDetails } from "./project-menu-details";
-import { downloadWorkspace } from "#/utils/download-workspace";
import { useWsClient } from "#/context/ws-client-provider";
-import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
+import { DownloadModal } from "#/components/shared/download-modal";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
@@ -30,7 +28,7 @@ export function ProjectMenuCard({
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
- const [working, setWorking] = React.useState(false);
+ const [downloading, setDownloading] = React.useState(false);
const toggleMenuVisibility = () => {
setContextMenuIsOpen((prev) => !prev);
@@ -58,20 +56,16 @@ Please push the changes to GitHub and open a pull request.
const handleDownloadWorkspace = () => {
posthog.capture("download_workspace_button_clicked");
- try {
- setWorking(true);
- downloadWorkspace().then(
- () => setWorking(false),
- () => setWorking(false),
- );
- } catch (error) {
- toast.error("Failed to download workspace");
- }
+ setDownloading(true);
+ };
+
+ const handleDownloadClose = () => {
+ setDownloading(false);
};
return (
- {!working && contextMenuIsOpen && (
+ {!downloading && contextMenuIsOpen && (
setConnectToGitHubModalOpen(true)}
@@ -93,17 +87,20 @@ Please push the changes to GitHub and open a pull request.
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
/>
)}
-
+ )}
{connectToGitHubModalOpen && (
setConnectToGitHubModalOpen(false)}>
)}
- {t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
+ {t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL)}
);
diff --git a/frontend/src/components/shared/download-modal.tsx b/frontend/src/components/shared/download-modal.tsx
new file mode 100644
index 000000000000..0d2b92d4fcb3
--- /dev/null
+++ b/frontend/src/components/shared/download-modal.tsx
@@ -0,0 +1,33 @@
+import { useDownloadProgress } from "#/hooks/use-download-progress";
+import { DownloadProgress } from "./download-progress";
+
+interface DownloadModalProps {
+ initialPath: string;
+ onClose: () => void;
+ isOpen: boolean;
+}
+
+function ActiveDownload({
+ initialPath,
+ onClose,
+}: {
+ initialPath: string;
+ onClose: () => void;
+}) {
+ const { progress, cancelDownload } = useDownloadProgress(
+ initialPath,
+ onClose,
+ );
+
+ return ;
+}
+
+export function DownloadModal({
+ initialPath,
+ onClose,
+ isOpen,
+}: DownloadModalProps) {
+ if (!isOpen) return null;
+
+ return ;
+}
diff --git a/frontend/src/components/shared/download-progress.tsx b/frontend/src/components/shared/download-progress.tsx
new file mode 100644
index 000000000000..d5d79d0c6b43
--- /dev/null
+++ b/frontend/src/components/shared/download-progress.tsx
@@ -0,0 +1,87 @@
+export interface DownloadProgressState {
+ filesTotal: number;
+ filesDownloaded: number;
+ currentFile: string;
+ totalBytesDownloaded: number;
+ bytesDownloadedPerSecond: number;
+ isDiscoveringFiles: boolean;
+}
+
+interface DownloadProgressProps {
+ progress: DownloadProgressState;
+ onCancel: () => void;
+}
+
+export function DownloadProgress({
+ progress,
+ onCancel,
+}: DownloadProgressProps) {
+ const formatBytes = (bytes: number) => {
+ const units = ["B", "KB", "MB", "GB"];
+ let size = bytes;
+ let unitIndex = 0;
+ while (size >= 1024 && unitIndex < units.length - 1) {
+ size /= 1024;
+ unitIndex += 1;
+ }
+ return `${size.toFixed(1)} ${units[unitIndex]}`;
+ };
+
+ return (
+
+
+
+
+ {progress.isDiscoveringFiles
+ ? "Preparing Download..."
+ : "Downloading Files"}
+
+
+ {progress.isDiscoveringFiles
+ ? `Found ${progress.filesTotal} files...`
+ : progress.currentFile}
+
+
+
+
+
+ {progress.isDiscoveringFiles ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {progress.isDiscoveringFiles
+ ? `Scanning workspace...`
+ : `${progress.filesDownloaded} of ${progress.filesTotal} files`}
+
+ {!progress.isDiscoveringFiles && (
+ {formatBytes(progress.bytesDownloadedPerSecond)}/s
+ )}
+
+
+
+
+ Cancel
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/modal-backdrop.tsx b/frontend/src/components/shared/modals/modal-backdrop.tsx
index d10eb6ac7b9b..5d3fcea43217 100644
--- a/frontend/src/components/shared/modals/modal-backdrop.tsx
+++ b/frontend/src/components/shared/modals/modal-backdrop.tsx
@@ -20,7 +20,7 @@ export function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
};
return (
-
+
void,
+) {
+ const [isStarted, setIsStarted] = useState(false);
+ const [progress, setProgress] =
+ useState
(INITIAL_PROGRESS);
+ const progressRef = useRef(INITIAL_PROGRESS);
+ const abortController = useRef();
+
+ // Create AbortController on mount
+ useEffect(() => {
+ const controller = new AbortController();
+ abortController.current = controller;
+ // Initialize progress ref with initial state
+ progressRef.current = INITIAL_PROGRESS;
+ return () => {
+ controller.abort();
+ abortController.current = undefined;
+ };
+ }, []); // Empty deps array - only run on mount/unmount
+
+ // Start download when isStarted becomes true
+ useEffect(() => {
+ if (!isStarted) {
+ setIsStarted(true);
+ return;
+ }
+
+ if (!abortController.current) return;
+
+ // Start download
+ const download = async () => {
+ try {
+ await downloadFiles(initialPath, {
+ onProgress: (p) => {
+ // Update both the ref and state
+ progressRef.current = { ...p };
+ setProgress((prev: DownloadProgressState) => ({ ...prev, ...p }));
+ },
+ signal: abortController.current!.signal,
+ });
+ onClose();
+ } catch (error) {
+ if (error instanceof Error && error.message === "Download cancelled") {
+ onClose();
+ } else {
+ throw error;
+ }
+ }
+ };
+ download();
+ }, [initialPath, onClose, isStarted]);
+
+ // No longer need startDownload as it's handled in useEffect
+
+ const cancelDownload = useCallback(() => {
+ abortController.current?.abort();
+ }, []);
+
+ return {
+ progress,
+ cancelDownload,
+ };
+}
diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json
index c86c62f2f55c..df5f4de4154e 100644
--- a/frontend/src/i18n/translation.json
+++ b/frontend/src/i18n/translation.json
@@ -2001,9 +2001,9 @@
"en": "Push to GitHub",
"es": "Subir a GitHub"
},
- "PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL": {
- "en": "Download as .zip",
- "es": "Descargar como .zip"
+ "PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL": {
+ "en": "Download files",
+ "es": "Descargar archivos"
},
"ACTION_MESSAGE$RUN": {
"en": "Running a bash command"
diff --git a/frontend/src/types/file-system.d.ts b/frontend/src/types/file-system.d.ts
new file mode 100644
index 000000000000..c2d823f701e0
--- /dev/null
+++ b/frontend/src/types/file-system.d.ts
@@ -0,0 +1,31 @@
+interface FileSystemWritableFileStream extends WritableStream {
+ write(data: BufferSource | Blob | string): Promise;
+ seek(position: number): Promise;
+ truncate(size: number): Promise;
+}
+
+interface FileSystemFileHandle {
+ kind: "file";
+ name: string;
+ getFile(): Promise;
+ createWritable(options?: {
+ keepExistingData?: boolean;
+ }): Promise;
+}
+
+interface FileSystemDirectoryHandle {
+ kind: "directory";
+ name: string;
+ getDirectoryHandle(
+ name: string,
+ options?: { create?: boolean },
+ ): Promise;
+ getFileHandle(
+ name: string,
+ options?: { create?: boolean },
+ ): Promise;
+}
+
+interface Window {
+ showDirectoryPicker(): Promise;
+}
diff --git a/frontend/src/utils/download-files.ts b/frontend/src/utils/download-files.ts
new file mode 100644
index 000000000000..b22ebf47bdda
--- /dev/null
+++ b/frontend/src/utils/download-files.ts
@@ -0,0 +1,305 @@
+import OpenHands from "#/api/open-hands";
+
+interface DownloadProgress {
+ filesTotal: number;
+ filesDownloaded: number;
+ currentFile: string;
+ totalBytesDownloaded: number;
+ bytesDownloadedPerSecond: number;
+ isDiscoveringFiles: boolean;
+}
+
+interface DownloadOptions {
+ onProgress?: (progress: DownloadProgress) => void;
+ signal?: AbortSignal;
+}
+
+/**
+ * Checks if the File System Access API is supported
+ */
+function isFileSystemAccessSupported(): boolean {
+ return "showDirectoryPicker" in window;
+}
+
+/**
+ * Creates subdirectories and returns the final directory handle
+ */
+async function createSubdirectories(
+ baseHandle: FileSystemDirectoryHandle,
+ pathParts: string[],
+): Promise {
+ return pathParts.reduce(async (promise, part) => {
+ const handle = await promise;
+ return handle.getDirectoryHandle(part, { create: true });
+ }, Promise.resolve(baseHandle));
+}
+
+/**
+ * Recursively gets all files in a directory
+ */
+async function getAllFiles(
+ path: string,
+ progress: DownloadProgress,
+ options?: DownloadOptions,
+): Promise {
+ const entries = await OpenHands.getFiles(path);
+
+ const processEntry = async (entry: string): Promise => {
+ if (options?.signal?.aborted) {
+ throw new Error("Download cancelled");
+ }
+
+ const fullPath = path + entry;
+ if (entry.endsWith("/")) {
+ const subEntries = await OpenHands.getFiles(fullPath);
+ const subFilesPromises = subEntries.map((subEntry) =>
+ processEntry(subEntry),
+ );
+ const subFilesArrays = await Promise.all(subFilesPromises);
+ return subFilesArrays.flat();
+ }
+ const updatedProgress = {
+ ...progress,
+ filesTotal: progress.filesTotal + 1,
+ currentFile: fullPath,
+ };
+ options?.onProgress?.(updatedProgress);
+ return [fullPath];
+ };
+
+ const filePromises = entries.map((entry) => processEntry(entry));
+ const fileArrays = await Promise.all(filePromises);
+
+ const updatedProgress = {
+ ...progress,
+ isDiscoveringFiles: false,
+ };
+ options?.onProgress?.(updatedProgress);
+
+ return fileArrays.flat();
+}
+
+/**
+ * Process a batch of files
+ */
+async function processBatch(
+ batch: string[],
+ directoryHandle: FileSystemDirectoryHandle,
+ progress: DownloadProgress,
+ startTime: number,
+ completedFiles: number,
+ totalBytes: number,
+ options?: DownloadOptions,
+): Promise<{ newCompleted: number; newBytes: number }> {
+ if (options?.signal?.aborted) {
+ throw new Error("Download cancelled");
+ }
+
+ // Process files in the batch in parallel
+ const results = await Promise.all(
+ batch.map(async (path) => {
+ try {
+ const newProgress = {
+ ...progress,
+ currentFile: path,
+ isDiscoveringFiles: false,
+ filesDownloaded: completedFiles,
+ totalBytesDownloaded: totalBytes,
+ bytesDownloadedPerSecond:
+ totalBytes / ((Date.now() - startTime) / 1000),
+ };
+ options?.onProgress?.(newProgress);
+
+ const content = await OpenHands.getFile(path);
+
+ // Save to the selected directory preserving structure
+ const pathParts = path.split("/").filter(Boolean);
+ const fileName = pathParts.pop() || "file";
+ const dirHandle =
+ pathParts.length > 0
+ ? await createSubdirectories(directoryHandle, pathParts)
+ : directoryHandle;
+
+ // Create and write the file
+ const fileHandle = await dirHandle.getFileHandle(fileName, {
+ create: true,
+ });
+ const writable = await fileHandle.createWritable();
+ await writable.write(content);
+ await writable.close();
+
+ // Return the size of this file
+ return new Blob([content]).size;
+ } catch (error) {
+ // Silently handle file processing errors and return 0 bytes
+ return 0;
+ }
+ }),
+ );
+
+ // Calculate batch totals
+ const batchBytes = results.reduce((sum, size) => sum + size, 0);
+ const newTotalBytes = totalBytes + batchBytes;
+ const newCompleted =
+ completedFiles + results.filter((size) => size > 0).length;
+
+ // Update progress with batch results
+ const updatedProgress = {
+ ...progress,
+ filesDownloaded: newCompleted,
+ totalBytesDownloaded: newTotalBytes,
+ bytesDownloadedPerSecond: newTotalBytes / ((Date.now() - startTime) / 1000),
+ isDiscoveringFiles: false,
+ };
+ options?.onProgress?.(updatedProgress);
+
+ return {
+ newCompleted,
+ newBytes: newTotalBytes,
+ };
+}
+
+/**
+ * Downloads files from the workspace one by one
+ * @param initialPath Initial path to start downloading from. If not provided, downloads from root
+ * @param options Download options including progress callback and abort signal
+ */
+export async function downloadFiles(
+ initialPath?: string,
+ options?: DownloadOptions,
+): Promise {
+ const startTime = Date.now();
+ const progress: DownloadProgress = {
+ filesTotal: 0, // Will be updated during file discovery
+ filesDownloaded: 0,
+ currentFile: "",
+ totalBytesDownloaded: 0,
+ bytesDownloadedPerSecond: 0,
+ isDiscoveringFiles: true,
+ };
+
+ try {
+ // Check if File System Access API is supported
+ if (!isFileSystemAccessSupported()) {
+ throw new Error(
+ "Your browser doesn't support downloading folders. Please use Chrome, Edge, or another browser that supports the File System Access API.",
+ );
+ }
+
+ // Show directory picker first
+ let directoryHandle: FileSystemDirectoryHandle;
+ try {
+ directoryHandle = await window.showDirectoryPicker();
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new Error("Download cancelled");
+ }
+ if (error instanceof Error && error.name === "SecurityError") {
+ throw new Error(
+ "Permission denied. Please allow access to the download location when prompted.",
+ );
+ }
+ throw new Error("Failed to select download location. Please try again.");
+ }
+
+ // Then recursively get all files
+ const files = await getAllFiles(initialPath || "", progress, options);
+
+ // Set isDiscoveringFiles to false now that we have the full list and preserve filesTotal
+ const finalTotal = progress.filesTotal;
+ options?.onProgress?.({
+ ...progress,
+ filesTotal: finalTotal,
+ isDiscoveringFiles: false,
+ });
+
+ // Verify we still have permission after the potentially long file scan
+ try {
+ // Try to create and write to a test file to verify permissions
+ const testHandle = await directoryHandle.getFileHandle(
+ ".openhands-test",
+ { create: true },
+ );
+ const writable = await testHandle.createWritable();
+ await writable.close();
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ error.message.includes("User activation is required")
+ ) {
+ // Ask for permission again
+ try {
+ directoryHandle = await window.showDirectoryPicker();
+ } catch (permissionError) {
+ if (
+ permissionError instanceof Error &&
+ permissionError.name === "AbortError"
+ ) {
+ throw new Error("Download cancelled");
+ }
+ if (
+ permissionError instanceof Error &&
+ permissionError.name === "SecurityError"
+ ) {
+ throw new Error(
+ "Permission denied. Please allow access to the download location when prompted.",
+ );
+ }
+ throw new Error(
+ "Failed to select download location. Please try again.",
+ );
+ }
+ } else {
+ throw error;
+ }
+ }
+
+ // Process files in parallel batches to avoid overwhelming the browser
+ const BATCH_SIZE = 5;
+ const batches = Array.from(
+ { length: Math.ceil(files.length / BATCH_SIZE) },
+ (_, i) => files.slice(i * BATCH_SIZE, (i + 1) * BATCH_SIZE),
+ );
+
+ // Keep track of completed files across all batches
+ let completedFiles = 0;
+ let totalBytesDownloaded = 0;
+
+ // Process batches sequentially to maintain order and avoid overwhelming the browser
+ await batches.reduce(
+ (promise, batch) =>
+ promise.then(async () => {
+ const { newCompleted, newBytes } = await processBatch(
+ batch,
+ directoryHandle,
+ progress,
+ startTime,
+ completedFiles,
+ totalBytesDownloaded,
+ options,
+ );
+ completedFiles = newCompleted;
+ totalBytesDownloaded = newBytes;
+ }),
+ Promise.resolve(),
+ );
+ } catch (error) {
+ if (error instanceof Error && error.message === "Download cancelled") {
+ throw error;
+ }
+ // Re-throw the error as is if it's already a user-friendly message
+ if (
+ error instanceof Error &&
+ (error.message.includes("browser doesn't support") ||
+ error.message.includes("Failed to select") ||
+ error.message === "Download cancelled")
+ ) {
+ throw error;
+ }
+
+ // Otherwise, wrap it with a generic message
+ throw new Error(
+ `Failed to download files: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 1a57ebcd8bce..7af574f19e12 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -1,7 +1,6 @@
/** @type {import('tailwindcss').Config} */
import { nextui } from "@nextui-org/react";
import typography from '@tailwindcss/typography';
-
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py
index fbcf4eaa836a..a8ae543b8751 100644
--- a/openhands/server/session/manager.py
+++ b/openhands/server/session/manager.py
@@ -29,6 +29,14 @@ class SessionManager:
_last_alive_timestamps: dict[str, float] = field(default_factory=dict)
_redis_listen_task: asyncio.Task | None = None
_session_is_running_flags: dict[str, asyncio.Event] = field(default_factory=dict)
+ _active_conversations: dict[str, tuple[Conversation, int]] = field(
+ default_factory=dict
+ )
+ _detached_conversations: dict[str, tuple[Conversation, float]] = field(
+ default_factory=dict
+ )
+ _conversations_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+ _cleanup_task: asyncio.Task | None = None
_has_remote_connections_flags: dict[str, asyncio.Event] = field(
default_factory=dict
)
@@ -37,12 +45,16 @@ async def __aenter__(self):
redis_client = self._get_redis_client()
if redis_client:
self._redis_listen_task = asyncio.create_task(self._redis_subscribe())
+ self._cleanup_task = asyncio.create_task(self._cleanup_detached_conversations())
return self
async def __aexit__(self, exc_type, exc_value, traceback):
if self._redis_listen_task:
self._redis_listen_task.cancel()
self._redis_listen_task = None
+ if self._cleanup_task:
+ self._cleanup_task.cancel()
+ self._cleanup_task = None
def _get_redis_client(self):
redis_client = getattr(self.sio.manager, 'redis', None)
@@ -128,21 +140,68 @@ async def attach_to_conversation(self, sid: str) -> Conversation | None:
start_time = time.time()
if not await session_exists(sid, self.file_store):
return None
- c = Conversation(sid, file_store=self.file_store, config=self.config)
- try:
- await c.connect()
- except RuntimeUnavailableError as e:
- logger.error(f'Error connecting to conversation {c.sid}: {e}')
- return None
- end_time = time.time()
- logger.info(
- f'Conversation {c.sid} connected in {end_time - start_time} seconds'
- )
- return c
- async def detach_from_conversation(self, conversation: Conversation):
- await conversation.disconnect()
+ async with self._conversations_lock:
+ # Check if we have an active conversation we can reuse
+ if sid in self._active_conversations:
+ conversation, count = self._active_conversations[sid]
+ self._active_conversations[sid] = (conversation, count + 1)
+ logger.info(f'Reusing active conversation {sid}')
+ return conversation
+
+ # Check if we have a detached conversation we can reuse
+ if sid in self._detached_conversations:
+ conversation, _ = self._detached_conversations.pop(sid)
+ self._active_conversations[sid] = (conversation, 1)
+ logger.info(f'Reusing detached conversation {sid}')
+ return conversation
+
+ # Create new conversation if none exists
+ c = Conversation(sid, file_store=self.file_store, config=self.config)
+ try:
+ await c.connect()
+ except RuntimeUnavailableError as e:
+ logger.error(f'Error connecting to conversation {c.sid}: {e}')
+ return None
+ end_time = time.time()
+ logger.info(
+ f'Conversation {c.sid} connected in {end_time - start_time} seconds'
+ )
+ self._active_conversations[sid] = (c, 1)
+ return c
+ async def detach_from_conversation(self, conversation: Conversation):
+ sid = conversation.sid
+ async with self._conversations_lock:
+ if sid in self._active_conversations:
+ conv, count = self._active_conversations[sid]
+ if count > 1:
+ self._active_conversations[sid] = (conv, count - 1)
+ return
+ else:
+ self._active_conversations.pop(sid)
+ self._detached_conversations[sid] = (conversation, time.time())
+
+ async def _cleanup_detached_conversations(self):
+ while should_continue():
+ try:
+ async with self._conversations_lock:
+ # Create a list of items to process to avoid modifying dict during iteration
+ items = list(self._detached_conversations.items())
+ for sid, (conversation, detach_time) in items:
+ await conversation.disconnect()
+ self._detached_conversations.pop(sid, None)
+
+ await asyncio.sleep(60)
+ except asyncio.CancelledError:
+ async with self._conversations_lock:
+ for conversation, _ in self._detached_conversations.values():
+ await conversation.disconnect()
+ self._detached_conversations.clear()
+ return
+ except Exception:
+ logger.warning('error_cleaning_detached_conversations', exc_info=True)
+ await asyncio.sleep(15)
async def init_or_join_session(
self, sid: str, connection_id: str, session_init_data: SessionInitData
):