Skip to content

webgl: bind an OS-assigned ephemeral port for the viewer server - #676

Merged
kroq-gar78 merged 1 commit into
mainfrom
claude/webgl-ephemeral-port
Aug 20, 2026
Merged

webgl: bind an OS-assigned ephemeral port for the viewer server#676
kroq-gar78 merged 1 commit into
mainfrom
claude/webgl-ephemeral-port

Conversation

@mvdoc

@mvdoc mvdoc commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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.

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, so server.get_client() blocked for the full timeout. This is the unhandled thread exception that showed up in run 29051424261 alongside the RuntimeError: Failed to establish WebSocket connection ... within 60 seconds.

Fix: 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 (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 above

Binding in __init__ means stop() has to be safe against a server that hasn't 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. (This is the point gemini-code-assist flagged on #658.)

run() now sets a readiness Event once those attributes exist; stop() waits on it (bounded, short-circuited by is_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 random in view.py and corrects the port docstring, which still promised a random port from 1024–65536.

Testing

Exercised the real WebApp directly (standalone, since a full cortex import needs h5py/Chromium not present in the dev sandbox):

  • port=0 binds a real ephemeral port synchronously and the server serves HTTP on it.
  • An explicit in-use port raises OSError (errno 98) synchronously in the caller — no silent thread death.
  • 30 viewers constructed concurrently from threads got 30 distinct ports — zero collisions.
  • Every 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 check on the touched files reports the same 18 pre-existing findings as main — no new ones.

🤖 Generated with Claude Code

`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>
@kroq-gar78
kroq-gar78 merged commit 112e544 into main Aug 20, 2026
13 checks passed
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>
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.

2 participants