From af3d995025e76a5b0032f5e95172d46cb9daf60e Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 12 Nov 2026 10:10:25 -0600 Subject: [PATCH 1/7] fix: notify the user when a widget's comm open fails after render (#320) --- shinywidgets/_shinywidgets.py | 127 ++++++++++++++++++------------ tests/unit/test_widget_display.py | 51 +++++++++++- 2 files changed, 126 insertions(+), 52 deletions(-) diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py index df93a491a..19c7c1fda 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -43,6 +43,7 @@ get_current_context, # pyright: ignore[reportPrivateImportUsage] ) from shiny.session import get_current_session, require_active_session, session_context +from shiny.types import NotifyException from ._accessors import rate_policy_for from ._as_widget import as_widget, pydeck_has_widget @@ -250,59 +251,85 @@ def _open_shiny_comm(): if w is None or WIDGET_INSTANCE_MAP.get(id) is not w: return - if is_ipyreact_registry_widget(w): - fan_out_ipyreact_registry(w) - return + try: + if is_ipyreact_registry_widget(w): + fan_out_ipyreact_registry(w) + return - # Already opened in another session by open_shared_in(); a plain - # ShinyComm here would cut that session off. - if isinstance(w.comm, SharedShinyComm): - open_shared_in(w, session) - return + # Already opened in another session by open_shared_in(); a plain + # ShinyComm here would cut that session off. + if isinstance(w.comm, SharedShinyComm): + open_shared_in(w, session) + return + + # A shared widget's lazily created defaults (e.g. its Layout) are shared too + with ( + shared_construction() + if shared + else WidgetRenderContext.resumed(render_ctx) + ): + prepare_to_serialize(w) + # Serve geolibre's bundled app from the Shiny app and switch the + # widget to the mode that probes that route. This must happen here + # (not in init_shiny_widget()): that hook runs inside + # Widget.__init__(), and geolibre's Map.__init__() sets + # _remote_mode after super().__init__() returns, overwriting it. + if any(widget_pkg(cls) == "geolibre" for cls in type(w).__mro__): + geolibre_app_mount(w, session) + # Mount a guarded loopback proxy for localtileserver-backed + # ipyleaflet layers and route their tile URLs through it (no-op + # for other widgets). Must run before get_state() so the browser + # only ever sees the rewritten (same-origin) URL. + localtileserver_layer_proxy(w, session) + state, buffer_paths, buffers = _remove_buffers(w.get_state()) + + # Covers widgets that are opened but never displayed (so no render-time + # walk opens their references); the browser would otherwise wait for a + # model it never gets. Foreign references are left for the render to reject. + for r in direct_widget_references(w): + if is_process_global(r): + open_shared_in(r, session) - # A shared widget's lazily created defaults (e.g. its Layout) are shared too - with ( - shared_construction() if shared else WidgetRenderContext.resumed(render_ctx) - ): - prepare_to_serialize(w) - # Serve geolibre's bundled app from the Shiny app and switch the - # widget to the mode that probes that route. This must happen here - # (not in init_shiny_widget()): that hook runs inside - # Widget.__init__(), and geolibre's Map.__init__() sets - # _remote_mode after super().__init__() returns, overwriting it. - if any(widget_pkg(cls) == "geolibre" for cls in type(w).__mro__): - geolibre_app_mount(w, session) - # Mount a guarded loopback proxy for localtileserver-backed - # ipyleaflet layers and route their tile URLs through it (no-op - # for other widgets). Must run before get_state() so the browser - # only ever sees the rewritten (same-origin) URL. - localtileserver_layer_proxy(w, session) - state, buffer_paths, buffers = _remove_buffers(w.get_state()) - - # Covers widgets that are opened but never displayed (so no render-time - # walk opens their references); the browser would otherwise wait for a - # model it never gets. Foreign references are left for the render to reject. - for r in direct_widget_references(w): - if is_process_global(r): - open_shared_in(r, session) - - # Initialize the comm -- this sends widget state to the frontend - placeholder = w.comm - w.comm = ShinyComm( - comm_id=id, - comm_manager=COMM_MANAGERS[session.id], - session=session, - target_name="jupyter.widget", - data={"state": state, "buffer_paths": buffer_paths}, - buffers=cast(BufferType, buffers), - # TODO: should this be hard-coded? - metadata={"version": __protocol_version__}, - html_deps=session._process_ui(TagList(*widget_deps))["deps"], - **open_keys, - ) + # Initialize the comm -- this sends widget state to the frontend + placeholder = w.comm + w.comm = ShinyComm( + comm_id=id, + comm_manager=COMM_MANAGERS[session.id], + session=session, + target_name="jupyter.widget", + data={"state": state, "buffer_paths": buffer_paths}, + buffers=cast(BufferType, buffers), + # TODO: should this be hard-coded? + metadata={"version": __protocol_version__}, + html_deps=session._process_ui(TagList(*widget_deps))["deps"], + **open_keys, + ) - flush_ipycanvas_manager(w) - flush_queued_sends(placeholder, w.comm) + flush_ipycanvas_manager(w) + flush_queued_sends(placeholder, w.comm) + except Exception as e: + # The render already returned, so Shiny's render-error display + # never sees this failure; unhandled, the output stays blank + # forever (and on older Shiny the error would end the session). + # NotifyException makes Shiny print the traceback and show a + # sticky error notification while the session survives -- it + # fires even though this effect destroyed itself above. + # + # A process-global widget belongs to every session, so one + # session's failure must not close it. A session-owned one is + # closed, tearing down its half-initialized state the same way + # render invalidation would. + if not is_process_global(w): + try: + close_widget(w) + except Exception: + logger.exception( + "Failed to close widget after comm-open failure" + ) + raise NotifyException( + f"Failed to display {type(w).__name__}: {e}", + sanitize=session.app.sanitize_errors, + ) from e # If the widget initialized in a reactive _output_ context, then cleanup the widget # when the context gets invalidated. Use the render output's Context (captured by diff --git a/tests/unit/test_widget_display.py b/tests/unit/test_widget_display.py index b5157f162..07fa27617 100644 --- a/tests/unit/test_widget_display.py +++ b/tests/unit/test_widget_display.py @@ -2,15 +2,16 @@ import asyncio import json +from typing import Any, Dict, List import pytest -from ipywidgets import Widget +from ipywidgets import IntSlider, Widget from plotly.graph_objects import FigureWidget, Scatter from shiny import Session, reactive from shiny.session import session_context from shiny.session._session import AppSession from shinywidgets._comm import ShinyComm -from shinywidgets._shinywidgets import SESSION_WIDGET_ID_MAP +from shinywidgets._shinywidgets import SESSION_WIDGET_ID_MAP, is_closed_widget def test_open_does_not_create_display_previews(widget_session: Session) -> None: @@ -71,3 +72,49 @@ def _repr_mimebundle_(self, **kwargs: object) -> dict[str, object]: class CustomFigureWidget(FigureWidget): pass + + +SANITIZED_MSG = "An error has occurred. Check your logs or contact the app author for clarification." + + +class BrokenStateSlider(IntSlider): + def get_state(self, *args: Any, **kwargs: Any) -> Any: + raise RuntimeError("boom during get_state") + + +def test_comm_open_failure_notifies_user_and_closes_widget( + widget_session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + sent: List[Dict[str, Any]] = [] + monkeypatch.setattr(widget_session, "_send_message_sync", sent.append) + + async def unhandled(e: Exception) -> None: + raise AssertionError(f"session must survive a comm-open failure: {e}") + + monkeypatch.setattr(widget_session, "_unhandled_error", unhandled) + + with session_context(widget_session): + w = BrokenStateSlider() + asyncio.get_event_loop().run_until_complete(reactive.flush()) + + [note] = [m for m in sent if "notification" in m] + payload = note["notification"]["message"] + assert payload["type"] == "error" + assert payload["duration"] is None + assert "boom during get_state" in payload["html"] + assert is_closed_widget(w) + + +def test_comm_open_failure_notification_is_sanitized_with_app_flag( + widget_session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + widget_session.app.sanitize_errors = True + sent: List[Dict[str, Any]] = [] + monkeypatch.setattr(widget_session, "_send_message_sync", sent.append) + + with session_context(widget_session): + _ = BrokenStateSlider() + asyncio.get_event_loop().run_until_complete(reactive.flush()) + + [note] = [m for m in sent if "notification" in m] + assert note["notification"]["message"]["html"] == SANITIZED_MSG From 2cb16dcf02f89593200e0f71444ef2635d213ba6 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 12 Nov 2026 10:11:02 -0600 Subject: [PATCH 2/7] fix: notify the user when a widget callback fails on a browser message (#320) --- shinywidgets/_comm.py | 47 ++++++++++++++++- shinywidgets/_shinywidgets.py | 4 +- tests/unit/test_callback_errors.py | 85 +++++++++++++++++++++++++++++- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/shinywidgets/_comm.py b/shinywidgets/_comm.py index 4cf7de61b..e8433da97 100644 --- a/shinywidgets/_comm.py +++ b/shinywidgets/_comm.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Callable, Dict, List, Optional -from shiny import Session +from shiny import Session, ui if TYPE_CHECKING: from ipywidgets import Widget @@ -77,6 +77,8 @@ def __init__( self.comm_manager = comm_manager self.target_name = target_name self.session = session + # Error messages already shown to the user (see notify_client_msg_error) + self._notified_errors: set[str] = set() self.open(data=data, metadata=metadata, buffers=buffers, **keys) @classmethod @@ -96,6 +98,7 @@ def from_client( comm.comm_manager = comm_manager comm.target_name = target_name comm.session = session + comm._notified_errors = set() comm._closed = False comm.awaiting_owner = True comm_manager.register_comm(comm) @@ -242,6 +245,7 @@ def dispatch_client_msg( """ self._parent_header = msg.get("header") scope = ClientMsgScope() + scope.comm = self token = handling_client_msg.set(scope) traitlets_level_patch.patch() try: @@ -501,6 +505,10 @@ class ClientMsgScope: """ active = True + # The comm whose message is being dispatched: how a rerouted callback + # error finds the session (and per-widget dedup set) for its user-facing + # notification (see notify_client_msg_error). + comm: Optional["ShinyComm"] = None handling_client_msg: ContextVar[Optional[ClientMsgScope]] = ContextVar( @@ -551,6 +559,37 @@ def restore(self) -> None: traitlets_level_patch = TraitletsLevelPatch() +# Cap per comm so a callback that fails with ever-changing messages can't +# grow the dedup set (or flood the user with notifications) without bound. +MAX_ERROR_NOTIFICATIONS_PER_COMM = 50 + + +def notify_client_msg_error(comm: ShinyComm, record: logging.LogRecord) -> None: + """ + Show a sticky browser notification for a widget-callback error that + ipywidgets swallowed (see ClientMsgErrorFilter): the error already + reached the server log, but if the failing callback is part of how the + widget renders, the user's output is otherwise blank with no + explanation. Each unique message is shown once per widget; repeats only + go to the server log. + """ + if record.exc_info is not None and record.exc_info[1] is not None: + msg = str(record.exc_info[1]) + else: + msg = record.getMessage() + notified = comm._notified_errors + if msg in notified or len(notified) >= MAX_ERROR_NOTIFICATIONS_PER_COMM: + return + notified.add(msg) + session = comm.session + try: + if session.app.sanitize_errors: + msg = session.app.sanitize_error_msg + ui.notification_show(msg, type="error", duration=None, session=session) + except Exception: + logger.exception("Failed to show widget callback error in the browser") + + class ClientMsgErrorFilter(logging.Filter): """ Outside IPython, ipywidgets catches exceptions raised by widget callbacks @@ -560,6 +599,10 @@ class ClientMsgErrorFilter(logging.Filter): While handling a browser message, move such records to the `shinywidgets` logger (dropping the original, so a configured root handler doesn't show them twice). Everywhere else, e.g. in Jupyter, `traitlets` logging is untouched. + + A rerouted record is also shown to the user as a sticky error + notification (deduplicated per widget, see notify_client_msg_error): the + failing callback often leaves their output blank with no explanation. """ def filter(self, record: logging.LogRecord) -> bool: @@ -567,6 +610,8 @@ def filter(self, record: logging.LogRecord) -> bool: if scope is None or not scope.active or record.levelno < logging.WARNING: return True logger.error(record.getMessage(), exc_info=record.exc_info) + if scope.comm is not None: + notify_client_msg_error(scope.comm, record) return False diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py index 19c7c1fda..6d9dfb5ee 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -323,9 +323,7 @@ def _open_shiny_comm(): try: close_widget(w) except Exception: - logger.exception( - "Failed to close widget after comm-open failure" - ) + logger.exception("Failed to close widget after comm-open failure") raise NotifyException( f"Failed to display {type(w).__name__}: {e}", sanitize=session.app.sanitize_errors, diff --git a/tests/unit/test_callback_errors.py b/tests/unit/test_callback_errors.py index 2cfc773bb..54f9c5d2d 100644 --- a/tests/unit/test_callback_errors.py +++ b/tests/unit/test_callback_errors.py @@ -2,7 +2,9 @@ Exceptions raised by widget callbacks while handling a browser message are caught by ipywidgets and logged to the `traitlets` logger, whose only handler is a NullHandler, so outside of IPython they would vanish. shinywidgets re-routes -them to its own logger so they show up in the server's console. +them to its own logger so they show up in the server's console, and shows the +user a sticky error notification (deduplicated per widget), since the error +usually means their output is blank. """ from __future__ import annotations @@ -208,3 +210,84 @@ def schedule(change: object) -> None: [record] = caplog.records assert record.name == "traitlets" assert record.levelno == logging.WARNING + + +def capture_notifications( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> List[Dict[str, Any]]: + sent: List[Dict[str, Any]] = [] + monkeypatch.setattr(session, "_send_message_sync", sent.append) + return sent + + +def failing_slider() -> ShinyComm: + def boom(change: object) -> None: + raise RuntimeError("observer failed") + + slider = IntSlider(value=1) + slider.observe(boom, names="value") + comm = open_comm(slider) + comm.handle_msg( + client_msg( + comm.comm_id, + {"method": "update", "state": {"value": 5}, "buffer_paths": []}, + ) + ) + return comm + + +def test_observer_error_notifies_browser( + widget_session: Session, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + sent = capture_notifications(widget_session, monkeypatch) + with session_context(widget_session), caplog.at_level(logging.WARNING): + failing_slider() + + # Still logged server-side (the #304 behavior) ... + [record] = caplog.records + assert record.name == "shinywidgets" + # ... and now also shown to the user as a sticky error notification. + [note] = [m for m in sent if "notification" in m] + payload = note["notification"]["message"] + assert payload["type"] == "error" + assert payload["duration"] is None + assert "observer failed" in payload["html"] + + +def test_observer_error_notifies_once_per_unique_message( + widget_session: Session, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + sent = capture_notifications(widget_session, monkeypatch) + with session_context(widget_session), caplog.at_level(logging.WARNING): + comm = failing_slider() + comm.handle_msg( + client_msg( + comm.comm_id, + {"method": "update", "state": {"value": 6}, "buffer_paths": []}, + ) + ) + + # Both failures are logged ... + assert len(caplog.records) == 2 + # ... but the user is notified only once. + assert len([m for m in sent if "notification" in m]) == 1 + + +def test_observer_error_notification_sanitized_when_app_sanitizes( + widget_session: Session, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + widget_session.app.sanitize_errors = True + sent = capture_notifications(widget_session, monkeypatch) + with session_context(widget_session), caplog.at_level(logging.WARNING): + failing_slider() + + [note] = [m for m in sent if "notification" in m] + assert note["notification"]["message"]["html"] == ( + "An error has occurred. Check your logs or contact the app author for clarification." + ) From 043d686ad4180d543f2d5fdbedac2e9d09a7c36e Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 12 Nov 2026 10:15:59 -0600 Subject: [PATCH 3/7] docs: changelog entry for post-render widget error notifications (#320) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcce7c528..7221d750d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug fixes +* A widget that fails after its render function returned no longer leaves the output silently blank (or, on older Shiny, ends the session). Failures while a widget's comm opens (e.g., an error in a display hook or in state serialization) and failures in widget callbacks triggered by browser messages (e.g., an Altair/VegaFusion chart without `vl-convert-python` installed) now show the user a sticky error notification, deduplicated per widget. The full error message is shown locally; where the app sanitizes errors (e.g., Posit Connect), the notification uses the generic sanitized message. The error continues to be logged server-side. (#320) * Fixed many issues with widgets nested inside other widgets and with widget outputs inserted dynamically (e.g., `output_widget()` inside `@render.ui`). With ipywidgets 8, these either didn't render at all (e.g., a Plotly `FigureWidget` or a styled `HTML` widget inside an ipyleaflet popup) or rendered while logging errors like `Class HTMLStyleModel not found` (e.g., ipyleaflet popups, `WidgetControl` children, and leafmap legends). (#75, #101, #137, #157, #194, #237) * Re-rendering, replacing, hiding/showing, or removing widget outputs no longer leaks memory on the server or in the browser. Re-rendered widgets, and the data they hold, are now freed right away instead of when the session ends. (#246, #258, #260, #261, #262, #263, #303, #405) Related fixes: * pythreejs widgets no longer cause Shiny sessions to hang indefinitely with ipywidgets 8. (#244) From 0f72c6abd7a0e894389c80a4c305b18cab2768a8 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 12 Nov 2026 15:55:58 -0600 Subject: [PATCH 4/7] fix: release the comm from the dispatch scope when dispatch ends (#320) --- shinywidgets/_comm.py | 4 ++++ tests/unit/test_callback_errors.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/shinywidgets/_comm.py b/shinywidgets/_comm.py index e8433da97..0daa3f25c 100644 --- a/shinywidgets/_comm.py +++ b/shinywidgets/_comm.py @@ -254,6 +254,10 @@ def dispatch_client_msg( self._parent_header = None traitlets_level_patch.restore() scope.active = False + # Deferred work shares this scope via its copied context (see + # ClientMsgScope); release the comm so that work can't pin it + # (and its widget callbacks and session) after dispatch ends. + scope.comm = None handling_client_msg.reset(token) def handle_close(self, msg: Dict[str, object]) -> None: diff --git a/tests/unit/test_callback_errors.py b/tests/unit/test_callback_errors.py index 54f9c5d2d..01390e39f 100644 --- a/tests/unit/test_callback_errors.py +++ b/tests/unit/test_callback_errors.py @@ -18,7 +18,7 @@ from ipywidgets import Button, IntSlider from shiny import Session, reactive from shiny.session import session_context -from shinywidgets._comm import ShinyComm +from shinywidgets._comm import ClientMsgScope, ShinyComm, handling_client_msg def flush() -> None: @@ -212,6 +212,33 @@ def schedule(change: object) -> None: assert record.levelno == logging.WARNING +def test_scope_releases_comm_after_dispatch(widget_session: Session) -> None: + # Deferred work (asyncio tasks, loop.call_soon) shares the dispatch scope + # via its copied context, so a strong comm reference on it would keep the + # comm -- and its widget callbacks and session -- alive for the lifetime + # of that work, even after the widget is closed or replaced. + scopes: List[ClientMsgScope] = [] + + def grab(change: object) -> None: + scope = handling_client_msg.get() + assert scope is not None + scopes.append(scope) + + with session_context(widget_session): + slider = IntSlider(value=1) + slider.observe(grab, names="value") + comm = open_comm(slider) + comm.handle_msg( + client_msg( + comm.comm_id, + {"method": "update", "state": {"value": 5}, "buffer_paths": []}, + ) + ) + + [scope] = scopes + assert scope.comm is None + + def capture_notifications( session: Session, monkeypatch: pytest.MonkeyPatch ) -> List[Dict[str, Any]]: From 417ccb741d4ae77d603f4b7cf517e18e4226866c Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 12 Nov 2026 18:14:17 -0600 Subject: [PATCH 5/7] fix: dedupe widget callback error notifications by notification id (#320) The dispatch, not the logging filter, now shows the notification: the filter only notes the exception's message on the dispatch scope. Reusing one notification id per widget lets the browser replace a repeat, so the per-comm set of shown messages, its cap, and the comm reference on the scope are gone. A traitlets warning without an exception no longer notifies. --- shinywidgets/_comm.py | 65 ++++++++++++------------------ tests/unit/test_callback_errors.py | 61 +++++++++++++++++++++------- 2 files changed, 73 insertions(+), 53 deletions(-) diff --git a/shinywidgets/_comm.py b/shinywidgets/_comm.py index 947699ef4..9c0749076 100644 --- a/shinywidgets/_comm.py +++ b/shinywidgets/_comm.py @@ -78,8 +78,6 @@ def __init__( self.comm_manager = comm_manager self.target_name = target_name self.session = session - # Error messages already shown to the user (see notify_client_msg_error) - self._notified_errors: set[str] = set() self.open(data=data, metadata=metadata, buffers=buffers, **keys) @classmethod @@ -99,7 +97,6 @@ def from_client( comm.comm_manager = comm_manager comm.target_name = target_name comm.session = session - comm._notified_errors = set() comm._closed = False comm.awaiting_owner = True comm_manager.register_comm(comm) @@ -252,7 +249,6 @@ def dispatch_client_msg( """ self._parent_header = msg.get("header") scope = ClientMsgScope() - scope.comm = self token = handling_client_msg.set(scope) traitlets_level_patch.patch() try: @@ -261,11 +257,9 @@ def dispatch_client_msg( self._parent_header = None traitlets_level_patch.restore() scope.active = False - # Deferred work shares this scope via its copied context (see - # ClientMsgScope); release the comm so that work can't pin it - # (and its widget callbacks and session) after dispatch ends. - scope.comm = None handling_client_msg.reset(token) + if scope.errors: + notify_client_msg_error(self, scope.errors[0]) def handle_close(self, msg: Dict[str, object]) -> None: """The browser closed this comm: a later close() has nothing to publish.""" @@ -520,10 +514,13 @@ class ClientMsgScope: """ active = True - # The comm whose message is being dispatched: how a rerouted callback - # error finds the session (and per-widget dedup set) for its user-facing - # notification (see notify_client_msg_error). - comm: Optional["ShinyComm"] = None + + def __init__(self) -> None: + # The callback errors rerouted during dispatch, for its user-facing + # notification (see notify_client_msg_error). Their messages, not the + # exceptions: deferred work can outlive the dispatch holding this + # scope, and a traceback's frames would pin the widget and session. + self.errors: List[str] = [] handling_client_msg: ContextVar[Optional[ClientMsgScope]] = ContextVar( @@ -574,35 +571,25 @@ def restore(self) -> None: traitlets_level_patch = TraitletsLevelPatch() -# Cap per comm so a callback that fails with ever-changing messages can't -# grow the dedup set (or flood the user with notifications) without bound. -MAX_ERROR_NOTIFICATIONS_PER_COMM = 50 - - -def notify_client_msg_error(comm: ShinyComm, record: logging.LogRecord) -> None: +def notify_client_msg_error(comm: ShinyComm, msg: str) -> None: """ Show a sticky browser notification for a widget-callback error that ipywidgets swallowed (see ClientMsgErrorFilter): the error already reached the server log, but if the failing callback is part of how the widget renders, the user's output is otherwise blank with no - explanation. Each unique message is shown once per widget; repeats only - go to the server log. + explanation. The browser replaces a notification whose id it already + shows, so a callback that fails on every message leaves one per widget. """ - if record.exc_info is not None and record.exc_info[1] is not None: - msg = str(record.exc_info[1]) - else: - msg = record.getMessage() - notified = comm._notified_errors - if msg in notified or len(notified) >= MAX_ERROR_NOTIFICATIONS_PER_COMM: - return - notified.add(msg) session = comm.session - try: - if session.app.sanitize_errors: - msg = session.app.sanitize_error_msg - ui.notification_show(msg, type="error", duration=None, session=session) - except Exception: - logger.exception("Failed to show widget callback error in the browser") + if session.app.sanitize_errors: + msg = session.app.sanitize_error_msg + ui.notification_show( + msg, + type="error", + duration=None, + id=f"shinywidgets-error-{comm.comm_id}", + session=session, + ) class ClientMsgErrorFilter(logging.Filter): @@ -615,9 +602,8 @@ class ClientMsgErrorFilter(logging.Filter): logger (dropping the original, so a configured root handler doesn't show them twice). Everywhere else, e.g. in Jupyter, `traitlets` logging is untouched. - A rerouted record is also shown to the user as a sticky error - notification (deduplicated per widget, see notify_client_msg_error): the - failing callback often leaves their output blank with no explanation. + A rerouted exception is also noted on the dispatch's scope, so the + dispatch can show it to the user (see notify_client_msg_error). """ def filter(self, record: logging.LogRecord) -> bool: @@ -625,8 +611,9 @@ def filter(self, record: logging.LogRecord) -> bool: if scope is None or not scope.active or record.levelno < logging.WARNING: return True logger.error(record.getMessage(), exc_info=record.exc_info) - if scope.comm is not None: - notify_client_msg_error(scope.comm, record) + exc = record.exc_info[1] if record.exc_info else None + if exc is not None: + scope.errors.append(str(exc)) return False diff --git a/tests/unit/test_callback_errors.py b/tests/unit/test_callback_errors.py index 01390e39f..721c2e220 100644 --- a/tests/unit/test_callback_errors.py +++ b/tests/unit/test_callback_errors.py @@ -3,8 +3,8 @@ caught by ipywidgets and logged to the `traitlets` logger, whose only handler is a NullHandler, so outside of IPython they would vanish. shinywidgets re-routes them to its own logger so they show up in the server's console, and shows the -user a sticky error notification (deduplicated per widget), since the error -usually means their output is blank. +user a sticky error notification (one per widget), since the error usually +means their output is blank. """ from __future__ import annotations @@ -212,21 +212,23 @@ def schedule(change: object) -> None: assert record.levelno == logging.WARNING -def test_scope_releases_comm_after_dispatch(widget_session: Session) -> None: +def test_scope_keeps_error_messages_not_exceptions(widget_session: Session) -> None: # Deferred work (asyncio tasks, loop.call_soon) shares the dispatch scope - # via its copied context, so a strong comm reference on it would keep the - # comm -- and its widget callbacks and session -- alive for the lifetime - # of that work, even after the widget is closed or replaced. + # via its copied context, so an exception kept on it would keep its + # traceback's frames -- and through them the widget, its callbacks and + # session -- alive for the lifetime of that work, even after the widget + # is closed or replaced. scopes: List[ClientMsgScope] = [] - def grab(change: object) -> None: + def boom(change: object) -> None: scope = handling_client_msg.get() assert scope is not None scopes.append(scope) + raise RuntimeError("observer failed") with session_context(widget_session): slider = IntSlider(value=1) - slider.observe(grab, names="value") + slider.observe(boom, names="value") comm = open_comm(slider) comm.handle_msg( client_msg( @@ -236,7 +238,7 @@ def grab(change: object) -> None: ) [scope] = scopes - assert scope.comm is None + assert scope.errors == ["observer failed"] def capture_notifications( @@ -283,7 +285,7 @@ def test_observer_error_notifies_browser( assert "observer failed" in payload["html"] -def test_observer_error_notifies_once_per_unique_message( +def test_repeat_errors_reuse_the_widgets_notification( widget_session: Session, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, @@ -297,11 +299,42 @@ def test_observer_error_notifies_once_per_unique_message( {"method": "update", "state": {"value": 6}, "buffer_paths": []}, ) ) + other = failing_slider() + + # Every failure is logged ... + assert len(caplog.records) == 3 + # ... but the browser replaces a notification whose id it already shows, + # so an observer that fails on every message leaves one per widget. + ids = [m["notification"]["message"]["id"] for m in sent if "notification" in m] + assert ids == [ + f"shinywidgets-error-{comm.comm_id}", + f"shinywidgets-error-{comm.comm_id}", + f"shinywidgets-error-{other.comm_id}", + ] + + +def test_warning_without_exception_does_not_notify( + widget_session: Session, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + def warn(change: object) -> None: + logging.getLogger("traitlets").warning("heads up") + + sent = capture_notifications(widget_session, monkeypatch) + with session_context(widget_session), caplog.at_level(logging.WARNING): + slider = IntSlider(value=1) + slider.observe(warn, names="value") + comm = open_comm(slider) + comm.handle_msg( + client_msg( + comm.comm_id, + {"method": "update", "state": {"value": 5}, "buffer_paths": []}, + ) + ) - # Both failures are logged ... - assert len(caplog.records) == 2 - # ... but the user is notified only once. - assert len([m for m in sent if "notification" in m]) == 1 + assert "heads up" in caplog.text + assert [m for m in sent if "notification" in m] == [] def test_observer_error_notification_sanitized_when_app_sanitizes( From ac9f1a13418c99415e1d20473340e39d39c5a35e Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 13 Nov 2026 10:36:28 -0600 Subject: [PATCH 6/7] refactor: don't close the widget after a comm-open failure; trim changelog (#320) Render invalidation or session end already cleans up the half-initialized widget, so closing it early wasn't worth the nested error handling. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- shinywidgets/_shinywidgets.py | 17 +++-------------- tests/unit/test_widget_display.py | 7 +++---- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7221d750d..4ecd69f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug fixes -* A widget that fails after its render function returned no longer leaves the output silently blank (or, on older Shiny, ends the session). Failures while a widget's comm opens (e.g., an error in a display hook or in state serialization) and failures in widget callbacks triggered by browser messages (e.g., an Altair/VegaFusion chart without `vl-convert-python` installed) now show the user a sticky error notification, deduplicated per widget. The full error message is shown locally; where the app sanitizes errors (e.g., Posit Connect), the notification uses the generic sanitized message. The error continues to be logged server-side. (#320) +* A widget that fails after its render function returns (e.g., an Altair/VegaFusion chart without `vl-convert-python` installed) now shows the user an error notification instead of leaving the output blank. (#320) * Fixed many issues with widgets nested inside other widgets and with widget outputs inserted dynamically (e.g., `output_widget()` inside `@render.ui`). With ipywidgets 8, these either didn't render at all (e.g., a Plotly `FigureWidget` or a styled `HTML` widget inside an ipyleaflet popup) or rendered while logging errors like `Class HTMLStyleModel not found` (e.g., ipyleaflet popups, `WidgetControl` children, and leafmap legends). (#75, #101, #137, #157, #194, #237) * Re-rendering, replacing, hiding/showing, or removing widget outputs no longer leaks memory on the server or in the browser. Re-rendered widgets, and the data they hold, are now freed right away instead of when the session ends. (#246, #258, #260, #261, #262, #263, #303, #405) Related fixes: * pythreejs widgets no longer cause Shiny sessions to hang indefinitely with ipywidgets 8. (#244) diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py index c7d566950..bb036dc85 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -310,20 +310,9 @@ def _open_shiny_comm(): except Exception as e: # The render already returned, so Shiny's render-error display # never sees this failure; unhandled, the output stays blank - # forever (and on older Shiny the error would end the session). - # NotifyException makes Shiny print the traceback and show a - # sticky error notification while the session survives -- it - # fires even though this effect destroyed itself above. - # - # A process-global widget belongs to every session, so one - # session's failure must not close it. A session-owned one is - # closed, tearing down its half-initialized state the same way - # render invalidation would. - if not is_process_global(w): - try: - close_widget(w) - except Exception: - logger.exception("Failed to close widget after comm-open failure") + # forever. NotifyException makes Shiny print the traceback and + # show a sticky error notification while the session survives -- + # it fires even though this effect destroyed itself above. raise NotifyException( f"Failed to display {type(w).__name__}: {e}", sanitize=session.app.sanitize_errors, diff --git a/tests/unit/test_widget_display.py b/tests/unit/test_widget_display.py index 07fa27617..f5ed37f14 100644 --- a/tests/unit/test_widget_display.py +++ b/tests/unit/test_widget_display.py @@ -11,7 +11,7 @@ from shiny.session import session_context from shiny.session._session import AppSession from shinywidgets._comm import ShinyComm -from shinywidgets._shinywidgets import SESSION_WIDGET_ID_MAP, is_closed_widget +from shinywidgets._shinywidgets import SESSION_WIDGET_ID_MAP def test_open_does_not_create_display_previews(widget_session: Session) -> None: @@ -82,7 +82,7 @@ def get_state(self, *args: Any, **kwargs: Any) -> Any: raise RuntimeError("boom during get_state") -def test_comm_open_failure_notifies_user_and_closes_widget( +def test_comm_open_failure_notifies_user( widget_session: Session, monkeypatch: pytest.MonkeyPatch ) -> None: sent: List[Dict[str, Any]] = [] @@ -94,7 +94,7 @@ async def unhandled(e: Exception) -> None: monkeypatch.setattr(widget_session, "_unhandled_error", unhandled) with session_context(widget_session): - w = BrokenStateSlider() + _ = BrokenStateSlider() asyncio.get_event_loop().run_until_complete(reactive.flush()) [note] = [m for m in sent if "notification" in m] @@ -102,7 +102,6 @@ async def unhandled(e: Exception) -> None: assert payload["type"] == "error" assert payload["duration"] is None assert "boom during get_state" in payload["html"] - assert is_closed_widget(w) def test_comm_open_failure_notification_is_sanitized_with_app_flag( From 9c7ce67d405775dd3b757bf286aa7bec78ecd1e0 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 13 Nov 2026 10:59:04 -0600 Subject: [PATCH 7/7] fix: stop queuing sends on a widget whose comm failed to open (#320) Its placeholder comm would otherwise keep queuing widget.send() messages that can never be delivered, until the output re-renders or the session ends. Process-global widgets are left alone: another session's open still drains their queue. Co-Authored-By: Claude --- shinywidgets/_shinywidgets.py | 6 ++++++ tests/unit/test_widget_display.py | 12 +++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py index bb036dc85..c52ebeb8a 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -313,6 +313,12 @@ def _open_shiny_comm(): # forever. NotifyException makes Shiny print the traceback and # show a sticky error notification while the session survives -- # it fires even though this effect destroyed itself above. + # + # A session-owned widget's comm can no longer open, so stop its + # placeholder queuing sends. A process-global one's placeholder is + # still drained when another session opens it (open_shared_in()). + if not is_process_global(w) and isinstance(w.comm, OrphanedShinyComm): + w.comm.close() raise NotifyException( f"Failed to display {type(w).__name__}: {e}", sanitize=session.app.sanitize_errors, diff --git a/tests/unit/test_widget_display.py b/tests/unit/test_widget_display.py index f5ed37f14..9b3030dd8 100644 --- a/tests/unit/test_widget_display.py +++ b/tests/unit/test_widget_display.py @@ -10,7 +10,7 @@ from shiny import Session, reactive from shiny.session import session_context from shiny.session._session import AppSession -from shinywidgets._comm import ShinyComm +from shinywidgets._comm import OrphanedShinyComm, ShinyComm from shinywidgets._shinywidgets import SESSION_WIDGET_ID_MAP @@ -104,6 +104,16 @@ async def unhandled(e: Exception) -> None: assert "boom during get_state" in payload["html"] +def test_comm_open_failure_stops_queuing_sends(widget_session: Session) -> None: + with session_context(widget_session): + w = BrokenStateSlider() + asyncio.get_event_loop().run_until_complete(reactive.flush()) + w.send({"x": 1}) + + assert isinstance(w.comm, OrphanedShinyComm) + assert w.comm.take_queued_sends() == [] + + def test_comm_open_failure_notification_is_sanitized_with_app_flag( widget_session: Session, monkeypatch: pytest.MonkeyPatch ) -> None: