From 76a39eb6f125bd00b2bbd78bddf4c7f6f6128a55 Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Tue, 1 Sep 2026 01:12:20 -0700 Subject: [PATCH] Open the webgl viewer at localhost rather than the machine's hostname show() built the viewer URL from socket.gethostname(), then handed it to webbrowser.open(). That browser runs on the same machine as the server, so the hostname buys nothing there, and on macOS it actively gets in the way: with scutil HostName unset, gethostname() returns the mDNS name ("mymac.local"), which resolves to a long list of addresses -- two link-local ones with no usable scope among them -- and reaches the server only if the application firewall lets the python process accept connections on a non-loopback interface. The auto-opened tab then fails for reasons that look nothing like a pycortex problem. The webgl.domain_name config option cannot help, since it is appended to the hostname rather than replacing it. Build both URLs instead. localhost is what gets opened and what is printed first, matching what headless.py already does; the hostname URL is still printed (and still shown in the IPython link) for handing to someone on another machine, so remote setups relying on domain_name keep working. The port line now comes with a full URL, which is what you need anyway when the browser does not open on its own. Co-Authored-By: Claude Opus 5 --- cortex/tests/test_webgl_view.py | 42 +++++++++++++++++++++++++++++++++ cortex/webgl/view.py | 37 ++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 cortex/tests/test_webgl_view.py diff --git a/cortex/tests/test_webgl_view.py b/cortex/tests/test_webgl_view.py new file mode 100644 index 000000000..39dbf5802 --- /dev/null +++ b/cortex/tests/test_webgl_view.py @@ -0,0 +1,42 @@ +"""Tests for the viewer plumbing in cortex.webgl.view that needs no browser.""" + +import pytest + +from cortex.webgl import serve, view + + +def test_local_url_is_localhost(monkeypatch): + """The URL opened on this machine must not depend on the hostname. + + socket.gethostname() returns the mDNS name on macOS, which resolves to a + list of addresses (link-local ones included) and only reaches the server if + the firewall lets python accept connections on a non-loopback interface. + """ + monkeypatch.setattr(serve, "hostname", "mymac.local") + monkeypatch.setattr(view, "domain_name", "") + + local, network = view._viewer_urls(39140) + assert local == "http://localhost:39140/mixer.html" + assert network == "http://mymac.local:39140/mixer.html" + + +def test_network_url_appends_configured_domain(monkeypatch): + """webgl.domain_name is appended to the hostname, and never to localhost.""" + monkeypatch.setattr(serve, "hostname", "mymac") + monkeypatch.setattr(view, "domain_name", ".example.org") + + local, network = view._viewer_urls(8080) + assert local == "http://localhost:8080/mixer.html" + assert network == "http://mymac.example.org:8080/mixer.html" + + +@pytest.mark.parametrize("hostname", ["localhost", "mymac.local", "mymac"]) +def test_urls_agree_on_port_and_path(monkeypatch, hostname): + """Both URLs address the same running server.""" + monkeypatch.setattr(serve, "hostname", hostname) + monkeypatch.setattr(view, "domain_name", "") + + local, network = view._viewer_urls(1234) + for url in (local, network): + assert url.startswith("http://") + assert url.endswith(":1234/mixer.html") diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index da506235f..f24e4f3c6 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -41,6 +41,26 @@ colormaps = [(os.path.splitext(os.path.split(cm)[1])[0], serve.make_base64(cm)) for cm in sorted(colormaps)] + +def _viewer_urls(port: int) -> tuple[str, str]: + """Return the (local, network) URLs of a viewer running on `port`. + + The local URL is the one to open on the machine running the server. The + network URL is built from the machine's hostname plus the configured + ``webgl.domain_name``, and is the one to hand to someone else. + + They have to be kept apart, because the hostname is not a dependable way + for this machine to reach itself. ``socket.gethostname()`` returns the mDNS + name on macOS (``mymac.local``), which resolves to a whole list of + addresses -- link-local ones among them -- and only reaches the server if + the local firewall lets this python process accept connections on a + non-loopback interface. None of that applies to localhost. + """ + local = "http://localhost:%d/mixer.html" % port + network = "http://%s%s:%d/mixer.html" % (serve.hostname, domain_name, port) + return local, network + + def make_static( outpath, data, @@ -1032,17 +1052,28 @@ def get_local_client(self): port) server.start() + local_url, network_url = _viewer_urls(server.port) print("Started server on port %d"%server.port) - url = "http://%s%s:%d/mixer.html"%(serve.hostname, domain_name, server.port) + if network_url == local_url: + print("Open the viewer at %s"%local_url) + else: + print("Open the viewer at %s (from another machine: %s)" + %(local_url, network_url)) if open_browser: - webbrowser.open(url) + # This runs on the same machine as the server, so localhost is both + # correct and the most reliable thing to hand the browser. + webbrowser.open(local_url) client = server.get_client() client.server = server return client elif display_url: try: from IPython.display import HTML, display - display(HTML('Open viewer: {0}'.format(url))) + link = 'Open viewer: {0}'.format(local_url) + if network_url != local_url: + link += (' (from another machine: ' + '{0})'.format(network_url)) + display(HTML(link)) except: pass