Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions cortex/export/headless.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,50 @@ def _wait_for_viewer_loaded(handle, timeout: float = 60.0) -> None:
)


def _wait_for_tracts_loaded(handle, timeout: float = 60.0) -> None:
"""Block until every tractogram's buffers have arrived in the browser.

``viewer.loaded`` deliberately does not wait for tractograms -- a slow
streamline download must not hold up a viewer that is perfectly usable
without it (mriview.js: ``addTracts``). That leaves a race for anything
that screenshots straight after loading: ``getImage``/``save_3d_views``
would catch a scene whose streamlines are still in flight. So poll
``viewer.tractsState()`` too, which answers "resolved" immediately when
there are no tractograms at all.

A failed download is reported rather than waited out: the screenshot
would silently be missing streamlines.
"""
deadline = time.monotonic() + timeout
poll_interval = 0.1
last_err: Optional[str] = None
while time.monotonic() < deadline:
try:
result = handle.send(
method="run", params=["window.viewer.tractsState", []]
)
except Exception as exc:
last_err = repr(exc)
result = None
val = result[0] if isinstance(result, list) and result else result
if val == "resolved":
return
if val == "rejected":
raise RuntimeError(
"A tractogram's streamline buffers failed to download in the "
"headless browser; the viewer would render without them."
)
if val is not None:
last_err = (
str(val.get("error", val)) if isinstance(val, dict) else str(val)
)
time.sleep(poll_interval)
raise RuntimeError(
f"Tractogram buffers did not finish loading within {timeout:.0f}s "
f"(last response: {last_err!r})."
)


# --------------------------------------------------------------------------- #
# Helper: run Playwright in a dedicated thread to avoid asyncio conflicts #
# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -461,6 +505,10 @@ def _await_client() -> None:
# calls in tests and callers, and shortens the wait when the
# browser is faster than the worst-case timeout.
_wait_for_viewer_loaded(handle, timeout=timeout)
# ... and until any tractogram has finished downloading: those load
# outside viewer.loaded on purpose, so without this a screenshot
# taken right away can come back without its streamlines.
_wait_for_tracts_loaded(handle, timeout=timeout)

yield handle

Expand Down
8 changes: 7 additions & 1 deletion cortex/tests/test_webgl_headless.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,11 +750,17 @@ def _served_metadata(handle):
``show()`` regenerates the page from the same ``metadata`` dict that
``addData`` merges into, so this is how we check that a reload of the
viewer would show everything that has been added so far.

The payload is the right-hand side of ``mixer.html``'s ``metadata = ...``
assignment, not the argument of the ``dataset.fromJSON()`` below it: that
call takes the variable, so decoding from there lands on an identifier and
raises. Line 4 of the template declares ``metadata`` without an ``=``, so
the assignment is the first ``metadata = `` in the page.
"""
url = "http://localhost:%d/mixer.html" % handle.server.port
with urllib.request.urlopen(url, timeout=30) as resp:
page = resp.read().decode("utf-8")
marker = "dataset.fromJSON("
marker = "metadata = "
start = page.index(marker) + len(marker)
return json.JSONDecoder().raw_decode(page, start)[0]

Expand Down
Loading
Loading