Skip to content

ImportedStyleSheet with unset url is shared across server sessions and permanently 500s every new session (guarding the read is not sufficient) #8739

Description

@dem865

Summary

An ImportedStyleSheet whose url is never set gets reused across Bokeh server sessions and permanently breaks every subsequent session bootstrap in that process. Reactive._get_properties reads str(stylesheet.url) unguarded, raises UnsetValueError, and the server returns HTTP 500 for every new session until it is restarted.

The guards added in #8071 do not prevent this, and — more importantly — guarding that read alone would not fix it either. See "Why a guard is not enough" below; that is the part I think is most useful to you.

Evidence that one object is shared across sessions

From a production log covering one incident: 15 consecutive failed /autoload.js bootstraps, each carrying a distinct bokeh-autoload-element UUID (so 15 genuinely separate new sessions), all crashing on the same Bokeh model id. One Python ImportedStyleSheet object, url never set, read by 15 unrelated sessions in a row. Restarting the process clears it every time, so it is process-memory state.

Across the retained history, 19 of 19 500 GET .../autoload.js responses were immediately preceded by this exception. Nothing else preceded any of them.

Traceback shape

panel/viewable.py  server_doc -> get_root -> _get_model
panel/layout/base.py  _get_objects / _get_model   (tree walk)
panel/reactive.py  _get_properties
    url = str(stylesheet.url)
bokeh/core/property/descriptors.py:282  __get__
    raise UnsetValueError(...)

Seen through both a panel/pane/base.py Pane and a panel/widgets/button.py Button, so it is not tied to one component type.

Why a guard is not enough

I tested both candidate fixes against the versions below, driving a real Document all the way to doc.to_json() — the call pull_doc_reply.create() uses to build the PULL-DOC-REPLY payload that the autoload path depends on.

(a) Guard the read and let the stylesheet through — does not work. Model construction succeeds, and then Document.to_json() raises the identical UnsetValueError at the identical descriptor, by a completely different and also unguarded route:

Model.to_serializable -> HasProps.to_serializable
  -> properties_with_values -> query_properties_with_values
  -> descriptor.get_value()

Bokeh's serializer reads every serialized property unconditionally. So a guard at _get_properties relocates the failure from session bootstrap into the websocket handshake rather than removing it.

(b) Drop the broken stylesheet from properties['stylesheets'] — works. Construction and to_json() both succeed, and the component keeps its other stylesheets with their real CDN URLs (.../panel/1.8.10/dist/css/markdown.css etc.), losing only the corrupted entry.

I mention this because #8071's guards, as I read them, protect the cached entry (try: cached.url / except: replace) but not the incoming object, and patch_stylesheet's guards make it return early without repairing. The read at the top of the loop is still unguarded in main.

Minimal reproduction

stylesheets is param.List(item_type=str), so a raw ImportedStyleSheet cannot be assigned directly — it has to arrive the way Design._patch_modifiers produces it (sts = ImportedStyleSheet(url=sts)), i.e. as an object already inside the properties dict. This subclass injects one of the same shape and leaves _get_properties itself untouched:

import panel as pn
from bokeh.document import Document
from bokeh.models import ImportedStyleSheet

pn.extension()

class BrokenStylesheetMarkdown(pn.pane.Markdown):
    def _process_param_change(self, params):
        props = super()._process_param_change(params)
        if 'stylesheets' in props:
            props['stylesheets'] = list(props['stylesheets']) + [ImportedStyleSheet()]
        return props

doc = Document()
BrokenStylesheetMarkdown("hello world").server_doc(doc)   # UnsetValueError

Guard the read in _get_properties and re-run, then call doc.to_json() — that is where (a) fails.

Versions

Reproduced identically on both:

Panel Bokeh Python
1.8.10 3.9.0 3.12.0
1.9.3 3.8.0 3.12.3

The unguarded read is at reactive.py:710 in 1.8.10, :721 in 1.9.3, and is still present in main.

What I could not determine

Why the url ends up unset in the first place. I ruled out, with direct evidence rather than absence-of-evidence, several candidates on our side: no application code constructs an ImportedStyleSheet (all our stylesheets= are inline CSS strings, rebuilt per session); no module-level or class-level widget singleton exists; the one per-process shared object in our app holds no Panel models. Design._cache looked promising but is dead code on our path — its only call sites always pass an explicit, doc-scoped cache.

So I can show the object is shared and that dropping it is the viable repair, but not the corruption path. Given the note in #8071 that this has been hard to track down, I thought the sharing evidence and the "a guard alone relocates the crash" finding might be worth more than another report of the symptom.

Workaround in use

Wrapping Reactive._get_properties and, on UnsetValueError only, retrying Panel's own implementation with broken ImportedStyleSheets filtered out of _process_param_change's output. Deliberately no copy of the function body, so it works across the versions above. Happy to open a PR against reactive.py if a drop-on-unset is the direction you would take.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions