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.
Summary
An
ImportedStyleSheetwhoseurlis never set gets reused across Bokeh server sessions and permanently breaks every subsequent session bootstrap in that process.Reactive._get_propertiesreadsstr(stylesheet.url)unguarded, raisesUnsetValueError, 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.jsbootstraps, each carrying a distinctbokeh-autoload-elementUUID (so 15 genuinely separate new sessions), all crashing on the same Bokeh model id. One PythonImportedStyleSheetobject,urlnever 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.jsresponses were immediately preceded by this exception. Nothing else preceded any of them.Traceback shape
Seen through both a
panel/pane/base.pyPane and apanel/widgets/button.pyButton, 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
Documentall the way todoc.to_json()— the callpull_doc_reply.create()uses to build thePULL-DOC-REPLYpayload 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 identicalUnsetValueErrorat the identical descriptor, by a completely different and also unguarded route:Bokeh's serializer reads every
serializedproperty unconditionally. So a guard at_get_propertiesrelocates the failure from session bootstrap into the websocket handshake rather than removing it.(b) Drop the broken stylesheet from
properties['stylesheets']— works. Construction andto_json()both succeed, and the component keeps its other stylesheets with their real CDN URLs (.../panel/1.8.10/dist/css/markdown.cssetc.), 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, andpatch_stylesheet's guards make it return early without repairing. The read at the top of the loop is still unguarded inmain.Minimal reproduction
stylesheetsisparam.List(item_type=str), so a rawImportedStyleSheetcannot be assigned directly — it has to arrive the wayDesign._patch_modifiersproduces 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_propertiesitself untouched:Guard the read in
_get_propertiesand re-run, then calldoc.to_json()— that is where (a) fails.Versions
Reproduced identically on both:
The unguarded read is at
reactive.py:710in 1.8.10,:721in 1.9.3, and is still present inmain.What I could not determine
Why the
urlends 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 anImportedStyleSheet(all ourstylesheets=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._cachelooked 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_propertiesand, onUnsetValueErroronly, retrying Panel's own implementation with brokenImportedStyleSheets 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 againstreactive.pyif a drop-on-unset is the direction you would take.