Architecture
How tenmol works. This describes the system as built; where a detail lives in one file, that file is named instead of restated. tenmol is a fork of PyMOL whose Qt front-end has been replaced by a React app talking to a Python bridge. The C++/Python engine is kept and driven, not reimplemented: every molecule, surface, setting and ray-traced image is produced by the same code desktop PyMOL runs. Deployment model: local desktop replacement. One PyMOL process, one browser, onlocalhost,
with full filesystem access. Not a multi-tenant server. This is load-bearing — the bridge executes
arbitrary local code by design (see §6 The capability policy).
1. Process and threads
One process. Three threads, with strict rules about which may touchcmd.
cmd is guarded by an API lock, and three of its drains fail
silently under contention rather than blocking: cmd._get_feedback() returns None
(packages/engine/modules/pymol/internal.py) and cmd.get_setting_updates() returns []
(packages/engine/modules/pymol/setting.py) when lock_attempt fails. Worse, all viewport input
is queued through OrthoDefer with captured timestamps
(packages/engine/layer1/Scene.cpp:4113-4155), so reordering input corrupts drag state. A single
ordered thread makes both problems structurally impossible.
Why the status thread exists anyway. Those three calls are the only ones in the API that
attempt the lock instead of taking it, so they are the only ones safe to make from a second
thread. Running them off the engine thread is what keeps the console alive during a long ray.
packages/bridge/README.md is the reference for this layer. The bridge’s Python package is
packages/bridge/tenmol_bridge/, and module paths below are written relative to it.
2. Boot order, and why it is not negotiable
Engine.boot() (packages/bridge/tenmol_bridge/engine.py) runs these steps in this order, on the
engine thread. Each one has a failure mode that is silent if it is skipped or reordered.
- Create the GL context first, before
SingletonPyMOL().start(). PyMOL adopts whatever framebuffer is bound at its first draw —check_gl_stereo_capablereadsGL_FRAMEBUFFER_BINDINGintoG->ShaderMgr->defaultBackbuffer.framebuffer(packages/engine/layer5/PyMOL.cpp:2236-2239) — so our FBO must already be current on this thread. options.no_gui = 0. Options are snapshotted intoCPyMOLOptionsat_cmd._new(packages/engine/layer1/P.cpp:1800-1830); setting them afterwards does nothing.no_gui = 1setspmgui = 0, andOrthoFeedbackInthen drops every console message for the life of the process (packages/engine/layer1/Ortho.cpp:492-499). Never launch with-c/-cq.internal_gui,internal_feedbackandexternal_guiare set to0here so that window coordinates equal viewport coordinates; with the defaults on,reshape(640,480)yieldsget_viewport() == (420,462)and every mouse coordinate the browser sends is wrong.pymol2.SingletonPyMOL, neverpymol2.PyMOL.pcatchwrites through the file-scopeSingletonPyMOLGlobalspointer (packages/engine/layer1/P.cpp:2667); with a non-singleton that pointer is null and everyprint()is silently discarded.- Install
pcatch, so Python-origin output lands in the same line buffer as C-origin output, correctly interleaved. pymol.glutThread = <engine thread ident>.locking.is_gui_thread()isgui_ident is None or gui_ident == get_ident()(packages/engine/modules/pymol/locking.py), andpymol.glutThreadis module-levelNonewhichSingletonPyMOL.start()never sets. Without this line every thread is “the GUI thread” and the ordering guarantee is fiction.cmd.set('movie_panel', 0), on top of step 2:OrthoReshapealso subtractsMovieGetPanelHeight()(packages/engine/layer1/Ortho.cpp:2383-2390), so the moment any object has more than one state the viewport silently loses ~15 px against the window.- At least three warm-up draws.
IDLE_AND_READY == 3(packages/engine/layer5/PyMOL.cpp:105) andIdleAndReadyonly increments whenDrawnFlagis set, which only happens insidePyMOL_DrawWithoutLock. Until it is reached,OrthoExecDeferrednever runs and the first click is swallowed.
degraded or
headless rather than exiting; the front end has explicit handling for both, and
TENMOL_BRIDGE_FORCE_NO_PYMOL=1 exercises the path on a machine that does have PyMOL.
3. The pump draws every tick
Pump._run drains the command FIFO, then calls engine.tick(), which is p.idle() followed by
p.draw(). draw() is mandatory, not an optimisation.
Mouse input into the scene is not executed on arrival — it is queued. CScene::click, ::drag and
::release all go through OrthoDefer (packages/engine/layer1/Scene.cpp:4113-4155), and the
queue’s only drain is OrthoExecDeferred, whose only caller in the whole tree is
ExecutiveDrawNow. draw() is also what clears I->ModalDraw (packages/engine/layer5/PyMOL.cpp:2279-2286),
which cmd.mpng sets and which otherwise makes every subsequent API call a no-op forever, and what
runs SeqUpdate for the sequence viewer (packages/engine/layer1/Ortho.cpp:1470-1478,1882).
A bridge that never draws returns success from every input call and does nothing.
4. Offscreen GL
packages/bridge/tenmol_bridge/glcontext/ provisions a real hardware context with no window, one
FBO (colour + depth renderbuffers), on the thread that will own PyMOL.
Two rules every backend honours, both stated in that package’s
__init__.py: contexts are per-thread
(calling PyMOL_Draw from a thread that does not hold the context segfaults at glGetString), and
resize re-storages the attachments of the same FBO name rather than regenerating it, because
PyMOL latched that name on its first draw.
A missing backend raises NoOffscreenGL; backends are imported lazily so the package imports
cleanly everywhere.
5. The wire
One WebSocket atws://127.0.0.1:8765/ws, plus GET /healthz and GET /blob/{id} on the same
port. packages/protocol is the contract and has zero runtime dependencies; packages/bridge’s
session.py is its Python mirror, and the two are frozen together.
-
Text frames carry JSON control and RPC. Client → server:
call | do | input | sub | unsub | ack. Server → client:hello | ok | err | event | feedback. -
Binary frames carry bulk payloads — Mode G geometry and Mode P pixels:
The header is padded so
4 + header_lengthis a multiple of the alignment constant. That is what lets the TypeScript decoder hand back a zero-copyFloat32Arrayview instead of copying every buffer, and it is easy to regress: object namesa/ab/abcwere measured putting the payload at byte 237/238/239.
PROTOCOL_VERSION bump plus a bridge change in the same commit. Adding
a topic is not — topics are the extension point, one module per topic under
packages/protocol/src/topics/, with a frozen barrel and registry.
6. The capability policy
Not a deny-list.packages/bridge/tenmol_bridge/policy/ grants capabilities, and its module
docstring explains why: every symbol an earlier design proposed denying (run, cd, system,
quit, the _ctrl/_alt chord helpers, and raw command lines from the UI) is required by a
feature in the parity inventory. Every pymol.menu popup leaf and every wizard button returns a
command string (packages/engine/layer4/PopUp.cpp:471-475), so restricting raw command lines to
the console would have made the popup engine and the wizards unimplementable.
The security boundary is the transport: bind 127.0.0.1 only, a 256-bit token minted at
startup and written mode 0600, an Origin allow-list, and a loopback peer check. What the policy
still does is shape-check dotted names (1–3 segments, no __dunder__), require a known namespace
root, require an explicit grant for any private interior segment, mark calls dangerous, and
route rather than execute a few of them (quit becomes a bridge shutdown, not the C exit()).
Work packages added grants as policy/grants/wp-NN.py; the loader merges them, so no shared file
ever had two writers.
7. Change detection
There is no event bus in the C core. Change detection is a poll plus a command-echo channel, and the split matters:- The state tick (
tenmol_bridge/state/) snapshots names, enabled, groups, view, frame, state, scenes, vis, movie and wizard at 30 Hz (4 Hz when the tab is hidden) and diffs it into topic payloads. Measured on a 52,569-atom, 11-object scene: median 67.7 µs per tick, 0.25 % of one core including the status thread, zero false positives over 300 idle ticks.cmd.count_atoms()is banned from the tick — 5,902 µs for a selection at 500k atoms — so selection counts are a debounced client request instead. - Polling cannot see per-atom state.
cmd.get_vis()is object-level only:show spheres, m and name CAleaves it byte-identical while 574 atoms carry the rep, and per-atom colour is equally invisible. Those changes arrive on the invalidation channel instead — every executed command reports its invalidation classes with its result, fromtenmol_bridge/dispatch.py. - Feedback, settings and progress are genuine drains that clear as they are read, so the bridge is their sole consumer. A second reader silently steals updates.
_cmd.web_get_versionsreads four monotonic counters added toCExecutive— panel, enable, name and rep (see section 10). They are hints for the geometry cache, not content hashes.
8. The viewport: two render modes
Mode P (
tenmol_bridge/render/framestream.py) reads the FBO back in a tick hook, after the
draw. Its dirty gate is a pre-tick probe of PyMOL_GetRedisplay(reset=1), not a post-tick one,
because PyMOL_Draw clears RedisplayFlag at packages/engine/layer5/PyMOL.cpp:2331 before
calling ExecutiveDrawNow — a hook that polls it after the draw sees False for every real
change and produces a viewport that never updates. Frames are JPEG while the camera moves and
lossless once it settles, and they are dropped, never queued: at most N un-acked frames per
client, with a timeout so a client that stops acking degrades to “no flow control” rather than to a
frozen viewport.
Mode G (tenmol_bridge/render/modeg.py) serves _cmd.web_get_rep_geometry — the C++ accessor
in packages/engine/layer4/CmdWebGeometry.cpp — reframed as indexed-mesh or CGO-draw-arrays binary
frames, keyed per object / rep / state. Two invariants it must not break: impostor reps (spheres,
nb_spheres, dots, ellipsoids) ship as instance buffers and never as triangles, and the draw-arrays
block is rebuilt verbatim in the layout of packages/engine/layer1/CGO.cpp:1650-1671, re-inserting
the pick slot the accessor skips, or every sub-array after it is silently shifted.
The two composite: Mode P blits into a 2-D canvas, Mode G draws into a transparent WebGL2
canvas stacked on top, both clipped to PyMOL’s scene rectangle. packages/viewport/README.md
covers the client half; an e2e spec pins the bridge’s draw counter at zero while dragging and
picking in pure Mode G.
9. Input and picking
Pointer, wheel and pinch events are forwarded 1:1 and in order as{t:'input'} frames and executed
by the engine on the next tick. The camera is polled, not predicted: cmd.get_view() costs about
2 µs backend-side and the client cannot compute it itself, because the mouse bindings that produce
it (the three virtual_trackball formulations, mouse_scale/mouse_limit clamping) live in the C
core. One request is in flight at a time, so during a drag the Mode-G camera tracks at loopback
round-trip rate.
Picking is backend-authoritative whenever the backend can draw. PyMOL picks by rendering a
colour-index pass and reading back one pixel (packages/engine/layer1/ScenePicking.cpp), which
works because the bridge has a real GL context — including rectangle selection via SceneMultipick
and the ButMode routing that sends a click to the editor in editing mode
(packages/engine/layer1/SceneMouse.cpp:404-470).
packages/viewport/src/picking/ is the fallback for a backend that cannot draw: a client-side
raycast against the same geometry Mode G already holds. It is less accurate than PyMOL’s own pass,
and viewport.ts skips it entirely while the server is rasterising. A pick — from either path —
is offered to registered routes most-recent-first, so an armed wizard or the Builder consumes it
before the default “rewrite sele” behaviour runs.
10. What this fork changes in the engine
packages/engine/ is upstream PyMOL and is treated as read-only, with the exceptions below. Every
C++ edit is wrapped in /* tenmol web client -- BEGIN */ … /* -- END */ sentinels so it can be
found and re-applied by hand after an upstream merge.
Nothing else under
packages/engine/ is edited, and packages/engine/ is self-contained: its
setup.py uses paths relative to itself and builds without knowing this repo exists.
10b. Two backends behind one interface
The description above is the remote backend: drive the real C++/Python engine over the wire. There is now a second backend — a TypeScript port of the engine that runs in the browser — and the app chooses between them abstractly. The seam is@tenmol/backend’s Backend interface (packages/backend/src/backend.ts): the exact
surface the app used from the socket client — call, do, sub/unsub, events, input, lifecycle.
Two implementations satisfy it:
createCmd() needs only call+do, so the whole cmd façade — and @tenmol/stores,
@tenmol/viewport and every feature — is backend-agnostic. apps/web/src/app/session.ts is the one
place that constructs a backend, from config.backend (apps/web/src/app/config.ts
resolveBackendKind): the subdomain the page is served on picks the engine (a ts./engine.
host → local, a pymol./bridge. host → remote), overridable with ?backend= or
VITE_TENMOL_BACKEND for dev/CI/the parity harness.
The TypeScript engine emits the identical @tenmol/protocol topic payloads and Mode-G binary frames,
so the existing three.js renderer draws its geometry unchanged (the viewport defaults to Mode G for
the local backend, since it has no offscreen GL to rasterise Mode P). Its 1-to-1 parity with real
PyMOL over the ported command slice is proven by the differential suite in tools/parity — see
docs/engine-port.md.
11. The client
apps/web/src/features/registry.ts, so a feature directory is
self-contained and the shell never imports one directly. packages/viewport imports no React;
apps/web/src/features/viewport is its binding.
PyMOL draws its own “internal GUI” — object panel, movie panel, mouse-mode block, wizard panel and
prompt, scene buttons, command prompt, feedback scrollback, busy box, splash, selection marquee —
as 2-D blocks inside the GL viewport, stacked by OrthoLayoutPanel()
(packages/engine/layer1/Ortho.cpp:2261-2340). In this client all of them are DOM. That is the
largest structural difference from the original, and it is why internal_gui_width,
internal_gui_control_size and internal_gui_mode are honoured as CSS hints for .pse
round-trip parity rather than as layout drivers.
Each package README documents its own internals: packages/bridge/README.md,
packages/protocol/README.md, packages/stores/README.md, packages/viewport/README.md,
apps/web/README.md, apps/web/e2e/README.md.
12. Limits
Things that are permanently Mode P, because they have no Mode G expression. The authoritative list isMODE_G_CAPABLE_REPS in packages/protocol/src/geometry.ts; everything not in it falls back
with a stated reason, because a silently empty screen is exactly what the exporters produce.
Accepted differences from the Qt front-end:
- Quality dips during motion. Mode P sends JPEG while the camera moves; thin lines, labels and ray-trace outlines are momentarily softened during a drag.
cmd.viewport w,hcannot resize the window. A browser cannot resize itself; it resizes the canvas and reports the achieved size back, which is observably different for scripts.- Single-click latency has a ~150 ms floor, imposed by
I->SingleClickDelay(packages/engine/layer1/SceneMouse.cpp:1152). Not fixable client-side. - Some keyboard chords are the browser’s. PyMOL binds
CTRL-T,CTRL-F,CTRL-N,CTRL-W; several cannot bepreventDefault-ed in a normal tab. - Two stereo modes cannot cross the wire. A mode arrives if both eyes fit in one 2-D image:
anaglyph, cross-eye, wall-eye and Zalman by-row all do, and all work in Mode P.
quadbufferwants a second GL colour buffer andopenvrwants a head-mounted display; the frame transport is one read off a single FBO encoded as one image, so neither can ever arrive. Neither is available in Mode G at all — seeapps/web/src/features/menubar/stereo.ts, which measured every leaf. - The Tk skin is gone. This client replaces
pmg_qtonly. Legacy Tk plugins cannot be ported:mimic_pmg_tk.PMGAppcreates a real hiddentkinter.Tk()root andmimic_tk.pyinstalls a globalsys.meta_pathhook that still fires headlessly and hands plugins invisible dialogs. - Features that are incentive-only in open-source PyMOL stay broken, but loudly.
cmd.clean,cmd.load_mtz,.maeload,.mtl/STL export,assign_stereo,morph,focal_blur,find_pi_interactionsand others raiseIncentiveOnlyExceptionhere;tenmol_bridge/incentive_only.pyis the manifest, and affected UI surfaces surface the error rather than silently no-op. - Undo is at open-source parity.
editor.undocontextis a no-op stub upstream, so most “undoable” Builder actions are not undoable. - Generated API types are largely heuristic. Only a handful of type annotations exist across the ~400 API symbols; the override table plus a CI drift check are the mitigation.
13. plan §… in source comments
Many source files cite plan §N or a critique code. Those documents are gone; this is where the
decisions they named now live.
The
spikes/ directory holds the measured experiments those decisions came from, and the citations
into it are still live.