Troubleshooting (hooks)#
This page is a symptom-based guide for when the notebook viewer and its hooks
don’t behave as expected. It covers the notebook widget (show_anndata())
first, then what is particular to a browser-tab viewer
(AnnDataViewer, serve_anndata, cellucid serve).
Rule of thumb: in a notebook, most surprises come from timing — the widget hears the viewer only between cells — not from hook code.
First step (always): collect a debug report#
Run, in a new cell:
report = viewer.debug_connection()
report
A notebook widget’s report (it waits for nothing, so it works in any cell):
transport("notebook widget") andweb_source_url(where the browser loads the app from)displayedandconnected_outputs(the outputs whose page reached the kernel)channel_connected(whether the viewer opened its channel)dataset(the id and format the widget serves)state(ready, the last event type and when it arrived)recent_events(counts by event type, and the commands the viewer refused)
A browser-tab viewer’s report adds its server: server_health,
anndata_store_probes (a HEAD and a one-byte Range read of the served
store), a ping/pong round trip (frontend_roundtrip) and the page’s
frontend_debug_snapshot.
If you include this report when asking for help, debugging is usually 10× faster.
Symptom: the output is empty, or says the notebook cannot show widgets#
Likely causes (ordered)#
anywidgetis not installed where the notebook server runs (JupyterLab, Notebook 7). Installingcellucidinstalls it in the kernel’s environment, which is a different one in some setups.The front end does not render widgets at all.
In marimo, the widget is not the cell’s output.
Fix#
pip install anywidgetin the notebook server’s environment and reload the page.Use a front end that shows widgets (Supported environments matrix).
In marimo, end the cell with
viewer.
Symptom: the output says the app did not load#
The widget’s message reads “The page at https://www.cellucid.com has not answered as a Cellucid viewer…”.
Likely causes#
The browser cannot reach
web_source_url(offline laptop, blocked address, strict content policy).web_source_urlnames a copy of the app that is too old to be embedded.
Fix#
Allow
https://www.cellucid.com, or host a current copy of the app and passshow_anndata(..., web_source_url="https://cellucid.example.org").
Symptom: a call raises “… a notebook widget hears the viewer only between cells”#
wait_for_ready(), wait_for_event(), a session request’s and a session
load’s result() and viewer.apply_session_to_anndata(adata, request) wait
for the viewer’s answer, and in a notebook nothing the viewer says can arrive
while the cell that waits is running. They raise at once and name the alternative:
readiness:
viewer.state.readyin a later cell, or@viewer.on_ready;the next selection:
viewer.selectionin a later cell, or@viewer.on_selection;results (highlights, labels, scores, gene sets):
viewer.pull(adata)in a later cell (Round trip (results back from the viewer, columns and gene sets into it));an action’s answer: keep its request (
request = viewer.fields_list()), thenrequest.result()in the next cell;a session:
request = viewer.request_session(), thenrequest.result()in the next cell;a session loaded into the viewer:
loading = viewer.load_session(path), thenloading.result()in the next cell.
Symptom: viewer loads but no events arrive (on_selection never fires)#
Likely causes (ordered)#
A cell is still running: the widget’s events are handled while the kernel is idle, and they all arrive once it is.
You are interacting with a stale output (kernel restarted); it keeps its last view and says the notebook stopped it.
A hook raised: the exception is in the log (
cellucid.viewer_channel). The event was still recorded (viewer.state), and the other hooks ran.The selection was not confirmed: moving a lasso without confirming it sends no selection event.
The kernel’s machine could not hold a very large selection. A selection costs 4 bytes a cell on the wire and about 36 bytes a cell as the
list[int]hooks receive.
How to confirm#
viewer.debug_connection():channel_connectedmust beTrue, andrecent_events["by_type"]counts what arrived.Register a raw logger:
@viewer.on_message def _log(event): print(event)
Fix#
Let the cell finish (or interrupt a long one).
Stale output: re-run the cell that creates the viewer.
Very large selections: build highlight groups in smaller steps, and bring them back with
viewer.pull(adata).
Symptom: events arrive, but indices don’t match my AnnData#
Likely causes#
You reordered/subset
adataafter creating the viewer.You are using a different
adataobject than the one you passed toshow_anndata.The viewer opened a subsample of the cells: event cells are still rows of the full
obs(original positions), but only the opened cells can appear.
How to confirm#
selection = viewer.selection
print(selection.cells.min(), selection.cells.max(), len(selection))
print("adata.n_obs:", adata.n_obs)
If max >= adata.n_obs, you are definitely mismatched.
Fix#
Only interpret indices against the exact dataset the viewer is showing.
If you must reorder/subset, create a new viewer from the modified AnnData.
Prefer obs names:
viewer.selection.obs_names.
Symptom: Python actions don’t affect the UI (highlight/color/reset do nothing)#
Likely causes (ordered)#
The viewer refused the action — the field, category, cell or colour was not one it could apply.
The viewer has not connected yet: actions wait in Python and are delivered, in order, once it does (the viewer loads after the cell that showed it ends).
The output you are looking at belongs to another (or a stopped) viewer.
How to confirm#
The action’s request says it:
request.result()(in a later cell) raisesActionErrorwith the viewer’s reason.A refused action also prints to
sys.stderras soon as the viewer reports it:[cellucid] viewer.color_by(...) did not take effect in the viewer: ...
If you missed it, list the refusals after the fact:
viewer.debug_connection()["recent_events"]["command_errors"]
A non-empty list means the action arrived and was refused — fix the argument, not the connection.
viewer.debug_connection()["channel_connected"]says whether a viewer page is connected now.
Fix#
If the action was refused, correct the argument the reason names — most often a field or category name the loaded dataset does not have (the reason lists the ones it has).
If the output is stale, stop that viewer (
viewer.stop()), then create and show a current one.
Prevention#
Send actions from a hook that runs when the viewer is ready:
@viewer.on_ready def _ready(event): viewer.color_by(field={"source": "obs", "key": "cell_type"})
In a script, collect rejections and assert on them at a point you control, rather than reading a printed line:
rejected = [] @viewer.on_command_error def record(event): rejected.append(event) ... assert not rejected, rejected
Symptom: session capture fails#
Likely causes#
In a notebook, it was asked for and collected in the same cell; use
request = viewer.request_session()andrequest.result()in the next cell.The viewer’s channel is not connected.
The temporary directory has less free space than the session;
result()raises “The session bundle could not be saved: …”.
Fix#
Ask again in one cell and collect in the next.
To bring results onto AnnData,
viewer.pull(adata)needs no session.
Browser-tab viewers only#
Symptom: the viewer UI cannot be established or served#
AnnDataViewer(...) / serve_anndata(...) raises while establishing the
viewer’s web build, viewer.display() raises while verifying it, or a page
that was already open reloads to the plain text “Internal server error”.
Likely causes (ordered)
No outbound network access to the configured web source (every server start establishes the build again).
The source inventory or one declared asset failed exact validation.
The selected generation directory is not writable.
The generation was removed after the viewer started (for example
viewer.clear_web_cache()).
How to confirm
In the debug report:
report["web_ui"]["cache"]andreport["viewer_index_probe_error"].Open
<viewer.server_url>/index.htmlin a browser.
Fix
Ensure the Python runtime can reach the configured
web_source_url.Correct the exact inventory, object, MIME, length, hash, or build error shown.
Pass a writable
web_cache_dirwhen the default temporary location is not suitable.Before a shared session, exercise the source with
viewer.ensure_web_ui_cached(force=True).
Symptom: the browser cannot reach the server (remote kernel, HTTPS page)#
Likely causes
The kernel runs on another machine and nothing forwards its port.
A reverse proxy forwards the page, but its host is not declared (
421 Misdirected Request) or it does not pass WebSockets (the viewer says it cannot open its channel).
Fix (recommended order)
In a notebook, use
show_anndata()instead: it needs no address.Use SSH port forwarding with a fixed
port=(Remote servers, SSH tunneling, and cloud).Behind a reverse proxy, pass its exact browser-reachable base as
client_server_url=and its host asallowed_hosts=[...], and make it pass WebSockets (Security: CORS, origins, and mixed content).
Symptom: “Port already in use” / the viewer port changed#
Without
port=, anAnnDataViewertakes 8765, or the next free port when another viewer (or program) holds it, and prints a note saying so (Google Drive files open only on 8765). A port you pass is used or refused, never replaced.Stop viewers when done (
viewer.stop(), orcellucid.jupyter.cleanup_all()), and choose a fixed port for tunnels:from cellucid import AnnDataViewer viewer = AnnDataViewer( "data.h5ad", port=8765, dataset_name="My study", dataset_id="my-study-v1", )
Debugging checklist (browser tab)#
print("server_url:", viewer.server_url)
print("viewer_url:", viewer.viewer_url)
viewer.wait_for_ready(timeout=60)
viewer.debug_connection()
In a browser:
open
<server_url>/_cellucid/healthopen devtools → Network, filter
WS, and select the/_cellucid/wsconnection to see its frames
Next steps#
Reference (schemas + endpoints): Reference (hooks, commands, schemas, endpoints)