webgl: bind an OS-assigned ephemeral port for the viewer server - #676
Merged
Conversation
`WebApp` chose its port with `random.randint(1024, 65536)` and bound it asynchronously on the server thread via `server.listen(port)`. When the random port was already taken, `listen()` raised `OSError: [Errno 98] Address already in use` on that thread and died silently, while `server.start()` had already returned. The caller then pointed its client at a port whose server never came up and blocked until it timed out -- one of the intermittent headless/CI hangs (an unhandled thread exception `OSError: [Errno 98] Address already in use` in serve.py showed up in the job log). Bind the listening socket synchronously in `WebApp.__init__` via `bind_sockets(0)`, letting the OS hand out a guaranteed-free ephemeral port (read back into `self.port`). Bind failures now raise in the caller, visibly, instead of dying on the server thread; the pre-bound socket also removes the connect race, since the OS backlogs connections before the IOLoop starts. `show()` now requests port 0, and many simultaneous viewers are collision-free -- each holds its own kernel-reserved ephemeral port for its lifetime. Binding in `__init__` means `stop()` needs to be safe against a server that has not finished starting: it reads `self.server` / `self.ioloop`, which `run()` sets on the server thread, so an early `stop()` could hit an `AttributeError`, be lost entirely, or leak the bound sockets if the server never started. `run()` now sets a readiness `Event` once those attributes exist; `stop()` waits on it (bounded, short-circuited by `is_alive()`) before stopping the server and loop, and closes the bound sockets itself if the server never came up. Verified against the real `WebApp`: an ephemeral bind serves HTTP; an explicit in-use port raises `OSError` synchronously in the caller; 30 concurrent viewers get 30 distinct ports; and every `stop()` path (normal, never-started, concurrent start+immediate-stop) is clean with no leak. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 20, 2026
mvdoc
added a commit
to mvdoc/pycortex
that referenced
this pull request
Aug 21, 2026
* origin/main: (44 commits) WebGL viewer: make opacity slider and `o` toggle work for Vertex data (gallantlab#685) docs(mapper): add get_mapper docstring (gallantlab#686) ENH add types for dataset classes (BrainData, Dataview, ...) (gallantlab#669) Allow viewer data to be updated in real-time (gallantlab#675) FIX: fix minor bug in handling of vmin and vmax when not specified (replaces old cast code) (gallantlab#681) docs: correct the sulcus install instructions, which destroyed existing sulci (gallantlab#657) WebGL viewer: fix help-menu shortcut display (gallantlab#642) MNT ignore docs build outputs written into the source tree (gallantlab#680) DOC fix build warnings (gallantlab#673) webgl: bind an OS-assigned ephemeral port for the viewer server (gallantlab#676) ENH add types for Database and transforms (gallantlab#667) MNT remove obsolete Python 2 syntax, add some types (gallantlab#666) Bump JamesIves/github-pages-deploy-action from 4.8.0 to 4.9.0 (gallantlab#671) Bump actions/setup-python from 6 to 7 (gallantlab#661) docs: document in-browser sulcus drawing (pycortex-roidraw v0.4.0) (gallantlab#656) ENH Pure-python surf2surf matrix (direct nnfr construction) (gallantlab#651) Bump actions/cache from 5 to 6 (gallantlab#654) webgl: fix overlay/label async texture-bake races (complete) (gallantlab#653) docs: add in-browser ROI drawing (pycortex-roidraw) page (gallantlab#652) FIX: update BuWtRd and BuWtRd_alpha colormaps to have pure white at center, more sensible for biphasic data (gallantlab#649) ... # Conflicts: # cortex/quickflat/composite.py # cortex/utils.py # cortex/webgl/resources/js/shaderlib.js # cortex/webgl/view.py
alexhuth
pushed a commit
that referenced
this pull request
Aug 21, 2026
`WebApp` chose its port with `random.randint(1024, 65536)` and bound it asynchronously on the server thread via `server.listen(port)`. When the random port was already taken, `listen()` raised `OSError: [Errno 98] Address already in use` on that thread and died silently, while `server.start()` had already returned. The caller then pointed its client at a port whose server never came up and blocked until it timed out -- one of the intermittent headless/CI hangs (an unhandled thread exception `OSError: [Errno 98] Address already in use` in serve.py showed up in the job log). Bind the listening socket synchronously in `WebApp.__init__` via `bind_sockets(0)`, letting the OS hand out a guaranteed-free ephemeral port (read back into `self.port`). Bind failures now raise in the caller, visibly, instead of dying on the server thread; the pre-bound socket also removes the connect race, since the OS backlogs connections before the IOLoop starts. `show()` now requests port 0, and many simultaneous viewers are collision-free -- each holds its own kernel-reserved ephemeral port for its lifetime. Binding in `__init__` means `stop()` needs to be safe against a server that has not finished starting: it reads `self.server` / `self.ioloop`, which `run()` sets on the server thread, so an early `stop()` could hit an `AttributeError`, be lost entirely, or leak the bound sockets if the server never started. `run()` now sets a readiness `Event` once those attributes exist; `stop()` waits on it (bounded, short-circuited by `is_alive()`) before stopping the server and loop, and closes the bound sockets itself if the server never came up. Verified against the real `WebApp`: an ephemeral bind serves HTTP; an explicit in-use port raises `OSError` synchronously in the caller; 30 concurrent viewers get 30 distinct ports; and every `stop()` path (normal, never-started, concurrent start+immediate-stop) is clean with no leak. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Split out of #659 — this is the port half. The other half (the interpreter-exit deadlock in
headless_viewer) is in a separate PR; the two are independent and can land in either order.WebAppchose its port withrandom.randint(1024, 65536)and bound it asynchronously on the server thread viaserver.listen(port). When the random port was already taken,listen()raisedOSError: [Errno 98] Address already in useon that thread and died silently, whileserver.start()had already returned. The caller then pointed its client at a port whose server never came up, soserver.get_client()blocked for the full timeout. This is the unhandled thread exception that showed up in run 29051424261 alongside theRuntimeError: Failed to establish WebSocket connection ... within 60 seconds.Fix: bind the listening socket synchronously in
WebApp.__init__viabind_sockets(0), letting the OS hand out a guaranteed-free ephemeral port (read back intoself.port). Bind failures now raise in the caller, visibly, instead of dying on the server thread; the pre-bound socket also removes the connect race (the OS backlogs connections before the IOLoop starts).show()now requests port 0. As a side benefit, many simultaneous viewers are collision-free — each holds its own kernel-reserved ephemeral port for its lifetime.stop()safety, required by the aboveBinding in
__init__meansstop()has to be safe against a server that hasn't finished starting: it readsself.server/self.ioloop, whichrun()sets on the server thread, so an earlystop()could hit anAttributeError, be lost entirely, or leak the bound sockets if the server never started. (This is the point gemini-code-assist flagged on #658.)run()now sets a readinessEventonce those attributes exist;stop()waits on it (bounded, short-circuited byis_alive()) before stopping the server + loop, and closes the bound sockets itself if the server never came up. This is kept in the same PR because it exists solely to make the__init__-time bind safe — landing the bind without it would knowingly reintroduce the leak.Also drops the now-unused
import randominview.pyand corrects theportdocstring, which still promised a random port from 1024–65536.Testing
Exercised the real
WebAppdirectly (standalone, since a fullcorteximport needsh5py/Chromium not present in the dev sandbox):port=0binds a real ephemeral port synchronously and the server serves HTTP on it.OSError(errno 98) synchronously in the caller — no silent thread death.stop()path — normal serve→stop, never-started→stop (closes the socket, no leak, returns immediately), and 30× concurrent start+immediate-stop — is clean with no errors.ruff checkon the touched files reports the same 18 pre-existing findings asmain— no new ones.🤖 Generated with Claude Code