Skip to content

Commit 3714bd5

Browse files
authored
Add proper concurrency support in websockets mode (#1036)
1 parent 660e1aa commit 3714bd5

17 files changed

+170
-34
lines changed

docs/api/config.md

+8
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ Allows concurrent updates to state in the same session. If this is not updated,
1212

1313
By default, this is not enabled. You can enable this by setting it to `true`.
1414

15+
### MESOP_WEB_SOCKETS_ENABLED
16+
17+
!!! warning "Experimental feature"
18+
19+
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.
20+
21+
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.
22+
1523
### MESOP_STATE_SESSION_BACKEND
1624

1725
Sets the backend to use for caching state data server-side. This makes it so state does

mesop/cli/cli.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
execute_module,
1212
get_module_name_from_runfile_path,
1313
)
14+
from mesop.env.env import MESOP_WEBSOCKETS_ENABLED
1415
from mesop.exceptions import format_traceback
1516
from mesop.runtime import (
1617
enable_debug_mode,
@@ -22,7 +23,6 @@
2223
from mesop.server.flags import port
2324
from mesop.server.logging import log_startup
2425
from mesop.server.server import configure_flask_app
25-
from mesop.server.server_utils import MESOP_WEBSOCKETS_ENABLED
2626
from mesop.server.static_file_serving import configure_static_file_serving
2727
from mesop.utils.host_util import get_public_host
2828
from mesop.utils.runfiles import get_runfile_location

mesop/env/BUILD

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
load("//build_defs:defaults.bzl", "py_library")
2+
3+
package(
4+
default_visibility = ["//build_defs:mesop_internal"],
5+
)
6+
7+
py_library(
8+
name = "env",
9+
srcs = glob(["*.py"]),
10+
)

mesop/env/__init__.py

Whitespace-only changes.

mesop/env/env.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import os
2+
3+
AI_SERVICE_BASE_URL = os.environ.get(
4+
"MESOP_AI_SERVICE_BASE_URL", "http://localhost:43234"
5+
)
6+
7+
MESOP_WEBSOCKETS_ENABLED = (
8+
os.environ.get("MESOP_WEBSOCKETS_ENABLED", "false").lower() == "true"
9+
)
10+
11+
MESOP_CONCURRENT_UPDATES_ENABLED = (
12+
os.environ.get("MESOP_CONCURRENT_UPDATES_ENABLED", "false").lower() == "true"
13+
)
14+
15+
if MESOP_WEBSOCKETS_ENABLED:
16+
print("Experiment enabled: MESOP_WEBSOCKETS_ENABLED")
17+
print("Auto-enabling MESOP_CONCURRENT_UPDATES_ENABLED")
18+
MESOP_CONCURRENT_UPDATES_ENABLED = True
19+
elif MESOP_CONCURRENT_UPDATES_ENABLED:
20+
print("Experiment enabled: MESOP_CONCURRENT_UPDATES_ENABLED")
21+
22+
EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED = (
23+
os.environ.get("MESOP_EXPERIMENTAL_EDITOR_TOOLBAR", "false").lower() == "true"
24+
)
25+
26+
if EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED:
27+
print("Experiment enabled: EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED")

mesop/examples/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
from mesop.examples import composite as composite
1313
from mesop.examples import concurrency_state as concurrency_state
1414
from mesop.examples import concurrent_updates as concurrent_updates
15+
from mesop.examples import (
16+
concurrent_updates_websockets as concurrent_updates_websockets,
17+
)
1518
from mesop.examples import custom_font as custom_font
1619
from mesop.examples import dict_state as dict_state
1720
from mesop.examples import docs as docs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import time
2+
3+
import mesop as me
4+
5+
6+
@me.page(path="/concurrent_updates_websockets")
7+
def page():
8+
state = me.state(State)
9+
me.text("concurrent_updates_websockets")
10+
me.button(label="Slow state update", on_click=slow_state_update)
11+
me.button(label="Fast state update", on_click=fast_state_update)
12+
me.text("Slow state: " + str(state.slow_state))
13+
me.text("Fast state: " + str(state.fast_state))
14+
if state.show_box:
15+
with me.box():
16+
me.text("Box!")
17+
18+
19+
@me.stateclass
20+
class State:
21+
show_box: bool
22+
slow_state: bool
23+
fast_state: bool
24+
25+
26+
def slow_state_update(e: me.ClickEvent):
27+
time.sleep(3)
28+
me.state(State).show_box = True
29+
me.state(State).slow_state = True
30+
yield
31+
32+
33+
def fast_state_update(e: me.ClickEvent):
34+
me.state(State).show_box = True
35+
me.state(State).fast_state = True

mesop/runtime/BUILD

+2
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ py_library(
99
srcs = glob(["*.py"]),
1010
deps = [
1111
"//mesop/dataclass_utils",
12+
"//mesop/env",
1213
"//mesop/events",
1314
"//mesop/exceptions",
1415
"//mesop/protos:ui_py_pb2",
1516
"//mesop/security",
1617
"//mesop/server:state_sessions",
1718
"//mesop/utils",
19+
"//mesop/warn",
1820
] + THIRD_PARTY_PY_FLASK,
1921
)

mesop/runtime/context.py

+18
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import copy
3+
import threading
34
import types
45
import urllib.parse as urlparse
56
from typing import Any, Callable, Generator, Sequence, TypeVar, cast
@@ -10,6 +11,7 @@
1011
serialize_dataclass,
1112
update_dataclass_from_json,
1213
)
14+
from mesop.env.env import MESOP_WEBSOCKETS_ENABLED
1315
from mesop.exceptions import (
1416
MesopDeveloperException,
1517
MesopException,
@@ -42,6 +44,22 @@ def __init__(
4244
self._theme_settings: pb.ThemeSettings | None = None
4345
self._js_modules: set[str] = set()
4446
self._query_params: dict[str, list[str]] = {}
47+
if MESOP_WEBSOCKETS_ENABLED:
48+
self._lock = threading.Lock()
49+
50+
def acquire_lock(self) -> None:
51+
# No-op if websockets is not enabled because
52+
# there shouldn't be concurrent updates to the same
53+
# context instance.
54+
if MESOP_WEBSOCKETS_ENABLED:
55+
self._lock.acquire()
56+
57+
def release_lock(self) -> None:
58+
# No-op if websockets is not enabled because
59+
# there shouldn't be concurrent updates to the same
60+
# context instance.
61+
if MESOP_WEBSOCKETS_ENABLED:
62+
self._lock.release()
4563

4664
def register_js_module(self, js_module_path: str) -> None:
4765
self._js_modules.add(js_module_path)

mesop/runtime/runtime.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22
from dataclasses import dataclass
33
from typing import Any, Callable, Generator, Type, TypeVar, cast
44

5-
from flask import g
5+
from flask import g, request
66

77
import mesop.protos.ui_pb2 as pb
8+
from mesop.env.env import MESOP_WEBSOCKETS_ENABLED
89
from mesop.events import LoadEvent, MesopEvent
910
from mesop.exceptions import MesopDeveloperException, MesopUserException
1011
from mesop.key import Key
1112
from mesop.security.security_policy import SecurityPolicy
1213
from mesop.utils.backoff import exponential_backoff
14+
from mesop.warn import warn
1315

1416
from .context import Context
1517

@@ -54,12 +56,25 @@ def __init__(self):
5456
self._state_classes: list[type[Any]] = []
5557
self._loading_errors: list[pb.ServerError] = []
5658
self._has_served_traffic = False
59+
self._contexts = {}
5760

5861
def context(self) -> Context:
62+
if MESOP_WEBSOCKETS_ENABLED and hasattr(request, "sid"):
63+
# flask-socketio adds sid (session id) to the request object.
64+
sid = request.sid # type: ignore
65+
if sid not in self._contexts:
66+
self._contexts[sid] = self.create_context()
67+
return self._contexts[sid]
5968
if "_mesop_context" not in g:
6069
g._mesop_context = self.create_context()
6170
return g._mesop_context
6271

72+
def delete_context(self, sid: str) -> None:
73+
if sid in self._contexts:
74+
del self._contexts[sid]
75+
else:
76+
warn(f"Tried to delete context with sid={sid} that doesn't exist.")
77+
6378
def create_context(self) -> Context:
6479
# If running in prod mode, *always* enable the has served traffic safety check.
6580
# If running in debug mode, *disable* the has served traffic safety check.

mesop/server/BUILD

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ py_library(
3333
deps = [
3434
"//mesop/component_helpers",
3535
"//mesop/editor",
36+
"//mesop/env",
3637
"//mesop/events",
3738
"//mesop/protos:ui_py_pb2",
3839
"//mesop/utils",

mesop/server/server.py

+19-3
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
66
import mesop.protos.ui_pb2 as pb
77
from mesop.component_helpers import diff_component
88
from mesop.editor.component_configs import get_component_configs
9+
from mesop.env.env import (
10+
EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED,
11+
MESOP_CONCURRENT_UPDATES_ENABLED,
12+
MESOP_WEBSOCKETS_ENABLED,
13+
)
914
from mesop.events import LoadEvent
1015
from mesop.exceptions import format_traceback
1116
from mesop.runtime import runtime
1217
from mesop.server.constants import WEB_COMPONENTS_PATH_SEGMENT
1318
from mesop.server.server_debug_routes import configure_debug_routes
1419
from mesop.server.server_utils import (
15-
EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED,
16-
MESOP_CONCURRENT_UPDATES_ENABLED,
17-
MESOP_WEBSOCKETS_ENABLED,
1820
STREAM_END,
1921
create_update_state_event,
2022
is_same_site,
@@ -38,6 +40,7 @@ def render_loop(
3840
init_request: bool = False,
3941
) -> Generator[str, None, None]:
4042
try:
43+
runtime().context().acquire_lock()
4144
runtime().run_path(path=path, trace_mode=trace_mode)
4245
page_config = runtime().get_page_config(path=path)
4346
title = page_config.title if page_config else "Unknown path"
@@ -88,6 +91,8 @@ def render_loop(
8891
yield from yield_errors(
8992
error=pb.ServerError(exception=str(e), traceback=format_traceback())
9093
)
94+
finally:
95+
runtime().context().release_lock()
9196

9297
def yield_errors(error: pb.ServerError) -> Generator[str, None, None]:
9398
if not runtime().debug_mode:
@@ -254,6 +259,17 @@ def teardown_clear_stale_state_sessions(error=None):
254259

255260
socketio = SocketIO(flask_app)
256261

262+
@socketio.on_error(namespace=UI_PATH)
263+
def handle_error(e):
264+
print("WebSocket error", e)
265+
sid = request.sid # type: ignore
266+
runtime().delete_context(sid)
267+
268+
@socketio.on("disconnect", namespace=UI_PATH)
269+
def handle_disconnect():
270+
sid = request.sid # type: ignore
271+
runtime().delete_context(sid)
272+
257273
@socketio.on("message", namespace=UI_PATH)
258274
def handle_message(message):
259275
if not message:

mesop/server/server_debug_routes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
from flask import Flask, Response, request
88

9+
from mesop.env.env import AI_SERVICE_BASE_URL
910
from mesop.runtime import runtime
1011
from mesop.server.server_utils import (
11-
AI_SERVICE_BASE_URL,
1212
check_editor_access,
1313
make_sse_response,
1414
sse_request,

mesop/server/server_utils.py

+1-27
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import base64
22
import json
3-
import os
43
import secrets
54
import urllib.parse as urlparse
65
from typing import Any, Generator, Iterable
@@ -9,35 +8,10 @@
98
from flask import Response, abort, request
109

1110
import mesop.protos.ui_pb2 as pb
11+
from mesop.env.env import EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED
1212
from mesop.runtime import runtime
1313
from mesop.server.config import app_config
1414

15-
AI_SERVICE_BASE_URL = os.environ.get(
16-
"MESOP_AI_SERVICE_BASE_URL", "http://localhost:43234"
17-
)
18-
19-
MESOP_WEBSOCKETS_ENABLED = (
20-
os.environ.get("MESOP_WEBSOCKETS_ENABLED", "false").lower() == "true"
21-
)
22-
23-
MESOP_CONCURRENT_UPDATES_ENABLED = (
24-
os.environ.get("MESOP_CONCURRENT_UPDATES_ENABLED", "false").lower() == "true"
25-
)
26-
27-
if MESOP_WEBSOCKETS_ENABLED:
28-
print("Experiment enabled: MESOP_WEBSOCKETS_ENABLED")
29-
print("Auto-enabling MESOP_CONCURRENT_UPDATES_ENABLED")
30-
MESOP_CONCURRENT_UPDATES_ENABLED = True
31-
elif MESOP_CONCURRENT_UPDATES_ENABLED:
32-
print("Experiment enabled: MESOP_CONCURRENT_UPDATES_ENABLED")
33-
34-
EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED = (
35-
os.environ.get("MESOP_EXPERIMENTAL_EDITOR_TOOLBAR", "false").lower() == "true"
36-
)
37-
38-
if EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED:
39-
print("Experiment enabled: EXPERIMENTAL_EDITOR_TOOLBAR_ENABLED")
40-
4115
LOCALHOSTS = (
4216
# For IPv4 localhost
4317
"127.0.0.1",

mesop/server/wsgi_app.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
from absl import flags
55
from flask import Flask
66

7+
from mesop.env.env import MESOP_WEBSOCKETS_ENABLED
78
from mesop.runtime import enable_debug_mode
89
from mesop.server.constants import EDITOR_PACKAGE_PATH, PROD_PACKAGE_PATH
910
from mesop.server.flags import port
1011
from mesop.server.logging import log_startup
1112
from mesop.server.server import configure_flask_app
12-
from mesop.server.server_utils import MESOP_WEBSOCKETS_ENABLED
1313
from mesop.server.static_file_serving import configure_static_file_serving
1414
from mesop.utils.host_util import get_local_host
1515

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {testInWebSocketsEnabledOnly} from './e2e_helpers';
2+
import {expect} from '@playwright/test';
3+
4+
testInWebSocketsEnabledOnly(
5+
'concurrent updates (websockets)',
6+
async ({page}) => {
7+
await page.goto('/concurrent_updates_websockets');
8+
await page.getByRole('button', {name: 'Slow state update'}).click();
9+
await page.getByRole('button', {name: 'Fast state update'}).click();
10+
await expect(page.getByText('Fast state: true')).toBeVisible();
11+
expect(await page.locator('text="Box!"').count()).toBe(1);
12+
await expect(page.getByText('Slow state: false')).toBeVisible();
13+
await expect(page.getByText('Slow state: true')).toBeVisible();
14+
// Make sure there isn't a second Box from the concurrent update.
15+
expect(await page.locator('text="Box!"').count()).toBe(1);
16+
},
17+
);

mesop/tests/e2e/e2e_helpers.ts

+10
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,13 @@ export const testInConcurrentUpdatesEnabledOnly = base.extend({
1919
await use(page);
2020
},
2121
});
22+
23+
export const testInWebSocketsEnabledOnly = base.extend({
24+
// Skip this test if MESOP_WEBSOCKETS_ENABLED is not 'true'
25+
page: async ({page}, use) => {
26+
if (process.env.MESOP_WEBSOCKETS_ENABLED !== 'true') {
27+
base.skip(true, 'Skipping test in websockets disabled mode');
28+
}
29+
await use(page);
30+
},
31+
});

0 commit comments

Comments
 (0)