Spike 05 — State change detection for the web-client bridge
Status: BLOCKER RESOLVED — no new C++ is required for v1. Polling at 30 Hz costs 0.25 % of one core on a 52 k-atom / 11-object scene. Two real problems remain, and neither is “polling is too slow”: (a) consume-once drains force a single-owner rule, and (b) the main API lock blocks the poller for the entire duration of any long C++ call (measured: aSTATUS — re-read against the tree on 2026-08-02
§1–§7 are the design the bridge shipped and they still describe it. The two-channel split of §5 (blockingAPIEnterqueries on the engine thread, the three non-blocking drains anywhere else), the single-owner rule of §3, and the “_get_feedback()returnsNone, not[]” trap are all live constraints, not history. §8 IS SUPERSEDED. Do not implement it. It was a ranked wish-list of C++ that had not been written;08-native-changes.mdthen wrote something deliberately different, and §8’s top recommendation is the one that was rejected on evidence:The consequence for §2/§6: the poll’s blind spots (per-atom colour, per-atom reps,alter, coordinate edits) no longer need the §7.3 command-echo channel to be correct — a per-rep version bump sees all four. §7.3 remains a useful latency optimisation, not the only defence. Everything here was measured with a session scratchpad venv that no longer exists; usepackages/bridge/.venv/bin/python.
cmd.ray() froze cmd.get_names() for 3.8 s).
All numbers below were produced with
/private/tmp/claude-501/-Users-amirangel-Documents-GitHub-tenmol/177b7067-f921-4a11-839c-84d8a16f6415/scratchpad/venv/bin/python
(pymol 3.2.0a0, CPython 3.13.3, macOS arm64), headless via pymol2.PyMOL(). Scripts are in
<scratchpad>/events/e1_timing.py … e14_final.py. No repo file other than this document was
touched.
1. How the Qt GUI detects change today (the real polling model)
There is no event bus.grep -rn "Notify" layer0 layer1 layer2 layer3 layer4 layer5 returns
0 hits (re-verified). Every live surface is either a poll or a redraw.
1.1 Two timers, that’s it
update_feedback (pymol_qt_gui.py:941-958) is the whole “state sync” of the external GUI:
1.2 The object list is NOT polled from Python at all
Nothing inpmg_qt calls cmd.get_names() on a timer. The object panel is a C++ Block:
struct CExecutive : public Block with void draw(CGO*) override (packages/engine/layer3/ExecutiveDef.h:54,
:99). It is redrawn from the live I->Spec linked list on every paintGL, i.e. up to 50 Hz.
The React client cannot inherit this — it must synthesise an object-list feed that has never
existed. Same for the wizard panel, the scene bin strip and the internal command line.
1.3 The three existing consumers of the consume-once drains
2. Measured cost of the polls a web client needs
Scene:4HHB.pdb replicated to 52 569 atoms in 11 objects, cartoon shown.
Median of 200 calls each (<scratchpad>/events/e1_timing.py):
count_atoms is the only poll that scales with the scene, and it scales badly
(<scratchpad>/events/e6_misc.py):
count_atoms('sele') alone is 5.9 ms, i.e. 18 % of a 30 Hz budget. It must
not be in the hot tick.
2.1 Verdict on rate
Measured steady state of the actual proposed tick (<scratchpad>/events/e12_design.py),
300 ticks at 30 Hz on the 52 k-atom scene, snapshot + diff + settings drain, plus a 10 Hz status
thread running concurrently:
3. Consume-once behaviour — confirmed, and it is worse than “one consumer”
SettingGetUpdateList (packages/engine/layer1/Setting.cpp:1121-1147) clears each changed flag as it reads it:
<scratchpad>/events/e2_consume.py):
-
The per-object setting drain is a separate channel. Setting a per-object value does not
show up in the global drain:
So the bridge must call
cmd.get_setting_updates(name, state)for every object as well as the global one. Cost measured for 31 objects: 21.6 µs total (e13_vis.py) — cheap, do it every tick. -
_get_feedback()returnsNone, not[], when it cannot get the lock (packages/engine/modules/pymol/internal.py:596-606,lock_attempt=acquire(blocking=0),packages/engine/modules/pymol/locking.py:29-30). TreatingNoneas “no output” is silently correct today only because the queue is not drained; treating it as[]and also clearing local buffers would lose lines. Measured directly during a runningray:_get_feedback -> None. -
get_setting_updates()returns[]on a lock miss (packages/engine/modules/pymol/setting.py:440-447), which is indistinguishable from “nothing changed”. This is safe (nothing was drained, so the flags survive to the next tick) but it means a lock miss looks like quiescence. Do not build “settle detection” on it. -
The feedback queue is unbounded.
OrthoFeedbackIn(packages/engine/layer1/Ortho.cpp:492-499) pushes into astd::queue<std::string>with no cap;OrthoFeedbackOut(:502-515) pops exactly one. Measured: 20 000 undrained lines → +2.98 MB RSS, all 20 000 returned in order (e8_fb2.py). If the browser disconnects and the bridge stops draining, PyMOL leaks. The bridge must drain unconditionally and buffer on the Python side with its own cap. -
OrthoFeedbackInis gated onG->Option->pmgui. It is true underpymol2.PyMOL()(verified:cmd.do("print('X')")→["PyMOL>print('X')"]). Butecho=0suppresses it entirely, and raw Pythonprint()only reaches the queue ifpcatch._install()has redirectedsys.stdout(that is whatpymol_gl_widget.py:105does). The bridge must callpcatch._install()or it will not see plugin/scriptprintoutput. -
A session load fires 798 setting updates in one drain (
e6_misc.py) — usable as a “resync everything” signal. Scene recall fires a small set ([23, 152, 254, 396]).
4. Is there ANY push mechanism already? Yes — one, and it is unusable as-is
cmd.set_key returns None headless and only fires from real GUI key events.
cmd.load_callback(pymol.callback.Callback()) never fires headless — 0 hits after refresh(),
idle() and ray() (e4_push_alt.py); it is a render-time hook needing a GL pass.
cmd.log_open() is not a command stream: cmd.do("turn x, 5") logged nothing; only calls
made with log=1 appear ("/cmd.set('sphere_scale',0.2,'',0)\n").
The one real push channel is the wizard event mask (packages/engine/layer1/Wizard.cpp:49-58):
WizardUpdate (packages/engine/layer1/Wizard.cpp:101-131) compares LastUpdatedFrame / LastUpdatedState /
LastUpdatedView and calls back into Python. It is invoked from exactly one place:
ExecutiveDrawNow (packages/engine/layer3/Executive.cpp:11533). WizardDoView/WizardDoPosition are also
called from SceneUpdate (packages/engine/layer1/Scene.cpp:4675-4676) and WizardDoScene from
packages/engine/layer1/Scene.cpp:4812.
Measured headless with a spy wizard returning get_event_mask() == 1023
(<scratchpad>/events/e3_wizard_push.py):
- It is not a push at all — it is draw-pumped.
p.idle()delivers nothing. Onlycmd.refresh()(packages/engine/layer4/Cmd.cpp:4715-4731→SceneInvalidateCopy+ExecutiveDrawNow) orcmd.ray()deliver events. So you still need a loop; you have merely moved the poll into the render path. - The pump is expensive when it does anything.
cmd.refresh()on the 52 k-atom scene:median 1.5 µswhen clean, butmedian 38 313 µs(38 ms) aftercmd.color("red","all"), becauseSceneUpdaterebuilds every rep. Pumping at 30 Hz during interactive editing forces a full rep rebuild 30×/s. - Object-list deletes and all selection changes are missed (see table).
- Fatal: there is exactly one wizard stack and the user owns it. Verified:
WizardGetreturns the top of the stack, so the moment the user opens Mutagenesis or Measurement the bridge goes deaf. Default mask ispick+selectonly (packages/engine/layer1/Wizard.cpp:218-227), so real wizards would not re-emit them either.
wizards.md), where the bridge must proxy the user’s wizard.
5. The real hazard: the API lock, not the poll rate
cmd.get_names → CmdGetNames → APIEnter(G) (packages/engine/layer4/Cmd.cpp:2377-2387), the blocking
API lock. A poller thread running while the main thread ran cmd.ray(300,220) on the 52 k-atom
scene (<scratchpad>/events/e10_lockfree.py, ray took ~4.3 s):
e9_loop.py) that single blocked tick shows up as:
cmd.get_progress() genuinely tracks long operations. Sampled at 20 Hz from a second thread
during a 4.30 s ray (e11_progress.py): 63 of 81 samples were ≥ 0, values
0.25 → 0.386 → 0.440 → 0.495 → 0.577 → 0.734 → …. During a 5.20 s surface build via
cmd.refresh(): 95 of 98 samples ≥ 0. This is the mechanism for the web client’s progress bar
and it is the only thing that reports liveness while PyMOL is busy.
6. What the poll cannot see (measured gaps)
<scratchpad>/events/e12_design.py / e13_vis.py / e14_final.py:
- Per-atom representation state is invisible.
cmd.get_vis()reports the object-level visRep only. Proof:show spheres, m and name CAis undetectable by any cheap poll. (PyMOL’s own object panel does not display per-rep state either, so this is not a parity regression — but any React “reps” indicator beyond upstream’s would needcmd.count_atoms("x and rep spheres")per rep, which iscount_atoms-priced.) - Colour changes are invisible.
cmd.color("red", sel)changes no polled field.cmd.get_object_color_index(name)(0.8 µs) catches only the object colour, not per-atom. - Coordinate /
alter/alter_statechanges are invisible. - Group membership needs an extra query:
cmd.get_names("objects", selection="grp")(58.9 µs) — the group object itself survivesungroup, so the names tuple does not change. cmd.set_name("base","BASE")is a no-op becauseignore_caseisonby default — an easy false “missed event” when writing tests.
Positive control — what the poll does catch, within one tick
p.idle(): 1 s of idle()+refresh() at
movie_fps 30 produced 28 distinct frames over 164 ticks (e6_misc.py). So the tick must call
p.idle() for mplay to work at all, and frame is caught by the diff.
7. The design
7.1 Process/thread shape
- One PyMOL thread. All
APIEntercalls (i.e. all of §7.2) happen on the thread that also executes commands, so a poll can never contend with a command. Contention is not a deadlock but a 4-second stall (§5). - The status thread must only ever call the three non-blocking functions. Adding a
cmd.get_names()to it re-introduces the stall. - The bridge is the sole owner of the three drains.
get_setting_updates()(global and per-object),_get_feedback()andgetRedisplay()are destructive. No plugin, nopymol.rpc, nopymolhttpdmay run alongside.pcatch._install()must be called at startup. Nothing else in the process may call them — enforce with a lint rule overpackages/bridge/. _get_feedback() is Nonemeans “locked, retry”, not “empty”.- Never let the feedback queue go undrained (unbounded, §3.4).
7.2 The tick (30 Hz, main thread)
Snapshot fields, all measured cheap and all confirmed to change on the mutations in §6:document.hidden → client
tells the bridge). Do not go above 30 Hz: it buys nothing, because the camera is driven from
the browser (the client already knows its own view) and everything else is user-paced.
Explicitly not in the tick:
cmd.count_atoms(...)— 5.9 ms at 500 k atoms. Selection atom counts are a debounced request (client asks after thenames/enableddiff settles for ~150 ms) or ride on the command-echo channel.cmd.get_names("objects", selection=grp)per group (58.9 µs each) — only re-query whengroupsornameschanged.cmd.refresh()— see §7.4.
7.3 The command-echo channel (covers the §6 gaps)
Every command reaches PyMOL through the bridge (single browser client, no Qt GUI, no second input path). So the bridge wraps command execution and emits, alongside the result:color/set_color → color, show/hide → reps, alter/alter_state/load/create/remove → geometry, sculpt_activate → coords, …). This is the mechanism that catches per-atom colour,
per-atom reps, alter, and coordinate edits — none of which any poll can see.
The one leak: a .pml script or Python block run through cmd.do expands to many mutations
behind one echo. Mitigation, measured: after any cmd.do/run/@script, emit a
resync: full invalidation rather than trying to be clever. getRedisplay() is a usable
cheap gate here — measured true for turn/color/show/hide/select/deselect/create/delete/set/
disable/enable/frame/scene-store, false for pure reads like get_view() (but note it is also
set by count_atoms, so it is a hint, not an oracle).
7.4 cmd.refresh() — pump it, but lazily
cmd.refresh() is what makes PyMOL actually rebuild reps (and what the geometry-extraction path
needs). Measured on the 52 k-atom scene:
- nothing dirty → 1.5 µs (free, call it every tick)
- after
cmd.color("red","all")→ 38 313 µs (38 ms) - full-surface build → 5.20 s
cmd.refresh() once per tick (it is free when clean), but treat it as the long-op
boundary — when the tick duration exceeds ~50 ms, the status thread is what keeps the UI alive
(get_progress() returns real fractions throughout, §5).
7.5 Message shape
seq is a monotonic tick counter; on reconnect the client sends its last seq, and since the
drains are destructive and unreplayable the bridge always answers with state.resync.
8. What must be added in C++ (v2, optional — none of it blocks v1)
Every item below is an optimisation or a correctness improvement, not a prerequisite. Ranked by value/cost. All of these touch upstream files and are owned by whoever ownspackages/engine/layer1/,
packages/engine/layer3/, packages/engine/layer4/ — reported here, not applied.
8.1 Non-destructive setting generation counter (highest value, ~4 lines)
The consume-once drain is the only correctness problem in the design; everything else is performance.SettingRec::setChanged() (packages/engine/layer1/Setting.h:67-70) is the single write chokepoint
for every setting mutation in the program:
cmd.get_setting_generation()
(packages/engine/layer4/Cmd.cpp, APIEnterBlocked). The bridge then polls a uint64 instead of draining, and
get_setting_updates() is left alone for plugins/Qt. Removes the single-owner rule for settings
and makes reconnect replayable. (Note: it does not tell you which setting changed — keep the
drain as the detail channel, or add a parallel uint64 generation per SettingRec.)
8.2 ExecutiveNamesVersion (~3 lines)
ExecutiveInvalidatePanelList (packages/engine/layer3/Executive.cpp:1513-1518) is already the chokepoint for
“the object panel changed”. Its 11 call sites are precisely the interesting ones:
uint64_t NamesVersion{1}; to struct CExecutive (packages/engine/layer3/ExecutiveDef.h:54) and
++I->NamesVersion; inside ExecutiveInvalidatePanelList. Two sites do not route through it
and need their own bump: ExecutiveSpecEnable (packages/engine/layer3/Executive.cpp:15376, enable/disable) and
ExecutiveSetName (:3580, rename). Saves ~4 µs/tick — marginal, but it makes the “did the
object list change” question O(1) instead of “allocate two Python lists and compare”.
8.3 ReprVersion / ColorVersion (real new capability)
This is the only item that gives the client something it cannot get today. Bump a counter in
ExecutiveInvalidateRep (packages/engine/layer3/Executive.cpp:14001) keyed by invalidation class
(cRepInvColor, cRepInvVisib, cRepInvRep, cRepInvCoord). That closes the §6 gaps
(per-atom colour, per-atom reps, alter, coordinate edits) without relying on the command-echo
channel, which in turn makes the bridge robust to .pml scripts and to a future second client.
8.4 Explicitly NOT recommended
- Do not add a Notify/observer bus. The measured poll cost (67.7 µs at 30 Hz = 0.25 % core)
does not justify threading an event system through five layers, and a C++→Python callback has
to take the GIL anyway — which is exactly the
WizardCallPythonpattern that already exists and already has the re-entrancy hazard (WizardUpdate→SceneUpdate→WizardDoView,packages/engine/layer3/Executive.cpp:11533-11534; the wizard header itself warnsevent_mask_dirty = 128 # anything changed (BEWARE FEEDBACK!),packages/engine/modules/pymol/wizard/__init__.py:13). - Do not bound the feedback queue in C++ to fix §3.4 — bound it in the bridge instead, so the
fix does not have to ship in a rebuilt
_cmd.so.
9. Consequences for other owners (reported, not applied)
docs/architecture.md— the bridge must be single-threaded for state queries with a separate non-blocking status thread. A “poller thread + command thread” design stalls for the full duration ofray/surface builds (measured 3.8 s and 5.2 s).architecture.md— the bridge is the exclusive owner ofget_setting_updates()(global and per-object),_get_feedback()andgetRedisplay(reset=True). Any design that also runspymol.rpc,pymolhttpd, or a Qt GUI in the same process is broken. Add a lint rule.architecture.md/wizards.md— the wizard event mask cannot be used as the bridge’s change feed (single stack, user-owned, draw-pumped, misses delete/select). Wizards remain a proxied feature, not infrastructure.feature-parity.md— the object panel, wizard panel and scene bin have no Python data feed today (they are C++Block::drawsurfaces). Those rows need explicit “new bridge endpoint required” flags, not “wire up existing API”.- Whoever owns
internal-gui.md/ geometry extraction:cmd.get_vis()is object-level only — per-atom rep state must come from the command-echo channel or from §8.3. - Build/bootstrap owner:
pcatch._install()must be called by the bridge at startup, mirroringpackages/engine/modules/pmg_qt/pymol_gl_widget.py:105, or scriptprint()output never reaches the client.
10. Reproduction
<scratchpad> =
/private/tmp/claude-501/-Users-amirangel-Documents-GitHub-tenmol/177b7067-f921-4a11-839c-84d8a16f6415/scratchpad