Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a189649
just use debug log when client does not connect in time
rodja Sep 8, 2025
d0f1412
just use debug log when page response is not ready in time
rodja Sep 8, 2025
799f715
use polling if we are prerendering
rodja Sep 11, 2025
c37384e
increase js timeout to support polling websocket
rodja Sep 11, 2025
9768a33
allow super late responses (as was normal in 2.x)
rodja Sep 11, 2025
067dc75
update run_javascript docstring
rodja Sep 11, 2025
9bc60a4
refactor client connect/disconnect events and flags
rodja Sep 12, 2025
c9819d4
await connected before awaiting javascript
rodja Sep 12, 2025
af0daa0
first set of tests
rodja Sep 12, 2025
89c14bb
improve tests for prerendering
rodja Sep 12, 2025
ead1811
fix run_javascript documentation
rodja Sep 12, 2025
774e7fd
choose between prerender and prefetch in test helper
rodja Sep 13, 2025
001f4bf
keep prefetched page warm as long as possible
rodja Sep 14, 2025
0c314e4
better exception handling for @ui.page
rodja Sep 14, 2025
f933ead
delete client when page builder task is canceled
rodja Sep 14, 2025
1704e4d
log unknown exceptions when checking late return values from page bui…
rodja Sep 14, 2025
8e8b5fb
set better response_timeout in APIRouter
rodja Sep 14, 2025
4bb46f8
docs and logging
rodja Sep 14, 2025
60cf46e
show warning if building page takes too long
rodja Sep 14, 2025
1f113bf
properly handle client timeout exceptions
rodja Sep 14, 2025
1b97ee5
improve longbuild prerender test
rodja Sep 14, 2025
2ed5e18
derive CleintConnectionTimeout from TimeoutError
rodja Sep 14, 2025
2939e4f
better docstring
rodja Sep 15, 2025
4cee5a1
ensure we do not run timers in script mode evaluation
rodja Sep 15, 2025
9e15343
use background_tasks.create with new flag
rodja Sep 15, 2025
7111a08
Merge commit '9e15343ae33139d27604b7706c8c29c945d4f2bc' into page-exc…
rodja Sep 15, 2025
6a7f318
let exceptions be handled outside wait_for_result
rodja Sep 15, 2025
bfe03da
allow BaseException for create_error_page
rodja Sep 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions nicegui/api_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def page(self,
viewport: Optional[str] = None,
favicon: Optional[Union[str, Path]] = None,
dark: Optional[bool] = ..., # type: ignore
response_timeout: float = 3.0,
response_timeout: float = 20.0,
**kwargs,
) -> Callable:
"""Page
Expand All @@ -28,7 +28,7 @@ def page(self,
:param viewport: optional viewport meta tag content
:param favicon: optional relative filepath or absolute URL to a favicon (default: `None`, NiceGUI icon will be used)
:param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
:param response_timeout: maximum time for the decorated function to build the page (default: 3.0)
:param response_timeout: maximum time for the decorated function to build the page (default: 20.0)
:param kwargs: additional keyword arguments passed to FastAPI's @app.get method
"""
return ui_page(
Expand Down
2 changes: 1 addition & 1 deletion nicegui/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def __init__(self, **kwargs) -> None:
self._shutdown_handlers: list[Union[Callable[..., Any], Awaitable]] = []
self._connect_handlers: list[Union[Callable[..., Any], Awaitable]] = []
self._disconnect_handlers: list[Union[Callable[..., Any], Awaitable]] = []
self._exception_handlers: list[Callable[..., Any]] = [log.exception]
self._exception_handlers: list[Callable[..., Any]] = [lambda e: log.exception('observed exception', exc_info=e)]
self._page_exception_handler: Optional[Callable[..., Any]] = None

@property
Expand Down
12 changes: 8 additions & 4 deletions nicegui/background_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,21 @@
functions_awaited_on_shutdown: weakref.WeakSet[Callable] = weakref.WeakSet()


def create(coroutine: Awaitable, *, name: str = 'unnamed task') -> asyncio.Task:
def create(coroutine: Awaitable, *, name: str = 'unnamed task', handle_exceptions: bool = True) -> asyncio.Task:
"""Wraps a loop.create_task call and ensures there is an exception handler added to the task.

If the task raises an exception, it is logged and handled by the global exception handlers.
Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.

:param coroutine: the coroutine or awaitable to wrap
:param name: the name of the task which is helpful for debugging (default: 'unnamed task')
:param handle_exceptions: if True (default) possible exceptions are forwarded to the global exception handlers
"""
assert core.loop is not None
coroutine = coroutine if asyncio.iscoroutine(coroutine) else asyncio.wait_for(coroutine, None)
task: asyncio.Task = core.loop.create_task(coroutine, name=name)
task.add_done_callback(_handle_task_result)
if handle_exceptions:
task.add_done_callback(_handle_exceptions)
running_tasks.add(task)
task.add_done_callback(running_tasks.discard)
return task
Expand Down Expand Up @@ -73,7 +77,7 @@ async def wrapper() -> Any:
return wrapper()


def _handle_task_result(task: asyncio.Task) -> None:
def _handle_exceptions(task: asyncio.Task) -> None:
try:
task.result()
except asyncio.CancelledError:
Expand Down
91 changes: 69 additions & 22 deletions nicegui/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@
})


class ClientConnectionTimeout(TimeoutError):
def __init__(self, client: Client) -> None:
super().__init__(f'ClientConnectionTimeout: {client.id}')
self.client = client


class Client:
page_routes: ClassVar[dict[Callable[..., Any], str]] = {}
'''Maps page builders to their routes.'''
Expand All @@ -63,8 +69,9 @@ def __init__(self, page: page, *, request: Request | None = None) -> None:
self.elements: dict[int, Element] = {}
self.next_element_id: int = 0
self._waiting_for_connection: asyncio.Event = asyncio.Event()
self.is_waiting_for_connection: bool = False
self.is_waiting_for_disconnect: bool = False
self._waiting_for_disconnect: asyncio.Event = asyncio.Event()
self._is_connected: asyncio.Event = asyncio.Event()
self._deleted_event: asyncio.Event = asyncio.Event()
self.environ: dict[str, Any] | None = None
self.on_air = False
self._num_connections: defaultdict[str, int] = defaultdict(int)
Expand Down Expand Up @@ -112,11 +119,44 @@ def ip(self) -> str:
"""
return self.request.client.host if self.request.client is not None else ''

@property
def is_speculation_prerender(self) -> bool:
"""Whether this request was initiated by a prerender (Speculation Rules)."""
return 'prerender' in self._sec_purpose

@property
def is_speculation_prefetch(self) -> bool:
"""Whether this request was initiated by a prefetch (Speculation Rules).

We consider it prefetch when the header contains 'prefetch' and does not contain 'prerender'.
"""
sp = self._sec_purpose
return 'prefetch' in sp and 'prerender' not in sp

@property
def _sec_purpose(self) -> str:
"""Return the Sec-Purpose or Purpose header value in lower case."""
try:
header = self.request.headers.get('Sec-Purpose') or self.request.headers.get('Purpose') or ''
return header.lower()
except Exception:
return ''

@property
def has_socket_connection(self) -> bool:
"""Whether the client is connected."""
return self.tab_id is not None

@property
def is_waiting_for_connection(self) -> bool:
"""Whether the client is currently waiting for a connection."""
return self._waiting_for_connection.is_set()

@property
def is_waiting_for_disconnect(self) -> bool:
"""Whether the client is currently waiting for a disconnect."""
return self._waiting_for_disconnect.is_set()

@property
def head_html(self) -> str:
"""The HTML code to be inserted in the <head> of the page template."""
Expand Down Expand Up @@ -184,37 +224,38 @@ def resolve_title(self) -> str:
"""Return the title of the page."""
return self.page.resolve_title() if self.title is None else self.title

async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
async def connected(self, timeout: float = 3.0) -> None:
"""Block execution until the client is connected."""
self.is_waiting_for_connection = True
self._waiting_for_connection.set()
deadline = time.time() + timeout
while not self.has_socket_connection:
if time.time() > deadline:
raise TimeoutError(f'No connection after {timeout} seconds')
await asyncio.sleep(check_interval)
self.is_waiting_for_connection = False

async def disconnected(self, check_interval: float = 0.1) -> None:
if not self.has_socket_connection:
self._waiting_for_connection.set()
self._is_connected.clear()
timeout = max(timeout, self.page.response_timeout) if self.is_speculation_prefetch else timeout
try:
await asyncio.wait_for(self._is_connected.wait(), timeout=timeout)
except asyncio.TimeoutError as e:
raise ClientConnectionTimeout(self) from e

async def disconnected(self) -> None:
"""Block execution until the client disconnects."""
if not self.has_socket_connection:
await self.connected()
self.is_waiting_for_disconnect = True
while self.id in self.instances:
await asyncio.sleep(check_interval)
self.is_waiting_for_disconnect = False
if self.id in self.instances:
self._waiting_for_disconnect.set()
self._deleted_event.clear()
await self._deleted_event.wait()

def run_javascript(self, code: str, *, timeout: float = 1.0) -> AwaitableResponse:
def run_javascript(self, code: str, *, timeout: float = 3.0) -> AwaitableResponse:
"""Execute JavaScript on the client.

The client connection must be established before this method is called.
You can do this by `await client.connected()` or register a callback with `client.on_connect(...)`.

If the function is awaited, the result of the JavaScript code is returned.
Otherwise, the JavaScript code is executed without waiting for a response.

Obviously the javascript code is only executed after the client is connected.
Internally, `await ui.context.client.connected(timeout=timeout)` is called before the JavaScript code is executed.
This might delay the execution of the JavaScript code and is not covered by the `timeout` parameter.

:param code: JavaScript code to run
:param timeout: timeout in seconds (default: `1.0`)
:param timeout: timeout in seconds (default: `3.0`)

:return: AwaitableResponse that can be awaited to get the result of the JavaScript code
"""
Expand All @@ -226,6 +267,7 @@ def send_and_forget():

async def send_and_wait():
self.outbox.enqueue_message('run_javascript', {'code': code, 'request_id': request_id}, target_id)
await self.connected(timeout=timeout)
return await JavaScriptRequest(request_id, timeout=timeout)

return AwaitableResponse(send_and_forget, send_and_wait)
Expand All @@ -249,6 +291,8 @@ def on_disconnect(self, handler: Callable[..., Any] | Awaitable) -> None:

def handle_handshake(self, socket_id: str, document_id: str, next_message_id: int | None) -> None:
"""Cancel pending disconnect task and invoke connect handlers."""
self._waiting_for_connection.clear()
self._is_connected.set()
self._socket_to_document_id[socket_id] = document_id
self._cancel_delete_task(document_id)
self._num_connections[document_id] += 1
Expand Down Expand Up @@ -345,6 +389,8 @@ def delete(self) -> None:
If the global clients dictionary does not contain the client, its elements are still removed and a KeyError is raised.
Normally this should never happen, but has been observed (see #1826).
"""
self._waiting_for_disconnect.clear()
self._deleted_event.set()
self.remove_all_elements()
self.outbox.stop()
del Client.instances[self.id]
Expand All @@ -368,6 +414,7 @@ def prune_instances(cls, *, client_age_threshold: float = 60.0) -> None:
if not client.has_socket_connection and client.created <= time.time() - client_age_threshold
]
for client in stale_clients:
log.debug(f'Pruning stale client {client.id}')
client.delete()

except Exception:
Expand Down
8 changes: 4 additions & 4 deletions nicegui/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,27 +403,27 @@ def update(self) -> None:
return
self.client.outbox.enqueue_update(self)

def run_method(self, name: str, *args: Any, timeout: float = 1) -> AwaitableResponse:
def run_method(self, name: str, *args: Any, timeout: float = 3.0) -> AwaitableResponse:
"""Run a method on the client side.

If the function is awaited, the result of the method call is returned.
Otherwise, the method is executed without waiting for a response.

:param name: name of the method
:param args: arguments to pass to the method
:param timeout: maximum time to wait for a response (default: 1 second)
:param timeout: maximum time to wait for a response (default: 3 seconds)
"""
if not core.loop:
return NullResponse()
return self.client.run_javascript(f'return runMethod({self.id}, "{name}", {json.dumps(args)})', timeout=timeout)

def get_computed_prop(self, prop_name: str, *, timeout: float = 1) -> AwaitableResponse:
def get_computed_prop(self, prop_name: str, *, timeout: float = 3.0) -> AwaitableResponse:
"""Return a computed property.

This function should be awaited so that the computed property is properly returned.

:param prop_name: name of the computed prop
:param timeout: maximum time to wait for a response (default: 1 second)
:param timeout: maximum time to wait for a response (default: 3 seconds)
"""
if not core.loop:
return NullResponse()
Expand Down
4 changes: 2 additions & 2 deletions nicegui/elements/aggrid/aggrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ async def get_selected_row(self) -> Optional[dict]:
async def get_client_data(
self,
*,
timeout: float = 1,
timeout: float = 3.0,
method: Literal['all_unsorted', 'filtered_unsorted', 'filtered_sorted', 'leaf'] = 'all_unsorted'
) -> list[dict]:
"""Get the data from the client including any edits made by the client.
Expand All @@ -232,7 +232,7 @@ async def get_client_data(
Note that when editing a cell, the row data is not updated until the cell exits the edit mode.
This does not happen when the cell loses focus, unless ``stopEditingWhenCellsLoseFocus: True`` is set.

:param timeout: timeout in seconds (default: 1 second)
:param timeout: timeout in seconds (default: 3 seconds)
:param method: method to access the data, "all_unsorted" (default), "filtered_unsorted", "filtered_sorted", "leaf"

:return: list of row data
Expand Down
11 changes: 5 additions & 6 deletions nicegui/elements/timer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from contextlib import AbstractContextManager, nullcontext

from ..client import Client
from ..client import Client, ClientConnectionTimeout
from ..element import Element
from ..logging import log
from ..timer import Timer as BaseTimer
Expand All @@ -17,13 +17,12 @@ async def _can_start(self) -> bool:
See https://github.com/zauberzeug/nicegui/issues/206 for details.
Returns True if the client is connected, False if the client is not connected and the timer should be cancelled.
"""
# ignore served pages which do not reconnect to backend (e.g. monitoring requests, scrapers etc.)
TIMEOUT = 60.0
try:
await self.client.connected(timeout=TIMEOUT)
await self.client.connected()
return True
except TimeoutError:
log.error(f'Timer cancelled because client is not connected after {TIMEOUT} seconds')
except ClientConnectionTimeout:
self.cancel()
log.debug('Timer cancelled because client connection timed out')
return False

def _should_stop(self) -> bool:
Expand Down
7 changes: 4 additions & 3 deletions nicegui/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from . import background_tasks, core, helpers
from .awaitable_response import AwaitableResponse
from .client import Client
from .client import Client, ClientConnectionTimeout
from .context import context
from .dataclasses import KWONLY_SLOTS
from .logging import log
Expand Down Expand Up @@ -88,8 +88,9 @@ async def register_disconnect() -> None:
try:
await client.connected(timeout=10.0)
client.on_disconnect(lambda: self.unsubscribe(callback))
except TimeoutError:
log.warning('Could not register a disconnect handler for callback %s', callback)
except ClientConnectionTimeout:
log.debug('Could not register a disconnect handler for callback %s', callback)
self.unsubscribe(callback)
if core.loop and core.loop.is_running():
background_tasks.create(register_disconnect())
else:
Expand Down
9 changes: 6 additions & 3 deletions nicegui/functions/javascript.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@
from ..context import context


def run_javascript(code: str, *, timeout: float = 1.0) -> AwaitableResponse:
def run_javascript(code: str, *, timeout: float = 3.0) -> AwaitableResponse:
"""Run JavaScript

This function runs arbitrary JavaScript code on a page that is executed in the browser.
The client must be connected before this function is called.
To access a client-side Vue component or HTML element by ID,
use the JavaScript functions `getElement()` or `getHtmlElement()` (*added in version 2.9.0*).

If the function is awaited, the result of the JavaScript code is returned.
Otherwise, the JavaScript code is executed without waiting for a response.

Obviously the javascript code is only executed after the client is connected.
Internally, `await ui.context.client.connected(timeout=timeout)` is called before the JavaScript code is executed.
This might delay the execution of the JavaScript code and is not covered by the `timeout` parameter.

:param code: JavaScript code to run
:param timeout: timeout in seconds (default: `1.0`)
:param timeout: timeout in seconds (default: `3.0`)

:return: AwaitableResponse that can be awaited to get the result of the JavaScript code
"""
Expand Down
Loading
Loading