Skip to content

Commit 8df9500

Browse files
larsonerclaude
andcommitted
fix: fall back to one browser per export if the shared one dies
On CircleCI Linux, kaleido's shared sync-server browser renders once and then its process exits (choreographer's watchdog then closes the channels and the next export fails the whole build) — with both the machine image's browser and a fresh Chrome for Testing, while per-export browsers work fine there. On an export failure with the server running, stop it, log at info level, and retry the export with kaleido's regular one-browser-per-call path for the rest of the build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6894c4b commit 8df9500

2 files changed

Lines changed: 90 additions & 11 deletions

File tree

plotly/io/_sg_scraper.py

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,20 @@ def _trailing_repr_figure(block, block_vars):
141141
return figure
142142

143143

144+
# Whether the shared export browser is "unstarted", "running", or "disabled"
145+
_export_server_state = "unstarted"
146+
147+
144148
def _start_export_server():
145149
"""Keep one browser running for the whole build.
146150
147151
Without it, every static image export launches and tears down a browser
148152
(~1.5 s each); with it, only the first does (~50 ms each after that).
149153
Kaleido stops the server atexit.
150154
"""
155+
global _export_server_state
156+
if _export_server_state != "unstarted":
157+
return
151158
try:
152159
import kaleido
153160

@@ -163,8 +170,38 @@ def _start_export_server():
163170
kopts["headers"] = defaults.headers
164171
kaleido.start_sync_server(silence_warnings=True, **kopts)
165172
except Exception:
166-
pass # Kaleido v0 keeps a persistent instance itself; the probe
167-
# reports any other problem
173+
# Kaleido v0 keeps a persistent instance itself; the probe reports
174+
# any other problem
175+
_export_server_state = "disabled"
176+
else:
177+
_export_server_state = "running"
178+
179+
180+
def _abandon_export_server(exc):
181+
"""Stop using the shared browser; return whether a retry makes sense."""
182+
global _export_server_state
183+
if _export_server_state != "running":
184+
return False
185+
_export_server_state = "disabled"
186+
try:
187+
from sphinx.util.logging import getLogger
188+
189+
log = getLogger(__name__).info
190+
except Exception:
191+
log = logging.getLogger(__name__).info
192+
log(
193+
"The shared plotly static image export browser failed with '%s: %s'; "
194+
"falling back to one browser per exported figure.",
195+
type(exc).__name__,
196+
exc,
197+
)
198+
try:
199+
import kaleido
200+
201+
kaleido.stop_sync_server(silence_warnings=True)
202+
except Exception:
203+
pass
204+
return True
168205

169206

170207
@functools.lru_cache(maxsize=None) # functools.cache needs Python 3.9
@@ -228,15 +265,28 @@ def _raw_html_rst(html):
228265

229266
def _export_image(fig_dict, file, image_format):
230267
"""Export one static image (to memory when `file` is None)."""
231-
with warnings.catch_warnings():
232-
# The kopts the export server was started with already apply
233-
warnings.filterwarnings(
234-
"ignore", message="The kopts argument", category=UserWarning
235-
)
236-
if file is None:
237-
plotly.io.to_image(fig_dict, format=image_format, validate=False)
238-
else:
239-
plotly.io.write_image(fig_dict, file, format=image_format, validate=False)
268+
269+
def export():
270+
with warnings.catch_warnings():
271+
# The kopts the export server was started with already apply
272+
warnings.filterwarnings(
273+
"ignore", message="The kopts argument", category=UserWarning
274+
)
275+
if file is None:
276+
plotly.io.to_image(fig_dict, format=image_format, validate=False)
277+
else:
278+
plotly.io.write_image(
279+
fig_dict, file, format=image_format, validate=False
280+
)
281+
282+
try:
283+
export()
284+
except Exception as exc:
285+
# The shared browser can die mid-build (seen on CircleCI Linux);
286+
# retry with one browser per export.
287+
if not _abandon_export_server(exc):
288+
raise
289+
export()
240290

241291

242292
def _write_image(fig_dict, file, image_format):

tests/test_io/test_sg_scraper.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,35 @@ def test_scraper_bad_format(gallery):
207207
gallery.scrape()
208208

209209

210+
def test_scraper_export_server_fallback(gallery, monkeypatch):
211+
"""A shared browser dying mid-build falls back to per-export browsers."""
212+
import plotly.io._sg_scraper as sg_scraper
213+
214+
real_write_image = pio.write_image
215+
failed = []
216+
217+
def flaky_write_image(*args, **kwargs):
218+
if not failed:
219+
failed.append(True)
220+
raise ValueError("browser died")
221+
return real_write_image(*args, **kwargs)
222+
223+
monkeypatch.setattr(pio, "write_image", flaky_write_image)
224+
monkeypatch.setattr(sg_scraper, "_export_server_state", "running")
225+
pio.show(go.Figure())
226+
rst = gallery.scrape()
227+
228+
assert rst.count('class="plotly-graph-div"') == 1
229+
assert_image_color(Path(gallery.paths[0]), COLORS[0])
230+
assert sg_scraper._export_server_state == "disabled"
231+
232+
# With no server left to abandon, an export failure is fatal
233+
failed.clear()
234+
pio.show(go.Figure())
235+
with pytest.raises(RuntimeError, match="static-image-export"):
236+
gallery.scrape()
237+
238+
210239
def test_scraper_no_static_export(gallery, monkeypatch, caplog):
211240
"""Without static export, warn once and keep the interactive figures.
212241

0 commit comments

Comments
 (0)