From 9e9dc87de2b4a2f552f65edf146280b02a2c1b8c Mon Sep 17 00:00:00 2001
From: Will Chen <willchen90@gmail.com>
Date: Wed, 16 Oct 2024 00:23:09 -0700
Subject: [PATCH 1/6] Add proper concurrency support in websockets mode

---
 mesop/examples/__init__.py                    |  3 ++
 .../examples/concurrent_updates_websockets.py | 35 +++++++++++++++++++
 mesop/runtime/context.py                      | 16 +++++++++
 mesop/runtime/runtime.py                      |  8 ++++-
 mesop/server/server.py                        |  3 ++
 .../e2e/concurrent_updates_websockets_test.ts | 15 ++++++++
 mesop/tests/e2e/e2e_helpers.ts                | 10 ++++++
 7 files changed, 89 insertions(+), 1 deletion(-)
 create mode 100644 mesop/examples/concurrent_updates_websockets.py
 create mode 100644 mesop/tests/e2e/concurrent_updates_websockets_test.ts

diff --git a/mesop/examples/__init__.py b/mesop/examples/__init__.py
index ea9d1c14a..6d4ae1ac7 100644
--- a/mesop/examples/__init__.py
+++ b/mesop/examples/__init__.py
@@ -12,6 +12,9 @@
 from mesop.examples import composite as composite
 from mesop.examples import concurrency_state as concurrency_state
 from mesop.examples import concurrent_updates as concurrent_updates
+from mesop.examples import (
+  concurrent_updates_websockets as concurrent_updates_websockets,
+)
 from mesop.examples import custom_font as custom_font
 from mesop.examples import dict_state as dict_state
 from mesop.examples import docs as docs
diff --git a/mesop/examples/concurrent_updates_websockets.py b/mesop/examples/concurrent_updates_websockets.py
new file mode 100644
index 000000000..9bc811712
--- /dev/null
+++ b/mesop/examples/concurrent_updates_websockets.py
@@ -0,0 +1,35 @@
+import time
+
+import mesop as me
+
+
+@me.page(path="/concurrent_updates_websockets")
+def page():
+  state = me.state(State)
+  me.text("concurrent_updates_websockets")
+  me.button(label="Slow state update", on_click=slow_state_update)
+  me.button(label="Fast state update", on_click=fast_state_update)
+  me.text("Slow state: " + str(state.slow_state))
+  me.text("Fast state: " + str(state.fast_state))
+  if state.show_box:
+    with me.box():
+      me.text("Box!")
+
+
+@me.stateclass
+class State:
+  show_box: bool
+  slow_state: bool
+  fast_state: bool
+
+
+def slow_state_update(e: me.ClickEvent):
+  time.sleep(3)
+  me.state(State).show_box = True
+  me.state(State).slow_state = True
+  yield
+
+
+def fast_state_update(e: me.ClickEvent):
+  me.state(State).show_box = True
+  me.state(State).fast_state = True
diff --git a/mesop/runtime/context.py b/mesop/runtime/context.py
index e32196f4d..7c3e1d74b 100644
--- a/mesop/runtime/context.py
+++ b/mesop/runtime/context.py
@@ -1,5 +1,6 @@
 import asyncio
 import copy
+import threading
 import types
 import urllib.parse as urlparse
 from typing import Any, Callable, Generator, Sequence, TypeVar, cast
@@ -14,6 +15,7 @@
   MesopDeveloperException,
   MesopException,
 )
+from mesop.server.server_utils import MESOP_WEBSOCKETS_ENABLED
 from mesop.server.state_session import state_session
 
 T = TypeVar("T")
@@ -42,6 +44,20 @@ def __init__(
     self._theme_settings: pb.ThemeSettings | None = None
     self._js_modules: set[str] = set()
     self._query_params: dict[str, list[str]] = {}
+    if MESOP_WEBSOCKETS_ENABLED:
+      self._lock = threading.Lock()
+
+  def acquire_lock(self) -> None:
+    # No-op if websockets is not enabled because
+    # theoretically we don't need to lock.
+    if MESOP_WEBSOCKETS_ENABLED:
+      self._lock.acquire()
+
+  def release_lock(self) -> None:
+    # No-op if websockets is not enabled because
+    # theoretically we don't need to lock.
+    if MESOP_WEBSOCKETS_ENABLED:
+      self._lock.release()
 
   def register_js_module(self, js_module_path: str) -> None:
     self._js_modules.add(js_module_path)
diff --git a/mesop/runtime/runtime.py b/mesop/runtime/runtime.py
index c7a599ac8..c962d495a 100644
--- a/mesop/runtime/runtime.py
+++ b/mesop/runtime/runtime.py
@@ -2,13 +2,14 @@
 from dataclasses import dataclass
 from typing import Any, Callable, Generator, Type, TypeVar, cast
 
-from flask import g
+from flask import g, request
 
 import mesop.protos.ui_pb2 as pb
 from mesop.events import LoadEvent, MesopEvent
 from mesop.exceptions import MesopDeveloperException, MesopUserException
 from mesop.key import Key
 from mesop.security.security_policy import SecurityPolicy
+from mesop.server.server_utils import MESOP_WEBSOCKETS_ENABLED
 from mesop.utils.backoff import exponential_backoff
 
 from .context import Context
@@ -54,8 +55,13 @@ def __init__(self):
     self._state_classes: list[type[Any]] = []
     self._loading_errors: list[pb.ServerError] = []
     self._has_served_traffic = False
+    self._contexts = {}
 
   def context(self) -> Context:
+    if MESOP_WEBSOCKETS_ENABLED and hasattr(request, "sid"):
+      if request.sid not in self._contexts:
+        self._contexts[request.sid] = self.create_context()
+      return self._contexts[request.sid]
     if "_mesop_context" not in g:
       g._mesop_context = self.create_context()
     return g._mesop_context
diff --git a/mesop/server/server.py b/mesop/server/server.py
index e00ea5303..43996b784 100644
--- a/mesop/server/server.py
+++ b/mesop/server/server.py
@@ -38,6 +38,7 @@ def render_loop(
     init_request: bool = False,
   ) -> Generator[str, None, None]:
     try:
+      runtime().context().acquire_lock()
       runtime().run_path(path=path, trace_mode=trace_mode)
       page_config = runtime().get_page_config(path=path)
       title = page_config.title if page_config else "Unknown path"
@@ -88,6 +89,8 @@ def render_loop(
       yield from yield_errors(
         error=pb.ServerError(exception=str(e), traceback=format_traceback())
       )
+    finally:
+      runtime().context().release_lock()
 
   def yield_errors(error: pb.ServerError) -> Generator[str, None, None]:
     if not runtime().debug_mode:
diff --git a/mesop/tests/e2e/concurrent_updates_websockets_test.ts b/mesop/tests/e2e/concurrent_updates_websockets_test.ts
new file mode 100644
index 000000000..9f0eb6edc
--- /dev/null
+++ b/mesop/tests/e2e/concurrent_updates_websockets_test.ts
@@ -0,0 +1,15 @@
+import {testInWebSocketsEnabledOnly} from './e2e_helpers';
+import {expect} from '@playwright/test';
+
+testInWebSocketsEnabledOnly(
+  'concurrent updates (websockets)',
+  async ({page}) => {
+    await page.goto('/concurrent_updates_websockets');
+    await page.getByRole('button', {name: 'Slow state update'}).click();
+    await page.getByRole('button', {name: 'Fast state update'}).click();
+    await expect(page.getByText('Fast state: true')).toBeVisible();
+    await expect(page.getByText('Box!')).toBeVisible();
+    await expect(page.getByText('Slow state: false')).toBeVisible();
+    await expect(page.getByText('Slow state: true')).toBeVisible();
+  },
+);
diff --git a/mesop/tests/e2e/e2e_helpers.ts b/mesop/tests/e2e/e2e_helpers.ts
index d1913db31..4ba7be884 100644
--- a/mesop/tests/e2e/e2e_helpers.ts
+++ b/mesop/tests/e2e/e2e_helpers.ts
@@ -19,3 +19,13 @@ export const testInConcurrentUpdatesEnabledOnly = base.extend({
     await use(page);
   },
 });
+
+export const testInWebSocketsEnabledOnly = base.extend({
+  // Skip this test if MESOP_WEBSOCKETS_ENABLED is not 'true'
+  page: async ({page}, use) => {
+    if (process.env.MESOP_WEBSOCKETS_ENABLED !== 'true') {
+      base.skip(true, 'Skipping test in websockets disabled mode');
+    }
+    await use(page);
+  },
+});

From be410fbb1d5fba81a20c05646145cf1a0cf4173e Mon Sep 17 00:00:00 2001
From: Will Chen <willchen90@gmail.com>
Date: Thu, 17 Oct 2024 22:51:09 -0700
Subject: [PATCH 2/6] working

---
 mesop/cli/cli.py                              |  2 +-
 mesop/env/BUILD                               | 10 +++++++
 mesop/env/__init__.py                         |  0
 mesop/env/env.py                              | 27 ++++++++++++++++++
 mesop/runtime/BUILD                           |  1 +
 mesop/runtime/context.py                      | 11 +++-----
 mesop/runtime/runtime.py                      | 10 ++++---
 mesop/server/BUILD                            |  1 +
 mesop/server/server.py                        | 13 +++++----
 mesop/server/server_debug_routes.py           |  2 +-
 mesop/server/server_utils.py                  | 28 +------------------
 mesop/server/wsgi_app.py                      |  2 +-
 .../e2e/concurrent_updates_websockets_test.ts |  4 ++-
 13 files changed, 64 insertions(+), 47 deletions(-)
 create mode 100644 mesop/env/BUILD
 create mode 100644 mesop/env/__init__.py
 create mode 100644 mesop/env/env.py

diff --git a/mesop/cli/cli.py b/mesop/cli/cli.py
index 9d061516a..635fc7ceb 100644
--- a/mesop/cli/cli.py
+++ b/mesop/cli/cli.py
@@ -11,6 +11,7 @@
   execute_module,
   get_module_name_from_runfile_path,
 )
+from mesop.env.env import MESOP_WEBSOCKETS_ENABLED
 from mesop.exceptions import format_traceback
 from mesop.runtime import (
   enable_debug_mode,
@@ -22,7 +23,6 @@
 from mesop.server.flags import port
 from mesop.server.logging import log_startup
 from mesop.server.server import configure_flask_app
-from mesop.server.server_utils import MESOP_WEBSOCKETS_ENABLED
 from mesop.server.static_file_serving import configure_static_file_serving
 from mesop.utils.host_util import get_public_host
 from mesop.utils.runfiles import get_runfile_location
diff --git a/mesop/env/BUILD b/mesop/env/BUILD
new file mode 100644
index 000000000..fd2371c5a
--- /dev/null
+++ b/mesop/env/BUILD
@@ -0,0 +1,10 @@
+load("//build_defs:defaults.bzl", "py_library")
+
+package(
+    default_visibility = ["//build_defs:mesop_internal"],
+)
+
+py_library(
+    name = "env",
+    srcs = glob(["*.py"]),
+)
diff --git a/mesop/env/__init__.py b/mesop/env/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/mesop/env/env.py b/mesop/env/env.py
new file mode 100644
index 000000000..2d38e03e1
--- /dev/null
+++ b/mesop/env/env.py
@@ -0,0 +1,27 @@
+import os
+
+AI_SERVICE_BASE_URL = os.environ.get(
+  "MESOP_AI_SERVICE_BASE_URL", "http://localhost:43234"
+)
+
+MESOP_WEBSOCKETS_ENABLED = (
+  os.environ.get("MESOP_WEBSOCKETS_ENABLED", "false").lower() == "true"
+)
+
+MESOP_CONCURRENT_UPDATES_ENABLED = (
+  os.environ.get("MESOP_CONCURRENT_UPDATES_ENABLED", "false").lower() == "true"
+)
+
+if MESOP_WEBSOCKETS_ENABLED:
+  print("Experiment enabled: MESOP_WEBSOCKETS_ENABLED")
+  print("Auto-enabling MESOP_CONCURRENT_UPDATES_ENABLED")
+  MESOP_CONCURRENT_UPDATES_ENABLED = True
+elif MESOP_CONCURRENT_UPDATES_ENABLED:
+  print("Experiment enabled: MESOP_CONCURRENT_UPDATES_ENABLED")
+
+EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED = (
+  os.environ.get("MESOP_EXPERIMENTAL_EDITOR_TOOLBAR", "false").lower() == "true"
+)
+
+if EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED:
+  print("Experiment enabled: EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED")
diff --git a/mesop/runtime/BUILD b/mesop/runtime/BUILD
index 36180428a..f98e3edc4 100644
--- a/mesop/runtime/BUILD
+++ b/mesop/runtime/BUILD
@@ -9,6 +9,7 @@ py_library(
     srcs = glob(["*.py"]),
     deps = [
         "//mesop/dataclass_utils",
+        "//mesop/env",
         "//mesop/events",
         "//mesop/exceptions",
         "//mesop/protos:ui_py_pb2",
diff --git a/mesop/runtime/context.py b/mesop/runtime/context.py
index 7c3e1d74b..cde582b0a 100644
--- a/mesop/runtime/context.py
+++ b/mesop/runtime/context.py
@@ -15,7 +15,6 @@
   MesopDeveloperException,
   MesopException,
 )
-from mesop.server.server_utils import MESOP_WEBSOCKETS_ENABLED
 from mesop.server.state_session import state_session
 
 T = TypeVar("T")
@@ -44,20 +43,18 @@ def __init__(
     self._theme_settings: pb.ThemeSettings | None = None
     self._js_modules: set[str] = set()
     self._query_params: dict[str, list[str]] = {}
-    if MESOP_WEBSOCKETS_ENABLED:
-      self._lock = threading.Lock()
+
+    self._lock = threading.Lock()
 
   def acquire_lock(self) -> None:
     # No-op if websockets is not enabled because
     # theoretically we don't need to lock.
-    if MESOP_WEBSOCKETS_ENABLED:
-      self._lock.acquire()
+    self._lock.acquire()
 
   def release_lock(self) -> None:
     # No-op if websockets is not enabled because
     # theoretically we don't need to lock.
-    if MESOP_WEBSOCKETS_ENABLED:
-      self._lock.release()
+    self._lock.release()
 
   def register_js_module(self, js_module_path: str) -> None:
     self._js_modules.add(js_module_path)
diff --git a/mesop/runtime/runtime.py b/mesop/runtime/runtime.py
index c962d495a..72a9585fd 100644
--- a/mesop/runtime/runtime.py
+++ b/mesop/runtime/runtime.py
@@ -5,11 +5,11 @@
 from flask import g, request
 
 import mesop.protos.ui_pb2 as pb
+from mesop.env.env import MESOP_WEBSOCKETS_ENABLED
 from mesop.events import LoadEvent, MesopEvent
 from mesop.exceptions import MesopDeveloperException, MesopUserException
 from mesop.key import Key
 from mesop.security.security_policy import SecurityPolicy
-from mesop.server.server_utils import MESOP_WEBSOCKETS_ENABLED
 from mesop.utils.backoff import exponential_backoff
 
 from .context import Context
@@ -59,9 +59,11 @@ def __init__(self):
 
   def context(self) -> Context:
     if MESOP_WEBSOCKETS_ENABLED and hasattr(request, "sid"):
-      if request.sid not in self._contexts:
-        self._contexts[request.sid] = self.create_context()
-      return self._contexts[request.sid]
+      # flask-socketio adds sid (session id) to the request object.
+      sid = request.sid  # type: ignore
+      if sid not in self._contexts:
+        self._contexts[sid] = self.create_context()
+      return self._contexts[sid]
     if "_mesop_context" not in g:
       g._mesop_context = self.create_context()
     return g._mesop_context
diff --git a/mesop/server/BUILD b/mesop/server/BUILD
index bcd68fdb3..f64639ee3 100644
--- a/mesop/server/BUILD
+++ b/mesop/server/BUILD
@@ -33,6 +33,7 @@ py_library(
     deps = [
                "//mesop/component_helpers",
                "//mesop/editor",
+               "//mesop/env",
                "//mesop/events",
                "//mesop/protos:ui_py_pb2",
                "//mesop/utils",
diff --git a/mesop/server/server.py b/mesop/server/server.py
index 43996b784..33513d81f 100644
--- a/mesop/server/server.py
+++ b/mesop/server/server.py
@@ -6,15 +6,17 @@
 import mesop.protos.ui_pb2 as pb
 from mesop.component_helpers import diff_component
 from mesop.editor.component_configs import get_component_configs
+from mesop.env.env import (
+  EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED,
+  MESOP_CONCURRENT_UPDATES_ENABLED,
+  MESOP_WEBSOCKETS_ENABLED,
+)
 from mesop.events import LoadEvent
 from mesop.exceptions import format_traceback
 from mesop.runtime import runtime
 from mesop.server.constants import WEB_COMPONENTS_PATH_SEGMENT
 from mesop.server.server_debug_routes import configure_debug_routes
 from mesop.server.server_utils import (
-  EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED,
-  MESOP_CONCURRENT_UPDATES_ENABLED,
-  MESOP_WEBSOCKETS_ENABLED,
   STREAM_END,
   create_update_state_event,
   is_same_site,
@@ -38,7 +40,7 @@ def render_loop(
     init_request: bool = False,
   ) -> Generator[str, None, None]:
     try:
-      runtime().context().acquire_lock()
+      # runtime().context().acquire_lock()
       runtime().run_path(path=path, trace_mode=trace_mode)
       page_config = runtime().get_page_config(path=path)
       title = page_config.title if page_config else "Unknown path"
@@ -90,7 +92,8 @@ def render_loop(
         error=pb.ServerError(exception=str(e), traceback=format_traceback())
       )
     finally:
-      runtime().context().release_lock()
+      pass
+      # runtime().context().release_lock()
 
   def yield_errors(error: pb.ServerError) -> Generator[str, None, None]:
     if not runtime().debug_mode:
diff --git a/mesop/server/server_debug_routes.py b/mesop/server/server_debug_routes.py
index e41794034..d8c28bc82 100644
--- a/mesop/server/server_debug_routes.py
+++ b/mesop/server/server_debug_routes.py
@@ -6,9 +6,9 @@
 
 from flask import Flask, Response, request
 
+from mesop.env.env import AI_SERVICE_BASE_URL
 from mesop.runtime import runtime
 from mesop.server.server_utils import (
-  AI_SERVICE_BASE_URL,
   check_editor_access,
   make_sse_response,
   sse_request,
diff --git a/mesop/server/server_utils.py b/mesop/server/server_utils.py
index 8ef5184bf..425e0fc13 100644
--- a/mesop/server/server_utils.py
+++ b/mesop/server/server_utils.py
@@ -1,6 +1,5 @@
 import base64
 import json
-import os
 import secrets
 import urllib.parse as urlparse
 from typing import Any, Generator, Iterable
@@ -9,35 +8,10 @@
 from flask import Response, abort, request
 
 import mesop.protos.ui_pb2 as pb
+from mesop.env.env import EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED
 from mesop.runtime import runtime
 from mesop.server.config import app_config
 
-AI_SERVICE_BASE_URL = os.environ.get(
-  "MESOP_AI_SERVICE_BASE_URL", "http://localhost:43234"
-)
-
-MESOP_WEBSOCKETS_ENABLED = (
-  os.environ.get("MESOP_WEBSOCKETS_ENABLED", "false").lower() == "true"
-)
-
-MESOP_CONCURRENT_UPDATES_ENABLED = (
-  os.environ.get("MESOP_CONCURRENT_UPDATES_ENABLED", "false").lower() == "true"
-)
-
-if MESOP_WEBSOCKETS_ENABLED:
-  print("Experiment enabled: MESOP_WEBSOCKETS_ENABLED")
-  print("Auto-enabling MESOP_CONCURRENT_UPDATES_ENABLED")
-  MESOP_CONCURRENT_UPDATES_ENABLED = True
-elif MESOP_CONCURRENT_UPDATES_ENABLED:
-  print("Experiment enabled: MESOP_CONCURRENT_UPDATES_ENABLED")
-
-EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED = (
-  os.environ.get("MESOP_EXPERIMENTAL_EDITOR_TOOLBAR", "false").lower() == "true"
-)
-
-if EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED:
-  print("Experiment enabled: EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED")
-
 LOCALHOSTS = (
   # For IPv4 localhost
   "127.0.0.1",
diff --git a/mesop/server/wsgi_app.py b/mesop/server/wsgi_app.py
index dc5fc1f41..7115d6cf1 100644
--- a/mesop/server/wsgi_app.py
+++ b/mesop/server/wsgi_app.py
@@ -4,12 +4,12 @@
 from absl import flags
 from flask import Flask
 
+from mesop.env.env import MESOP_WEBSOCKETS_ENABLED
 from mesop.runtime import enable_debug_mode
 from mesop.server.constants import EDITOR_PACKAGE_PATH, PROD_PACKAGE_PATH
 from mesop.server.flags import port
 from mesop.server.logging import log_startup
 from mesop.server.server import configure_flask_app
-from mesop.server.server_utils import MESOP_WEBSOCKETS_ENABLED
 from mesop.server.static_file_serving import configure_static_file_serving
 from mesop.utils.host_util import get_local_host
 
diff --git a/mesop/tests/e2e/concurrent_updates_websockets_test.ts b/mesop/tests/e2e/concurrent_updates_websockets_test.ts
index 9f0eb6edc..8357b05c7 100644
--- a/mesop/tests/e2e/concurrent_updates_websockets_test.ts
+++ b/mesop/tests/e2e/concurrent_updates_websockets_test.ts
@@ -8,8 +8,10 @@ testInWebSocketsEnabledOnly(
     await page.getByRole('button', {name: 'Slow state update'}).click();
     await page.getByRole('button', {name: 'Fast state update'}).click();
     await expect(page.getByText('Fast state: true')).toBeVisible();
-    await expect(page.getByText('Box!')).toBeVisible();
+    expect(await page.locator('text="Box!"').count()).toBe(1);
     await expect(page.getByText('Slow state: false')).toBeVisible();
     await expect(page.getByText('Slow state: true')).toBeVisible();
+    //  Make sure there isn't a second Box from the concurrent update.
+    expect(await page.locator('text="Box!"').count()).toBe(1);
   },
 );

From 7f0f73a9228bee222a16ef1ac1f6a8c99a880e18 Mon Sep 17 00:00:00 2001
From: Will Chen <willchen90@gmail.com>
Date: Thu, 17 Oct 2024 23:05:29 -0700
Subject: [PATCH 3/6] fixup

---
 docs/api/config.md       |  8 ++++++++
 mesop/runtime/context.py | 17 +++++++++++------
 mesop/server/server.py   |  5 ++---
 3 files changed, 21 insertions(+), 9 deletions(-)

diff --git a/docs/api/config.md b/docs/api/config.md
index c86723e52..a45f4a0e5 100644
--- a/docs/api/config.md
+++ b/docs/api/config.md
@@ -12,6 +12,14 @@ Allows concurrent updates to state in the same session. If this is not updated,
 
 By default, this is not enabled. You can enable this by setting it to `true`.
 
+### MESOP_WEB_SOCKETS_ENABLED
+
+!!! warning "Experimental feature"
+
+    This is an experimental feature and is subject to breaking change. Please follow [https://github.com/google/mesop/issues/1028](https://github.com/google/mesop/issues/1028) for updates.
+
+This uses WebSockets instead of HTTP Server-Sent Events (SSE) as the transport protocol for UI updates. If you set this environment variable to `true`, then [`MESOP_CONCURRENT_UPDATES_ENABLED`](#MESOP_CONCURRENT_UPDATES_ENABLED) will automatically be enabled as well.
+
 ### MESOP_STATE_SESSION_BACKEND
 
 Sets the backend to use for caching state data server-side. This makes it so state does
diff --git a/mesop/runtime/context.py b/mesop/runtime/context.py
index cde582b0a..d0b3c33de 100644
--- a/mesop/runtime/context.py
+++ b/mesop/runtime/context.py
@@ -11,6 +11,7 @@
   serialize_dataclass,
   update_dataclass_from_json,
 )
+from mesop.env.env import MESOP_WEBSOCKETS_ENABLED
 from mesop.exceptions import (
   MesopDeveloperException,
   MesopException,
@@ -43,18 +44,22 @@ def __init__(
     self._theme_settings: pb.ThemeSettings | None = None
     self._js_modules: set[str] = set()
     self._query_params: dict[str, list[str]] = {}
-
-    self._lock = threading.Lock()
+    if MESOP_WEBSOCKETS_ENABLED:
+      self._lock = threading.Lock()
 
   def acquire_lock(self) -> None:
     # No-op if websockets is not enabled because
-    # theoretically we don't need to lock.
-    self._lock.acquire()
+    # there shouldn't be concurrent updates to the same
+    # context instance.
+    if MESOP_WEBSOCKETS_ENABLED:
+      self._lock.acquire()
 
   def release_lock(self) -> None:
     # No-op if websockets is not enabled because
-    # theoretically we don't need to lock.
-    self._lock.release()
+    # there shouldn't be concurrent updates to the same
+    # context instance.
+    if MESOP_WEBSOCKETS_ENABLED:
+      self._lock.release()
 
   def register_js_module(self, js_module_path: str) -> None:
     self._js_modules.add(js_module_path)
diff --git a/mesop/server/server.py b/mesop/server/server.py
index 33513d81f..7510c063c 100644
--- a/mesop/server/server.py
+++ b/mesop/server/server.py
@@ -40,7 +40,7 @@ def render_loop(
     init_request: bool = False,
   ) -> Generator[str, None, None]:
     try:
-      # runtime().context().acquire_lock()
+      runtime().context().acquire_lock()
       runtime().run_path(path=path, trace_mode=trace_mode)
       page_config = runtime().get_page_config(path=path)
       title = page_config.title if page_config else "Unknown path"
@@ -92,8 +92,7 @@ def render_loop(
         error=pb.ServerError(exception=str(e), traceback=format_traceback())
       )
     finally:
-      pass
-      # runtime().context().release_lock()
+      runtime().context().release_lock()
 
   def yield_errors(error: pb.ServerError) -> Generator[str, None, None]:
     if not runtime().debug_mode:

From 31a5b3cd808dcb8bf162e817f38cf2f7941c0b8a Mon Sep 17 00:00:00 2001
From: Will Chen <willchen90@gmail.com>
Date: Fri, 18 Oct 2024 22:00:41 -0700
Subject: [PATCH 4/6] pr comments

---
 mesop/runtime/runtime.py |  7 +++++++
 mesop/server/server.py   | 11 +++++++++++
 2 files changed, 18 insertions(+)

diff --git a/mesop/runtime/runtime.py b/mesop/runtime/runtime.py
index 72a9585fd..a208502a5 100644
--- a/mesop/runtime/runtime.py
+++ b/mesop/runtime/runtime.py
@@ -11,6 +11,7 @@
 from mesop.key import Key
 from mesop.security.security_policy import SecurityPolicy
 from mesop.utils.backoff import exponential_backoff
+from mesop.warn import warn
 
 from .context import Context
 
@@ -68,6 +69,12 @@ def context(self) -> Context:
       g._mesop_context = self.create_context()
     return g._mesop_context
 
+  def delete_context(self, sid: str) -> None:
+    if sid in self._contexts:
+      del self._contexts[sid]
+    else:
+      warn(f"Tried to delete context with sid={sid} that doesn't exist.")
+
   def create_context(self) -> Context:
     # If running in prod mode, *always* enable the has served traffic safety check.
     # If running in debug mode, *disable* the has served traffic safety check.
diff --git a/mesop/server/server.py b/mesop/server/server.py
index 7510c063c..6f2cc5983 100644
--- a/mesop/server/server.py
+++ b/mesop/server/server.py
@@ -259,6 +259,17 @@ def teardown_clear_stale_state_sessions(error=None):
 
     socketio = SocketIO(flask_app)
 
+    @socketio.on_error(namespace=UI_PATH)
+    def error_handler_chat(e):
+      print("WebSocket error", e)
+      sid = request.sid  # type: ignore
+      runtime().delete_context(sid)
+
+    @socketio.on("disconnect", namespace=UI_PATH)
+    def handle_disconnect():
+      sid = request.sid  # type: ignore
+      runtime().delete_context(sid)
+
     @socketio.on("message", namespace=UI_PATH)
     def handle_message(message):
       if not message:

From c7ed8c8c29aa69e3d29d28b02d444a1ef8a5c704 Mon Sep 17 00:00:00 2001
From: Will Chen <willchen90@gmail.com>
Date: Fri, 18 Oct 2024 23:04:42 -0700
Subject: [PATCH 5/6] fix

---
 mesop/runtime/BUILD | 1 +
 1 file changed, 1 insertion(+)

diff --git a/mesop/runtime/BUILD b/mesop/runtime/BUILD
index f98e3edc4..cccaf7ef0 100644
--- a/mesop/runtime/BUILD
+++ b/mesop/runtime/BUILD
@@ -16,5 +16,6 @@ py_library(
         "//mesop/security",
         "//mesop/server:state_sessions",
         "//mesop/utils",
+        "//mesop/warn",
     ] + THIRD_PARTY_PY_FLASK,
 )

From 3bcf1c2d414c3efac65bb7c8f3716cd1ce23ea93 Mon Sep 17 00:00:00 2001
From: Will Chen <willchen90@gmail.com>
Date: Sat, 19 Oct 2024 21:21:52 -0700
Subject: [PATCH 6/6] rename

---
 mesop/server/server.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mesop/server/server.py b/mesop/server/server.py
index 6f2cc5983..c5aff81fc 100644
--- a/mesop/server/server.py
+++ b/mesop/server/server.py
@@ -260,7 +260,7 @@ def teardown_clear_stale_state_sessions(error=None):
     socketio = SocketIO(flask_app)
 
     @socketio.on_error(namespace=UI_PATH)
-    def error_handler_chat(e):
+    def handle_error(e):
       print("WebSocket error", e)
       sid = request.sid  # type: ignore
       runtime().delete_context(sid)