From 5774de4a6c6c9136f7aed4f5642afb1cef7b4340 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Sun, 16 Aug 2026 07:14:39 -0400 Subject: [PATCH] fix: import vpython no longer hangs forever when no frontend connects (#281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notebook path busy-waited unboundedly on the websocket handshake at import time. Frontends that never run vpython's injected JavaScript — VS Code notebooks, Google Colab — therefore hung the kernel silently inside 'import vpython'. Now a bounded wait (default 30s, VPYTHON_CONNECT_TIMEOUT to override) raises a RuntimeError that names the detected environment (VS Code / Colab / generic), points at working environments, and links the tracking issue. Pure-python helper, fully unit tested. --- vpython/_frontend_wait.py | 60 ++++++++++++++++++++++++ vpython/test/test_frontend_wait.py | 74 ++++++++++++++++++++++++++++++ vpython/with_notebook.py | 7 ++- 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 vpython/_frontend_wait.py create mode 100644 vpython/test/test_frontend_wait.py diff --git a/vpython/_frontend_wait.py b/vpython/_frontend_wait.py new file mode 100644 index 0000000..e1e97da --- /dev/null +++ b/vpython/_frontend_wait.py @@ -0,0 +1,60 @@ +"""Bounded wait for the browser-side (glowcomm) frontend to connect. + +Historically `import vpython` in a notebook busy-waited forever on the +websocket handshake. In any frontend that does not run vpython's injected +JavaScript — VS Code notebooks and Google Colab are the common ones — that +loop never exits and the kernel appears to hang (issue #281). A loud, prompt +error beats a silent forever. + +Injected clock/sleep/environ keep this unit-testable without a kernel. +""" +import os +import time + +DEFAULT_TIMEOUT = 30.0 # seconds; generous for slow nbextension first-runs +_POLL = 0.1 + + +def _timeout_from(environ): + raw = environ.get('VPYTHON_CONNECT_TIMEOUT') + if raw: + try: + value = float(raw) + if value > 0: + return value + except ValueError: + pass + return DEFAULT_TIMEOUT + + +def _environment_note(environ): + if 'VSCODE_PID' in environ or 'VSCODE_CWD' in environ: + return ("It looks like this kernel was started by VS Code. VS Code's " + "notebook UI does not run vpython's frontend JavaScript, so " + "VPython cannot display there yet.") + if 'COLAB_RELEASE_TAG' in environ or 'COLAB_GPU' in environ: + return ("It looks like this is Google Colab. Colab's output sandbox " + "does not run vpython's frontend JavaScript, so VPython " + "cannot display there yet.") + return ("The notebook frontend never ran vpython's JavaScript. This " + "happens in notebook UIs that do not support Jupyter " + "nbextensions/labextensions.") + + +def wait_for_frontend(is_connected, environ=None, _time=time.time, + _sleep=time.sleep): + """Poll `is_connected()` until true, or raise RuntimeError on timeout.""" + environ = os.environ if environ is None else environ + timeout = _timeout_from(environ) + deadline = _time() + timeout + while not is_connected(): + if _time() >= deadline: + raise RuntimeError( + "no VPython frontend connected after {:.0f}s.\n{}\n" + "VPython works in Jupyter Notebook and JupyterLab opened in a " + "web browser. VS Code support is tracked at " + "https://github.com/vpython/vpython-jupyter/issues/281 .\n" + "(If your environment is just slow to start, raise the limit " + "with the VPYTHON_CONNECT_TIMEOUT environment variable.)" + .format(timeout, _environment_note(environ))) + _sleep(_POLL) diff --git a/vpython/test/test_frontend_wait.py b/vpython/test/test_frontend_wait.py new file mode 100644 index 0000000..a45dd81 --- /dev/null +++ b/vpython/test/test_frontend_wait.py @@ -0,0 +1,74 @@ +"""The frontend-connect wait must never hang forever (issue #281). + +Pure-python tests: the helper takes its clock, sleeper, and environment as +parameters, so no kernel, websocket, or notebook is involved. +""" +import pytest + +from vpython._frontend_wait import wait_for_frontend, DEFAULT_TIMEOUT + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def time(self): + return self.now + + def sleep(self, dt): + self.now += dt + + +def test_returns_as_soon_as_the_frontend_connects(): + clock = FakeClock() + flips_at = 1.0 + wait_for_frontend(lambda: clock.now >= flips_at, + environ={}, _time=clock.time, _sleep=clock.sleep) + assert clock.now < flips_at + 1.0 # returned promptly, no full-timeout burn + + +def test_raises_instead_of_hanging_when_nothing_ever_connects(): + clock = FakeClock() + with pytest.raises(RuntimeError) as exc: + wait_for_frontend(lambda: False, + environ={}, _time=clock.time, _sleep=clock.sleep) + msg = str(exc.value) + assert 'no VPython frontend connected' in msg + assert 'vpython-jupyter/issues/281' in msg + assert 'Jupyter' in msg # points at an environment that works + + +def test_message_names_vscode_when_running_under_vscode(): + clock = FakeClock() + with pytest.raises(RuntimeError) as exc: + wait_for_frontend(lambda: False, + environ={'VSCODE_PID': '123'}, + _time=clock.time, _sleep=clock.sleep) + assert 'VS Code' in str(exc.value) + + +def test_message_names_colab_when_running_under_colab(): + clock = FakeClock() + with pytest.raises(RuntimeError) as exc: + wait_for_frontend(lambda: False, + environ={'COLAB_RELEASE_TAG': 'x'}, + _time=clock.time, _sleep=clock.sleep) + assert 'Colab' in str(exc.value) + + +def test_timeout_is_overridable_via_environment(): + clock = FakeClock() + with pytest.raises(RuntimeError): + wait_for_frontend(lambda: False, + environ={'VPYTHON_CONNECT_TIMEOUT': '2'}, + _time=clock.time, _sleep=clock.sleep) + assert clock.now < DEFAULT_TIMEOUT # honored the shorter override + + +def test_bogus_override_falls_back_to_default(): + clock = FakeClock() + with pytest.raises(RuntimeError): + wait_for_frontend(lambda: False, + environ={'VPYTHON_CONNECT_TIMEOUT': 'soon'}, + _time=clock.time, _sleep=clock.sleep) + assert clock.now >= DEFAULT_TIMEOUT diff --git a/vpython/with_notebook.py b/vpython/with_notebook.py index f232232..d320ed9 100644 --- a/vpython/with_notebook.py +++ b/vpython/with_notebook.py @@ -150,8 +150,11 @@ def start_server(): t.start() # Setup Comm Channel and websocket baseObj.glow = GlowWidget(wsport=__SOCKET_PORT, wsuri='/ws') -while (not wsConnected): - time.sleep(0.1) # wait for websocket to connect +# Bounded wait (issue #281): in frontends that never run vpython's injected +# JavaScript — VS Code notebooks, Colab — the old unbounded loop hung the +# kernel forever inside `import vpython`. Fail loudly instead. +from ._frontend_wait import wait_for_frontend +wait_for_frontend(lambda: wsConnected) baseObj.trigger() # start the trigger ping-pong process