tenmol-bridge
The PyMOL side of the tenmol web client. One process, one PyMOL engine, one thread that owns the offscreen OpenGL context and the engine and the draw pump, one local browser, one WebSocket.file:line citation into
packages/engine/layer*/ or packages/engine/modules/. Follow the citation
before changing the line.
Run it
bash scripts/bootstrap.sh builds the venv and PyMOL from this tree if you do
not have one. pnpm dev starts this and the web client together.
GET /healthz reports the whole process model. Measured on a real start:
glutThread == threadIdent is not decoration — see rule 1.
Layout
topics.py is a wave-0 leftover with a 7-topic set and nothing imports it.
session.py is the live mirror; use that.
The four rules
1. The pump calls PyMOL_Draw every tick. cmd.refresh() is not a substitute.
Viewport input is not executed on arrival, it is enqueued: CScene::click,
drag and release all go through OrthoDefer
(packages/engine/layer1/Scene.cpp:4113-4155), as do deferred cmd.png and
deferred cmd.ray. The queue is drained by OrthoExecDeferred
(packages/engine/layer1/Ortho.cpp:268-277), whose only caller is
ExecutiveDrawNow (packages/engine/layer3/Executive.cpp:11521-11523) — and
that call is gated on PyMOL_GetIdleAndReady, i.e. IdleAndReady == 3
(packages/engine/layer5/PyMOL.cpp:105, :2560-2562). IdleAndReady only
increments inside PyMOL_Idle while I->DrawnFlag is set (:2412-2416), and
DrawnFlag is only ever set inside PyMOL_Draw (:2325, :2328). CmdRefresh
never sets it.
A bridge that does not draw therefore drains no clicks, no drags, no
deferred png, no deferred ray, no ModalDraw — silently, with no error and
no log line. engine.py does ≥ 3 warm-up draws before accepting input for the
same reason. This is also why --no-gl needs the client’s RPC camera driver:
raw {t:'input'} is accepted and never applied (measured: a 20-step drag moved
get_view()[2] by exactly 0).
Tick rate: 60 Hz, on absolute deadlines. Measured here,
queue.get(timeout=1/60), time.sleep(1/60) and Event.wait(1/60) all return
after ~22 ms (macOS timer coalescing), so a relative loop tops out near 45 Hz. It
must in any case stay well under 150 ms, because SceneIdle only promotes
press+release into a single click after I->SingleClickDelay = 0.15
(packages/engine/layer1/SceneMouse.cpp:1152).
2. no_gui = 0, SingletonPyMOL, pcatch — and never -c.
OrthoFeedbackIn() is gated on G->Option->pmgui
(packages/engine/layer1/Ortho.cpp:492-499), which is !options.no_gui
(packages/engine/layer1/P.cpp:1820). pymol -c sets no_gui=1
(packages/engine/modules/pymol/invocation.py:401) and the feedback queue is
then dead for the life of the process. Options are snapshotted into
CPyMOLOptions at _cmd._new, so they must be set before start().
pcatch writes through the file-scope SingletonPyMOLGlobals pointer
(packages/engine/layer1/P.cpp:2667): with a non-singleton pymol2.PyMOL() that
pointer is null and every print() is silently discarded — worse than not
installing it. Hence pymol2.SingletonPyMOL, exactly like pmg_qt.
3. The bridge logs to stderr only.
Afterpcatch._install(), the pcatch module is sys.stdout and
sys.stderr. Any print() in this process lands in the user’s PyMOL console.
Use tenmol_bridge.config.log, which writes to the real stderr captured at
import time. tests/test_process_model.py asserts no bridge log line ever
reaches cmd._get_feedback().
4. Exactly one consumer of each destructive drain.
cmd._get_feedback(), cmd.get_setting_updates() and
p.getRedisplay(reset=True) are consume-once. Two interleaved consumers split
the stream at random (measured: consumerA saw: [468 lines], consumerB saw: []). The status thread owns all three; the policy refuses to expose them over
the wire, and nothing else in this process may call them. No pymol.rpc, no
pymol.pymolhttpd, no Qt GUI, no plugin.
_get_feedback() returning None means “locked, retry”, not “no output”
(packages/engine/modules/pymol/internal.py:596-606). get_setting_updates()
returning [] on a lock miss is indistinguishable from “nothing changed”
(packages/engine/modules/pymol/setting.py:440-447) — never build quiescence
detection on it. panels/settings.py is the pattern: it taps the drain once and
publishes a cumulative, cursor-addressed log, so clients can poll slowly and
losslessly instead of racing the status thread.
Two things the plan got wrong; both are implemented the corrected way here
(a)_call_with_opengl_context must NOT be left at its default. The plan says
the default lambda f: f() is already correct once every cmd call runs on the
GL-owning thread. It is not. G->ValidContext is a counter incremented only
inside PyMOL_Draw (PyMOL_PushValidContext,
packages/engine/layer5/PyMOL.cpp:2940-2949, called at :2281/:2303); holding
the context current does not set it, because it is PyMOL’s own flag, not GL
state. So every path gated on G->HaveGUI && G->ValidContext silently does
nothing when reached from a plain cmd call. Measured: cmd.png() with no
explicit size wrote no file, and cmd.mpng(prefix) printed
MoviePNG-Error: Missing rendered image. five times and produced zero PNGs.
shims.py installs marshal-to-engine-thread + _pushValidContext + func() + _popValidContext (both are registered C entry points,
packages/engine/layer4/Cmd.cpp:6379-6380); with it the same two calls produce a
20,428-byte PNG and ['f0001.png','f0002.png','f0003.png']. This is not the
pmg_qt shim — the makeCurrent() half of that is Qt-specific and is not
copied.
(b) cmd.get_view() returns 18 floats, not 25.
packages/engine/modules/pymol/viewing.py:731 slices the C accessor’s 25 down to
r[0:3]+r[4:7]+r[8:11]+r[16:25]. _cmd.get_view() is the 25-float one. [:9]
is the rotation matrix in both.
Also worth knowing: import pymol must happen before anything imports
chempy. pymol/__init__.py:202-210 sets PYMOL_PATH/PYMOL_DATA, and
chempy/__init__.py:267-274 reads them at import time; get the order wrong and
chempy.path is '' forever and cmd.fragment('ala') fails with
FileNotFoundError: 'fragments/ala.pkl'. tests/conftest.py pins the order for
the whole test session.
Protocol v1
tenmol_bridge/session.py is the Python mirror of packages/protocol; the
strings must match exactly.
The binary header is padded so the payload starts 4-byte aligned; that is what
lets the TypeScript decoder return a zero-copy
Float32Array view instead of
memcpy-ing every buffer. Do not regress it.
19 topics, one owner each: feedback progress redisplay pixels view
selection objects menu settings wizard editor dialog frame
scenes movie_panel seqview colors plugin geometry.
Error kinds: CmdException QuietException IncentiveOnly NotAllowed
NotSerializable PythonError PyMOLUnavailable NoOffscreenGL BadMessage
Timeout EngineNotRunning Shutdown. (@tenmol/protocol types the first six;
the rest are bridge-only and arrive as opaque strings on the client.)
The command-echo invalidation channel
Every executed command reports what it invalidated (color / reps /
geometry / coords / names, or resync for do/run/@script) in the
ok frame. This is the only mechanism that can see per-atom colour and
per-atom reps: polling provably cannot. cmd.get_vis() is object-level only —
show spheres, m and name CA leaves it byte-identical while 574 atoms carry the
new rep.
Unicast vs broadcast
_emit_topic is a broadcast and stays one: objects, view, frame,
feedback, progress and the Mode-G invalidation notice are shared state.
What must not go through it is one client’s answer — _bridge.get_geometry and
_bridge.pull_geometry are UNICAST_ROUTES, because broadcasting them makes N
clients each pay for one client’s 360 KB pull. That is why the calling session is
threaded all the way down through dispatch.py.
Security
The boundary is the transport, not a symbol deny-list:- bind
127.0.0.1only (--allow-remoteis refused by default); - a 256-bit token minted at startup, written mode
0600with--token-file, required on/wsand/blob/{id}; - an
Originallow-list (a fixed port range — an ephemeral dev port needs--origin, which is what the e2e harness passes); - a loopback peer check. The precedent is PyMOL’s own HTTP bridge, which hard
rejects non-loopback peers
(
packages/engine/modules/pymol/pymolhttpd.py:61-68).
4401, a rejected origin or peer with 4403.
system, run, cd, quit, _ctrl/_alt/_ctsh and t:'do' are all
allowed. Denying them removed six features from the parity inventory and
bought nothing: this product executes arbitrary local code by design — it is a
desktop replacement for a program with a Python console. What the policy does
instead is check shape (1..3 identifier segments, no dunders), check
namespace against DEFAULT_ROOTS, merge per-owner grants, take one
confirmation for cmd.system, route cmd.quit to bridge shutdown instead of the
C exit() path (which skips atexit and Py_FinalizeEx), and mark every
dangerous call so the UI and the log can show it.
quiet is passed through, never forced to 1: several parity rows depend on
quiet=0 output reaching the console.
Adding a capability
Do not editpolicy/base.py. Drop a file:
grants/*.py in sorted order (by path, with
importlib.util, because a filename with a hyphen is not an importable module
name). A grant file may export GRANT, GRANTS or a callable grants().
The barrels are frozen; the modules next to them are not
panels/__init__.py and state/__init__.py were written once and list a small,
fixed set of names with lazy PEP-562 access. They have drifted from reality on
purpose: panels/PANELS names four modules while panels/ contains fourteen.
That is the design, not a bug — a feature adds panels/<mine>.py and imports it
directly (from .panels.settings import values), which is an import rather than
an edit, so two owners never collide on one file. Do not “fix” the barrel to
match the directory.
Most panels install themselves onto cmd as a cmd.tenmol_* namespace
(tenmol_files, tenmol_compute, tenmol_props, tenmol_plugins,
tenmol_volume, tenmol_shortcuts) so the browser reaches them through ordinary
{t:'call'} frames and no new endpoint is needed.
It runs without PyMOL, and without GL, on purpose
--no-pymol (or TENMOL_BRIDGE_FORCE_NO_PYMOL=1), or simply a machine where
import pymol fails: the engine goes to state degraded, the server still
starts, hello reports "state":"degraded", sub/unsub still work, and every
engine-bound call answers with
--no-gl is the other half and it is the cross-platform thesis made runnable: it
refuses to create a context at all, exactly like a Linux box with no EGL or a
Windows box with no WGL. The console, the RPC surface, cmd.ray and Mode-G
geometry extraction all still work; Mode P and backend picking do not, and the
client is expected to render and pick client-side. apps/web/e2e has one spec
that asserts precisely that, ending with healthz.draws == 0.
Offscreen GL
glcontext/ is dispatched on sys.platform: cgl.py (darwin, CGL legacy 2.1,
no drawable, one FBO), egl.py (linux), wgl.py (win32). The interface is
create_context(width, height) -> Context with .make_current(),
.resize(w, h), .release() and .info() -> dict. A missing backend raises the
typed NoOffscreenGL.
Two rules any backend must honour:
- Contexts are per-thread. Create it on the engine thread. Calling
PyMOL_Drawfrom a thread that does not hold the context segfaults atglGetString(packages/engine/layer5/PyMOL.cpp:2307). - Never regenerate the FBO on resize.
check_gl_stereo_capablelatchesG->ShaderMgr->defaultBackbuffer.framebufferfromGL_FRAMEBUFFER_BINDINGat the first draw (packages/engine/layer5/PyMOL.cpp:2236-2239).resize()re-storages the attachments of the same FBO name.
launchd daemon (as opposed to a per-user agent) may fail; and one benign
driver line — UNSUPPORTED (log once): POSSIBLE ISSUE: unit 0 GLD_TEXTURE_INDEX_2D is unloadable... — appears on every start.
Idle shutdown
A Qt PyMOL quits fromcloseEvent -> cmd.quit(); a browser tab has no
equivalent — it can be closed, crash, or have its machine suspended, and in none
of those cases does anything call cmd.quit. The status thread therefore watches
len(sessions) and calls request_shutdown after --idle-shutdown SECONDS.
It defaults to 0 (never), deliberately. pnpm dev reloads the page
constantly and the test suite shares one engine across long client-free
stretches; an armed watchdog would kill both. It also only arms after a client
has connected at least once. TENMOL_BRIDGE_IDLE_SHUTDOWN sets the default;
--idle-shutdown overrides it. /healthz.liveness shows the whole state.
Tests
packages/engine/test/dat/...
relative to the cwd. pnpm test:bridge does the same thing through
scripts/dev-bridge.sh --exec.
-s (capture off) is mandatory and is in addopts: pytest’s output capture
re-assigns sys.stdout around every test phase, which silently un-installs
pcatch and makes the Python half of the console vanish. conftest.py refuses
to run without it.
pymol2.SingletonPyMOL can only start once per process, so all engine tests
share one session-scoped bridge — a real uvicorn server on a real loopback port,
driven over a real WebSocket. That means test order matters and session state
carries: assert on the object you created, not on all. GL-dependent tests are
marked gl and skip themselves when no context can be created.
test_process_model.py is the acceptance suite for the process model: it drags
the mouse and asserts get_view()[:9] changed, asserts the feedback drain
carries both PyMOL>print(...) and the printed value, runs cmd.mpng and
asserts the engine still answers, asserts glutThread == threadIdent, and
asserts no bridge log line leaks into the console.
test_p11_infra2.py runs the real PEP 517 build hook and reads the wheel and
sdist back with zipfile/tarfile. It exists because setuptools ships *.py
and nothing else unless told: before [tool.setuptools.package-data], a built
wheel held 43 .py files and zero .json, and an installed bridge reported
defaultsSource: null with 0 of 779 settings carrying a default. Monkeypatching
a path inside the source tree cannot see that failure.