Skip to content

Commit 00b6954

Browse files
larsgebclaude
andcommitted
Pages: fix segfault by lazy-loading plt and isolating nbconvert in subprocess
Importing matplotlib.pyplot or nbconvert at module level in the same process as Metal GPU initialization causes a SIGSEGV (exit code 139) on the macOS CI runner. Fix: - Never import matplotlib in this script; get plt lazily from the exec namespace after the notebook's own import cell runs (MPLBACKEND=Agg env var, already set in CI, controls the backend) - Invoke nbconvert as a subprocess after all GPU work is done, keeping its heavy imports (jinja2, lxml, …) fully isolated from the Metal runtime Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1087d34 commit 00b6954

1 file changed

Lines changed: 62 additions & 39 deletions

File tree

scripts/render_notebooks.py

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,41 @@
33
In-process notebook renderer for m1-gpu-cpp demos.
44
55
Executes jupytext .py notebooks by running cells with exec() in the same
6-
Python process, avoiding Jupyter kernel subprocess issues on macOS CI where
7-
Metal GPU access is available to the main process but not reliably inherited
8-
by kernel subprocesses.
6+
Python process. This avoids the Jupyter kernel subprocess which cannot
7+
reliably access the Metal GPU on macOS CI runners.
98
10-
Matplotlib figures are captured via a patched plt.show() and appended to
11-
cell outputs as base64-encoded PNG images. nbconvert's HTMLExporter is then
12-
called as a library (no subprocess) to produce the final HTML.
9+
Matplotlib figures are captured via a patched plt.show(). plt is obtained
10+
lazily from the exec namespace after the notebook's own import cell runs, so
11+
this script never imports matplotlib itself — the MPLBACKEND=Agg environment
12+
variable (set in CI) is sufficient to control the backend.
13+
14+
HTML rendering is done by spawning a separate nbconvert subprocess *after* all
15+
GPU work is finished, keeping nbconvert's heavy imports isolated from Metal.
1316
1417
Usage:
15-
python scripts/render_notebooks.py <output_dir> <notebook.py> [...]
18+
MPLBACKEND=Agg python scripts/render_notebooks.py <output_dir> <nb.py> [...]
1619
"""
1720

1821
import base64
1922
import io
23+
import subprocess
2024
import sys
2125
import traceback
2226
from pathlib import Path
2327

24-
import matplotlib
25-
26-
matplotlib.use("Agg")
27-
import matplotlib.pyplot as plt
2828
import jupytext
2929
import nbformat
3030
from nbformat import v4
31-
from nbconvert.exporters import HTMLExporter
3231

3332

3433
# ---------------------------------------------------------------------------
35-
# Figure capture helpers
34+
# Figure capture helpers (plt accessed through exec namespace, never imported
35+
# at module level to avoid a matplotlib ↔ Metal conflict at initialisation)
3636
# ---------------------------------------------------------------------------
3737

3838

39-
def _capture_and_close() -> list[str]:
40-
"""Save every open matplotlib figure as a base64 PNG and close all."""
39+
def _capture_and_close(plt) -> list[str]:
40+
"""Save every open matplotlib figure as base64 PNG and close all."""
4141
images: list[str] = []
4242
for fnum in plt.get_fignums():
4343
buf = io.BytesIO()
@@ -53,31 +53,30 @@ def _capture_and_close() -> list[str]:
5353
# ---------------------------------------------------------------------------
5454

5555

56-
def execute_notebook(py_path: Path, out_dir: Path) -> None:
56+
def execute_notebook(py_path: Path, out_dir: Path) -> Path:
57+
"""Execute a jupytext .py notebook in-process and save the result as .ipynb."""
5758
print(f"Executing {py_path} ...")
58-
# Explicit fmt avoids jupytext leaking YAML front-matter into the first cell.
5959
nb = jupytext.read(str(py_path), fmt="py:light")
6060

61-
# Shared namespace — persists across cells, just like a real kernel session.
61+
# Shared namespace — persists across all cells, just like a real kernel.
6262
ns: dict = {"__name__": "__main__"}
6363

64-
# Patch plt.show() so that cells which call it trigger figure capture
65-
# instead of trying to open a GUI window. Images are accumulated per-cell.
64+
# plt is obtained from ns after the notebook's first import cell runs.
65+
# cell_images accumulates captured figures for the current cell.
66+
plt_ref: list = [] # one-element list so the closure can rebind it
6667
cell_images: list[str] = []
6768

6869
def _patched_show(*_args, **_kwargs) -> None:
69-
cell_images.extend(_capture_and_close())
70-
71-
plt.show = _patched_show
70+
if plt_ref:
71+
cell_images.extend(_capture_and_close(plt_ref[0]))
7272

7373
for exec_count, cell in enumerate(nb.cells, start=1):
7474
if cell.cell_type != "code":
7575
continue
7676

77-
# Drop IPython magic lines. jupytext stores them as `# %magic` comments
78-
# in the .py source; when it converts to a notebook it strips the `# `
79-
# prefix, so the cell source contains bare `%magic` lines which are not
80-
# valid Python. Filter both forms.
77+
# Strip IPython magic lines. jupytext stores them as `# %magic`
78+
# comments; when converting to a notebook it removes the `# ` prefix,
79+
# producing bare `%magic` lines that are not valid Python.
8180
src_lines = [
8281
line
8382
for line in cell.source.splitlines()
@@ -109,14 +108,21 @@ def _patched_show(*_args, **_kwargs) -> None:
109108
finally:
110109
sys.stdout = sys.__stdout__
111110

111+
# Once plt appears in the namespace, patch show() so subsequent cells
112+
# that call plt.show() trigger figure capture instead of a GUI window.
113+
if not plt_ref and "plt" in ns:
114+
plt_ref.append(ns["plt"])
115+
plt_ref[0].show = _patched_show
116+
112117
text = stdout_buf.getvalue()
113118
if text:
114119
cell.outputs.append(
115120
v4.new_output(output_type="stream", name="stdout", text=text)
116121
)
117122

118-
# Capture any figures not yet collected by patched show().
119-
cell_images.extend(_capture_and_close())
123+
# Capture any figures not yet collected by the patched show().
124+
if plt_ref:
125+
cell_images.extend(_capture_and_close(plt_ref[0]))
120126

121127
for img_b64 in cell_images:
122128
cell.outputs.append(
@@ -127,15 +133,13 @@ def _patched_show(*_args, **_kwargs) -> None:
127133
)
128134
)
129135

130-
# Render to HTML using nbconvert as a library (no subprocess).
131-
exporter = HTMLExporter()
132-
exporter.exclude_input_prompt = True
133-
body, _ = exporter.from_notebook_node(nb)
134-
136+
# Save the executed notebook.
135137
out_dir.mkdir(parents=True, exist_ok=True)
136-
out_path = out_dir / f"{py_path.stem}.html"
137-
out_path.write_text(body, encoding="utf-8")
138-
print(f" → {out_path}")
138+
ipynb_path = out_dir / f"{py_path.stem}.ipynb"
139+
with open(ipynb_path, "w") as f:
140+
nbformat.write(nb, f)
141+
print(f" saved {ipynb_path}")
142+
return ipynb_path
139143

140144

141145
# ---------------------------------------------------------------------------
@@ -149,8 +153,27 @@ def main() -> None:
149153
sys.exit(1)
150154

151155
out_dir = Path(sys.argv[1])
152-
for nb_path in (Path(p) for p in sys.argv[2:]):
153-
execute_notebook(nb_path, out_dir)
156+
notebooks = [Path(p) for p in sys.argv[2:]]
157+
158+
# Phase 1 — execute all notebooks in-process (Metal GPU access required).
159+
ipynb_files: list[Path] = []
160+
for nb_path in notebooks:
161+
ipynb_files.append(execute_notebook(nb_path, out_dir))
162+
163+
# Phase 2 — convert to HTML in a subprocess so that nbconvert's heavy
164+
# imports (jinja2, lxml, …) are fully isolated from the Metal runtime.
165+
for ipynb_path in ipynb_files:
166+
subprocess.run(
167+
[
168+
sys.executable, "-m", "nbconvert",
169+
"--to", "html",
170+
"--output-dir", str(out_dir),
171+
str(ipynb_path),
172+
],
173+
check=True,
174+
)
175+
ipynb_path.unlink()
176+
print(f" → {out_dir / ipynb_path.with_suffix('.html').name}")
154177

155178

156179
if __name__ == "__main__":

0 commit comments

Comments
 (0)