Skip to content
Merged
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
60 changes: 60 additions & 0 deletions vpython/_frontend_wait.py
Original file line number Diff line number Diff line change
@@ -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)
74 changes: 74 additions & 0 deletions vpython/test/test_frontend_wait.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions vpython/with_notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading