Skip to content

VS Code + Colab frontends, with kernel-side scene replay (#281) - #293

Draft
sspickle wants to merge 21 commits into
masterfrom
feat/vscode-frontend
Draft

VS Code + Colab frontends, with kernel-side scene replay (#281)#293
sspickle wants to merge 21 commits into
masterfrom
feat/vscode-frontend

Conversation

@sspickle

@sspickle sspickle commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What this is

The kernel half of VPython support for VS Code notebooks and Google Colab (#281), plus the scene-replay architecture that makes both reliable. With this branch, VPython works in three hosts:

Host Transport Module
Classic Jupyter Comm down / websocket up (unchanged) with_notebook.py
VS Code whole protocol over the kernel's tornado websocket with_wsfrontend.py
Google Colab whole protocol over Jupyter comms with_colab.py

Status: working end-to-end in both new hosts. Scenes render live — objects, animation via rate(), zoom/orbit interaction, textures; text() with fonts in VS Code — verified on macOS with VS Code 1.133 and in real Colab (including full-brightness scene replay across output-frame re-renders).

VS Code: websocket-only mode

Under VS Code (auto-detected via VSCODE_PID/VSCODE_CWD, overridable with VPYTHON_FRONTEND=ws|jupyter), vpython skips the classic Comm + nbextension machinery and speaks the entire wire protocol over the tornado websocket it already runs, announcing the port with a custom-MIME display output:

application/vnd.vpython.v1+json   {"api": 1, "port": N, "wsuri": "/ws"}

The companion renderer extension — https://github.com/vpython/vscode-vpython — picks that up, loads GlowScript + glowcomm_host.js in the notebook output webview, connects, and drives the standard trigger ping-pong.

Why websocket-only: VS Code's Jupyter extension deprecated third-party access to raw kernel messaging (the ipywidgets comm mechanism), so the classic Comm downlink is unreachable from a renderer extension — but a webview can open a WebSocket to the kernel's tornado server directly (both directions verified in a spike). Moving the downlink onto the websocket the kernel already runs makes the whole protocol renderer-reachable with no Jupyter plumbing at all.

Colab: comm-only mode

Colab is the mirror image: output frames are sandboxed cross-origin iframes that cannot reach the kernel's tornado port (the port-proxy authenticates with Google cookies that don't flow there), but Colab shims Jupyter comms (google.colab.kernel.comms). So with_colab.py runs the whole protocol over a comm instead.

Colab's platform quirks shaped the design (each was found empirically):

  • Browser-initiated handshake: the kernel registers a passive comm target at import; the JS calls comms.open when its frame has actually loaded. (Kernel-initiated opens race Colab's async iframe lifecycle; kernel-side retry loops make Colab drop display outputs.) A per-session nonce freezes out zombie frames from saved outputs.
  • No import-time display: Colab always drops display output emitted during import, so the scene box is shown by an explicit wc.show() call.
  • CDN bootstrap: large inline <script>/script src= in display HTML are silently neutralized; a tiny inline injector loads glowcomm_colab.js (and GlowScript) from jsDelivr instead.
  • Mid-cell browser replies are impossible (replies only arrive between cells), so compound/text/extrusion/scene.pause/waitfor/pick raise a clear NotImplementedError rather than deadlocking; rate() self-clocks its flushes.

Demo notebook: vpython-colab-demo.ipynb.

Scene replay — the reliability architecture

Both new hosts have ephemeral frontends: Colab re-renders output frames on scroll, VS Code can evict webviews, pages reload. The kernel is the only durable holder of the scene, so it now journals it (_scene_journal.py — every constructor cmd and every attribute ever touched, in emission order) and every frontend attach replays: a wire reset cmd (added to glowcomm_host.js), then every cmd in its original order, then the current value of every dirty attribute. Both senders (CommSender, WsSender) share the same replay path (_frontend_replay.py).

Emission order matters — canvas construction emits its constructor, then a lights='empty_list' wipe of glow's built-in defaults, then the two standard distant_lights; replaying out of order deletes the lights and dims the scene (found and fixed during live Colab testing).

Changes

  • vpython/with_wsfrontend.py, vpython/_wssender.py (new): websocket-only bootstrap + kernel→browser sender — buffers packages until a renderer connects, then write-through, marshaling writes onto the tornado thread; replays on reconnect.
  • vpython/with_colab.py, vpython/_commsender.py (new): comm-only bootstrap (passive target, nonce, show(), self-heal) + comm sender with the same buffer/flush/replay contract.
  • vpython/_scene_journal.py, vpython/_frontend_replay.py (new): pure scene journal + replay builder.
  • vpython/vpython_libraries/glowcomm_host.js, glowcomm_colab.js (new): host-agnostic frontend runtime (from Pyodide/wasm support: pure-Python wheel, worker transport, GlowScript host factory #291's factoring, plus the reset cmd) and the Colab bootstrap.
  • vpython/_notebook_helpers.py: three-way frontend detection (_use_ws_frontend(), _is_colab()).
  • vpython/vpython.py: frontend selection, GlowWidget(sender_override=...), journal hooks in appendcmd/addattr/delete, classic per-canvas HTML suppressed when a frontend owns the container.
  • vpython/rate_control.py: direct-trigger flush for hosts whose browser triggers can't reach a blocked kernel.
  • Tests: test_scene_journal.py, test_colabfrontend.py, test_wsfrontend.py — suite: 38 passed.

Not in scope yet

Remote/WSL kernels (needs VS Code port forwarding), one scene container per notebook, widgets/pause/waitfor in ws mode (same deferrals as glowcomm_host), a kernel pump to lift Colab's mid-cell-reply limits.

Draft while the extension hardens — review welcome. Companion PRs: #292 (merged — the loud import timeout these modes gracefully degrade to), #291 (packaging; glowcomm_host.js originates there).

🤖 Generated with Claude Code

https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR

In hosts that never run vpython's injected JavaScript and expose no Comm
channel to third-party renderers (VS Code notebooks), speak the whole
protocol over the kernel's tornado websocket and announce the port via an
application/vnd.vpython.v1+json display output for the VPython VS Code
extension to render. Auto-detected via VSCODE_PID/VSCODE_CWD;
VPYTHON_FRONTEND=ws|jupyter overrides both ways.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
sspickle and others added 19 commits August 16, 2026 15:55
Colab output frames run our JS but no websocket reaches the kernel VM
(port proxy rejects programmatic connections from the sandboxed iframe);
Colab's google.colab.kernel.comms shim is fully duplex. Mirror image of
with_wsfrontend: the whole protocol rides an ipykernel Comm. No blocking
handshake (CommSender buffers until ack), comm-open retried post_execute,
and rate() self-clocks the flush (_direct_trigger) since a blocked kernel
never sees the browser's pacing triggers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
…instead

Their _wait spins for a browser reply that Colab's comm channel can only
deliver between cells: a guaranteed hang. Fail loudly (issue #281 style).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
Re-display the output frame after two unconnected cells; expose show()
for manual recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
Cell-boundary retries race the async bootstrap; an idle-loop task lands
the open+ack+attach within ~1s of any idle moment once the JS is ready.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
The ack for open N arrives during kernel idle — exactly when the retry
loop was closing comm N to open N+1, dropping every ack it provoked.
Leave prior opens alone; the ack that lands picks its comm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
…ms.open)

Only the browser knows when its bootstrap has finished loading, so let IT
open the comm: the kernel registers a passive target before displaying the
bootstrap, and the JS calls google.colab.kernel.comms.open once its
libraries are up. Kernel-initiated retries remain as fallback for shims
without comms.open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
Notebooks saved with outputs replay old bootstrap iframes on reopen; the
zombies call comms.open at the new kernel and fight the live frame for
the scene. Opens must carry the current session's nonce or are closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
Colab silently declines to activate large inline scripts in display
output (small inline scripts and script-src tags run fine — bisected
live). Ship glowcomm_colab.js from the same jsDelivr media/ dir as
GlowScript; the inline part is a one-line boot call carrying cdn+nonce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
A 1 Hz stream of kernel-initiated comm_opens correlates exactly with
Colab silently dropping display_data (the bootstrap box vanished from
import and post_execute alike). The browser-initiated handshake makes
kernel-side retries obsolete anyway; the per-cell post_execute fallback
remains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
The bootstrap box now comes from an explicit wc.show() cell or from the
post_execute self-heal (which fires on the first unconnected cell).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
Colab strips script-src attributes in display HTML (and skips large
inline scripts), but a small inline script may insert external scripts
freely. Verified end-to-end live: render -> boot -> comms.open -> connect
-> flush (connected True, backlog flushed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
Frontends are ephemeral (Colab re-renders output frames on scroll; VS
Code can evict outputs; pages reload). The kernel now journals every
constructor cmd and every attribute ever touched (SceneJournal, hooked
in appendcmd/addattr/delete), and on EVERY attach both senders send
reset + all constructors + current attr values instead of a one-shot
backlog flush. A new wire cmd 'reset' tells glowcomm_host to destroy()
the old scene first. Any frontend instance can now rebuild the world
from nothing — re-renders and reconnects become first-class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
Browsers cache jsDelivr responses for days; a CDN purge cannot reach
disk caches. The session nonce forces one fresh fetch per session for
glowcomm_colab.js and glowcomm_host.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
scene = canvas() constructs with _canvas_constructing=True, which
suppresses the frontend import — so the canvas constructor cmd lands in
the pending updates buffer BEFORE the journal exists, and replays were
missing their canvas (observed: journal had objects but no canvas; every
replayed object failed to construct).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
…journal

They share the object's idx; journal them separately and replay them
after the constructors. Observed live: canvas replaced by a {'title'}
cmd -> every replay was canvasless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
canvas enriches its cmd dict AFTER appendcmd; a record-time copy ships a
bare canvas and glow renders its dim default stage. Store the live
reference; copy at read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
Canvas construction emits: canvas ctor -> lights='empty_list' follow-up
(wiping glow's built-in default lights) -> two distant_light ctors (the
standard lighting). The journal replayed all constructors first and all
follow-ups last, so the lights wipe ran AFTER the standard lights were
recreated, deleting them — replayed scenes rendered ambient-only (dim).

The journal now keeps one emission-ordered log of ctor refs and follow-up
cmds and replays it verbatim after reset. Wire format unchanged; no
frontend JS change needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
@sspickle sspickle changed the title Websocket-only frontend: VPython in VS Code notebooks (#281) VS Code + Colab frontends, with kernel-side scene replay (#281) Aug 17, 2026
@sspickle

Copy link
Copy Markdown
Contributor Author

Folded the Colab work into this PR rather than stacking a second one: the scene-replay architecture (journal + replay-on-attach) spans both new frontends — it changes _wssender.py/with_wsfrontend.py too — so the websocket-only half wasn't reviewable in isolation anyway. The PR now covers both hosts + replay; body rewritten accordingly.

Colab is verified working end-to-end in a real notebook (browser-initiated comm handshake, session-nonce zombie-frame protection, scene replay at full brightness across output-frame re-renders). Demo: https://colab.research.google.com/github/vpython/vscode-vpython/blob/main/colab/vpython-colab-demo.ipynb

Random ports are fine locally, but remote forwards and their visibility
are per-port state that every kernel restart invalidates. Pinning lets a
devcontainer forward and expose the port once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
@sspickle

Copy link
Copy Markdown
Contributor Author

GitHub Codespaces now works too — desktop AND browser VS Code, zero config. The companion extension (vpython/vscode-vpython v0.1.7) gained an extension-host side that relays the wire protocol between the output webview and the kernel's tornado websocket over VS Code renderer messaging — the host dials 127.0.0.1 on the kernel's own machine, so no port forwarding, tunnel auth, or port-visibility changes are needed. Same mechanism covers Remote-SSH/WSL. Kernel-side additions on this branch: VPYTHON_WS_PORT (pin the websocket port) and the existing VPYTHON_CONNECT_TIMEOUT. Verified end-to-end in a real codespace from a browser-only session: open notebook → Run All → live scene (textures, animation, zoom/orbit). With classic Jupyter, VS Code, Colab, and Codespaces all working, this branch now delivers VPython on every major notebook host, including fully browser-only zero-install environments (Chromebooks).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant