18 KiB
P3 Interaction runtime: independent review, round R1
- Reviewer: independent adversarial reviewer (not the builder). Date 2026-10-07, 06:38 to 07:05 (Frame clock, EDT). Steam build 11094443 for every live row.
- Scope: PLAN §2.3 card "P3 Interaction runtime" (IN-1 to IN-9), the builder's report (M3, done),
docs/phase2/wp/P3.md,contracts/interaction.md,device/rt/04-input.js…07-tooltip.js. - Method: my own runner (
<scratch>/p3rev/p3rev.py, copy at/tmp/lgs/p3rev/p3rev.pyuntil a reboot), written without reusing the builder's checks. Each run is onelab.Lockstep with the action logger on. Inside the step,wp.p3is pushed throughrt.test.flagsand popped infinally. Every popup'sTriggerOverlayHapticEffect/SetOverlayInteractionAffordanceand Steam'sPlayAudioURLwere dropped for the whole step: nothing was pulsed or played. The laser was simulated with CDPInput.dispatchMouseEventon the popup's own target. The gamepad was simulated with D-pad moves (L.pad) only. Button A was never pressed for real; the press test used a non-bubbling event on our own test node. Outputs are<scratch>/p3rev/out_a.jsontoout_i.json;<scratch>is this workflow's scratchpad. The key numbers are copied below, because scratch can be lost. - Device left as found: CSS-only, no flags of mine, P3 modules
off, pointer at (1400, 900), pad source 1, starting route restored, no test nodes left. The shot copy on the Frame was deleted.
Verdict: fix
The code is careful and most claims hold under independent tests: IN-1 to IN-6, IN-8 and IN-9 pass. IN-9 still passed when removal happened mid-interaction, and DOM listener counts returned to baseline. The tooltip looks right next to the mockup. Two major findings remain:
- P3 keeps strong references to every DOM element it ever tagged, so pages the user leaves are never freed while
wp.p3is on. Proven live. - IN-7 does not pass as written. The evidence scores DOM state, not what the user sees.
Findings
| # | Sev | Title |
|---|---|---|
| F1 | major | Detached-DOM leak: states and attention keep every tagged element alive until the package is removed |
| F2 | major | IN-7 fails as written (label nearly invisible at 900 ms, tooltip visible until about 570 ms after leaving); the log reports "PASS 11/11" from DOM-state checks |
| F3 | minor | input is not fail-closed on a partial install: Steam subscriptions made before the throw are never undone |
| F4 | minor | Each input install leaves a [Symbol(lgs-p3-input)] record in webpackChunksteamui after removal |
| F5 | minor | Evidence log accuracy: logged times do not match the artifacts; the tooltip live shot predates the final 07-tooltip.js |
| F6 | minor | %{Token} selectors are resolved once at install; an install while P1's class index is incomplete disables ring and disabled tags for the session |
F1 (major): detached-DOM leak while wp.p3 is on
- Code:
06-states.jsadds tost.touched(aSet) on every spot host (l.130), press (l.156), focus-in element and FocusRing (l.183), ring-check ring (l.194) and disabled focus (l.221).05-attention.jsadds every dwelled element tost.marked(l.136).clearDwellnever deletes it, whiledelClsdoes (l.94) only for attend classes.- Both sets are emptied only in
remove()(06 l.338–341, 05 l.398–401). - Every gamepad focus move, laser spot host and dwell therefore pins its element. A pinned element keeps its whole detached subtree alive, which after a route change is the whole page Steam unmounted.
- Live proof (
out_d.json, 06:52): a 120 px test node withdata-lgs-spot,data-lgs-pressanddata-lgs-dwelland an 8 MB payload. Steps: laser over it, one synthetic A down/up on it, pointer off-window, node removed from the DOM,WeakRef, then CDPHeapProfiler.collectGarbage×2.- wp.p3 off (control):
collected. - wp.p3 on:
pressed: true,--hx 50%,lgs-dwellset, thenaliveafter GC. - After
flags.pop:collected.
- wp.p3 off (control):
- Why major: with
wp.p3on by default (REQ P3->V1), memory in the Steam UI renderer grows with every page the user visits and interacts with, for hours. This breaks the "no leaks" robustness bar. - Fix: drop both sets, or use
WeakSet.remove()already sweeps every live popup withquerySelectorAllfor the classes, and can do the same for inline--hx/--hy. Alternatively, delete an element from the set when its last tag or property is cleared (clearSpot,unpress, the focus-in timer,clearFocusTags,clearDwell).
F2 (major): IN-7 does not pass as written; the evidence scores DOM state
Card criterion: "None at 500 ms; the label at 900 ms; gone ≤ 250 ms after leaving".
Measured by me, sampling every 20 ms the node's state and the computed opacity of the capsule and of .lgs-tip-label:
| Path | 500 ms | Capsule in |
Label opacity at 900 ms | Label ≥ 0.5 | Leave → out |
Label 0 | Node hidden |
|---|---|---|---|---|---|---|---|
Laser (CDP hover, out_e.json) |
none ✓ | 818 ms | 0.04 | ≈ 985 ms | 223 ms | ≈ 406 ms | 571 ms (capsule opacity 1.0 until ≈ 510 ms) |
Gamepad (timed from the real vgp_onfocus / vgp_onblur, out_f.json) |
none ✓ | 824 ms | 0.12 | 975 ms | 245 ms | 440 ms | 585 ms |
- At 250 ms after leaving, the laser tooltip is still fully there: capsule opacity 1.0, label 0.71. "Gone ≤ 250 ms" fails under any reading except "the leave has started".
- The builder's runner scores this exactly that way:
laser_out_le_250testsoutAt <= 250, and the pad check is at 650 ms. - "Label at 900 ms" passes only as
textContent. P5's content-in lags the glass, so the label is at 4–12 % opacity at 900 ms. - P3-D1 documents the leave conflict: PLAN §1.13 / CTL §11 give a 0.2 s out delay plus a 350 ms dematerialize, which cannot be gone by 250 ms. It does not cover label legibility at 900 ms. Even so,
wp/P3.mdreports IN-7 as "PASS (11 / 11 checks)" and the Status says every acceptance test passes. - Fix, either:
- (a) make IN-7 pass, by timing so the label is readable by 900 ms and the tooltip is gone within 250 ms; or
- (b) record IN-7 as a deviation (FAIL as written), with both numbers measured on opacity, and get the PLAN §1.17 sign-off / coordinator amendment of the card criterion. Status should then say "M3 except IN-7 (deviation pending)".
- The code follows PLAN §1.13 and CTL §11. The defect is that the evidence and the M3 claim present a criterion conflict as a PASS.
F3 (minor): input leaks Steam subscriptions after a partial install
-
In
04-input.js,live = st(l.227), then the subscriptions:NavigationSource.Subscribel.444,RegisterForNavigationTypeChangel.447, the MobXreactionl.451, and the popup callbacks / 2 s poll without P1.st.removeis assigned only at l.486. If any later step throws, P1 callsremove(), finds nost.remove, and undoes nothing. -
These are direct Steam calls, not
rt.*helpers, so P1 cannot undo them either. This goes against runtime.md §1 rules 2–3 and §10 ("remove() safe after a partial install") and G-REMOVE. -
Offline proof (
<scratch>/p3rev/failclosed.js, a Steam-like mock that makes one call throw, thenremove()):Throwing call NavigationSource subscriber VR nav-type callback Popup-created callback Other RegisterForNavigationTypeChangeleft (1) — — AddPopupCreatedCallbackleft (1) left (1) — AddPopupDestroyedCallbackleft (1) left (1) left (1) the 2 s poll interval keeps running Control (no throw) 0 0 0 -
Impact: latent. It needs a Steam change that makes, for example, the needle-found MobX
reactionorRegisterForNavigationTypeChangethrow. That is exactly what fail-closed exists for. -
Fix: register each undo before or with each subscription and set
st.removefirst, or usert.cleanup. Also usert.expose('input', …)(runtime.md §3.5b), which P1 removes automatically, instead of assigningPUB().inputdirectly. The same pattern exists in 05/06/07 (input.onChangeregistered beforest.remove); it is lower risk there.
F4 (minor): webpack chunk record left after removal
findSteamModules() (04 l.61) pushes [[Symbol('lgs-p3-input')], {}, fn] into webpackChunksteamui on every install, and it is never removed. Live: the array went 67 → 68 for one install, and the last entry was Symbol(lgs-p3-input) after an earlier pop. It is tiny, but it is a global mutation that G-REMOVE says must not survive. Fix: splice it out after taking req, or cache req once per page.
F5 (minor): evidence-log accuracy
- Logged times are not the run times. The output files' mtimes:
out_r2a.json05:15:20 (logged 05:27);out_r2c.json05:20:46 (logged 05:42);out_r2e.json05:24:57 (logged 05:47);out_r3a.json05:29:58 /out_r3b.json05:30:13 (logged 06:05 / 06:07).
wp/P3.mdwas last written at 05:36:41, yet its Status says "complete (session 2, 06:12)". PLAN §2.1 asks for the date of each row; these times are wrong by 12–36 min.shots/p2_p3_tooltip_live.png(05:19:32) andp2_cmp_p3_tooltip.pngpredate the final07-tooltip.js(05:20:01, the 20 px padding change). My fresh shot (below) confirms the final code looks right, but the logged shot is not of the final code.- The JSON evidence lives only in scratch (
<scratch>/p3/out_*.json). Copy the key numbers into the log, as this review does.
F6 (minor): tokens resolved once at install
06-states.jsresolves%{FocusRing}and the%{…Disabled}parts at install (resolveList), and05/07resolve selectors at registration.- During this review the Steam main page crashed (06:49:56, during another agent's step, with P3 off). For about 4 minutes after the theme came back, P1's class index had 3 modules instead of 550 (
glass.py statusclassModules: 3;__LGS_INDEX.selector('FocusRing')→unresolved). - A P3 install in that window gets
ringSel = ''and drops the token parts of the disabled selector.lgs-ring-checkand part oflgs-focus-disabledthen stay off until the next reinstall, with no error. - Fix: re-resolve lazily on the first use, or on P1's index-ready / module event.
Re-run results (live, independent)
| Test | Run (Frame time) | What I did differently from the builder | Result |
|---|---|---|---|
| IN-1 | out_a 06:45 |
Flipped the source from the bar window and, separately, from main. A NavigationSource subscriber of my own, registered after P3's, read main's class inside Steam's notification | PASS: 10 / 10 popups → laser from the bar and from main, → pad on L.pad. In all 4 notifications the class was already flipped (same task). onChange reasons source |
| IN-2 | out_a 06:45 |
Stub with ttlMs: 400 and checked expiry |
PASS: our classes only. NavigationSource.Value, IsInGamepadNav and both objects' own properties unchanged at every step. The stub expired at 400 ms back to the live values (the live source was MOUSE at the start), with 4 onChange calls, reason stub |
| IN-3 | out_g 06:55 |
Latency from the first mouseover inside the dwelled element (the builder used the last one); a slow-sweep sanity check |
PASS: 80.3 / 84.7 / 84.6 ms (3 cards). Fast sweep at 45 ms per card: 0 dwells on 4 cards. Slow sweep at 140 ms per card: 4 / 4 dwells at 81–84 ms. Cleared on leaving and on the switch to pad |
| IN-4 | out_c 06:51 |
A real dispatchEvent (non-bubbling CustomEvent) on our own [data-lgs-press] node instead of calling the handler |
PASS: set on A down, cleared synchronously (0 ms) on A up; B, X and repeats ignored; safety clear at 410 ms; the host is found from a child; defaultPrevented false for every event; 0 .lgs-pressed left |
| IN-5 | out_c 06:51 |
MutationObserver on style; 5 D-pad moves afterwards |
PASS: writes 3 / 1 / 1 ms after pointermove (20/50 %, 50/30 %, 80/70 %); 0 writes in 1.2 s idle; 0 --hx after the switch to pad; 0 writes during 5 pad moves (P-09) |
| IN-6 | out_d 06:52 |
Same method (P5 keyframe on .lgs-focus-in.gpfocus::after), real L.pad moves |
PASS 3 / 3: class present in the vgp_onfocus task and at the next frame; ::after 0.60 at t0, 1.00 at 700 ms; class gone by 700 ms |
| IN-7 | out_e 06:53, out_f 06:54 |
Opacity-sampled timelines (table in F2); gamepad timed from the real focus events; a negative P-12 case; Steam's bar tooltip | FAIL as written (F2). Owner with visible text: no tooltip after 1.1 s (P-12 ✓). Bar: wrapper on, unDelayMS 800, not finished at 600 ms, finished at 900 ms, m_mapTooltips empty 300 ms after the pointer left ✓ |
| IN-8 | out_f 06:54 |
Also 'dwell'; haptic under the laser stub on a non-Panel node |
PASS: activate → 21, one request /sounds/deck_ui_default_activation.wav (dropped); hover and dwell → null, 0 requests; 0 requests in a 5-card laser sweep; haptic plan {effect 3, available, armed false, mode laser, fired false}, 0 calls |
| IN-9 | out_b 06:49 |
Removal from an active state (laser dwell and spot on a card under the pointer, a tooltip in, a held A press, a live rt.attend), plus CDP DOMDebugger.getEventListeners on the main and bar documents |
PASS: after the pop, no class, attribute, --hx, .lgs-tip or #lgs-tip-style on any popup; no P3 API; ShowTooltip is the prototype's again. Counts back: NavigationSource 2→3→2, VR nav-type 1→2→1, MobX observers 11→12→11, popup callbacks 6/3 unchanged. Listener diff on main and bar documents = {}. 0 writes of ours during laser moves and pad presses after removal |
| Motion (P-52, P-56) | out_h 06:57 (--media reduce), out_i 06:59 |
— | PASS: normal 250 / 350 ms (P5 lgs-mat-*; backdrop-filter animation is allowed for small glass by MO R8). Reduce Motion: 180 ms, glass opacity only, label keyframes empty. 0 lgs-mat animations 1.1 s after the leave; node hidden |
| Idle | out_g 06:55 |
1.5 s after the last input with wp.p3 on |
PASS: no running animation, none named lgs-*, no P3 marks. Install times: input 3.8 ms, attention 0.3, states 0.6, tooltip 0.5 |
| Leak | out_d 06:52 |
WeakRef + forced GC (F1) | FAIL (F1) |
Not re-run: IN-10, IN-11, IN-12 and IN-3b (builder extras, not card tests), perf, and lgs off itself. lgs off would disrupt about 20 agents; it calls the same remove() paths that IN-9 exercised from an active state. Wearer-only items (the SteamVR grip-mode path and haptic feel) stay blocked, as the builder says.
Checklist
- (a) Ownership: OK. Changed files are the four P3 modules,
contracts/interaction.md,wp/P3.md, one answered line inwp/P10.md(allowed by the request rule), and the twoshots/p2_*p3*files (PLAN §2.1 naming).device/proto/input_mode.jsis unchanged. No P3 files in_wip/or elsewhere. The open request is handled (REQ P10->P3[x]); no request to P3 is open. - (b) Safety:
- Persistence: no web storage, disk or network in P3 (grep clean); memory only. Nothing autostarts: the modules exist only while
wp.p3is on, and the default is off (nodevice/defaults.jsonyet). - Never-list: respected by the builder's runner and by mine (D-pad and BTakeFocus only, A never pressed for real, sounds and haptics dropped).
- Haptics: disarmed (
armed: false,fired: false). - Locks: every live step ran under
lab.Lock. - Removal: clean except F3 and F4.
- Persistence: no web storage, disk or network in P3 (grep clean); memory only. Nothing autostarts: the modules exist only while
- (c) Robustness:
- Leak F1 (major), the fail-closed gap F3, residue F4, and the stale tokens F6.
- No listeners left (DOMDebugger) and nothing running at idle.
- Bar wrapper: if another wrapper was stacked on top,
remove()leaves P3's wrapper in the chain, but it is inert because it checkslive === st. Acceptable.
- (d) Conformance:
- Pass: P-06 (lift and depth only after 80 ms:
.lgs-dwell), P-09, P-12 (icon-only, 0.8 s in, 0.2 s grace out, none on visible text), P-25 (P3 never cancels a Steam event), P-42 (tooltipborder 0,outline none, edges from P4's E3::before), P-52, P-56, P-74/P-75 (Steam's enum on Steam's bus; hover refused). - Missing: the IN-7 timing vs the card (F2).
- Pass: P-06 (lift and depth only after 80 ms:
- (e) Function retention:
- P3 cancels nothing (IN-4, real D-pad spy in the builder's run).
- Bar tooltips still appear, at 0.8 s.
- In-window tooltips work on both the laser path (CDP hover) and the gamepad path (D-pad focus).
pointer-events: noneon the tooltip.- No Steam handler replaced; the only patched function is
ShowTooltip, as the plan says (CTL P-C5).
- (f) Visual quality:
- My live shot with the final code is
<scratch>/p3rev/rev_tooltip_live.png: a 60 px icon-only test circle over a poster on/library/home, real laser attention, 1.3 s. - I viewed it next to
shots/p2_controls_tooltips.png("Options" over the poster). - Computed: 48 px tall, 20 px side padding, 20 px weight 600, capsule radius, P4 thick glass (
blur(30px) saturate(1.5), top sheen, inner shade, 5 mm shadow0 2px 6px rgb(0 0 0 / .3)), E3 conic arc on::before,border 0,outline none. - It reads as a dark glass pill with the poster blurred through it and a faint upper-left specular edge. It matches the mockup in size, weight, placement (12 px under the owner) and tint; the mockup's capsule is slightly denser. No outline.
- Motion: the glass leads the label on the way in and trails it on the way out, as MO intends. Note the empty capsule visible for about 100 ms at both ends; this feeds F2.
- My live shot with the final code is
Notes for the builder and coordinator
- Environment event (not attributed to P3):
- 06:49:56: the Steam main page crashed ("Target crashed") during another agent's Reduce-Motion step, 42 s after my removal run had popped
wp.p3. - The theme came back on at about 06:50:38, with the class index at 3 modules until about 06:53:44.
- P3 was off at the time of the crash.
- 06:49:56: the Steam main page crashed ("Target crashed") during another agent's Reduce-Motion step, 42 s after my removal run had popped
- Reproduce:
sh run.sh NAME [--media reduce] in1 in2 in3 in4 in5 in6 in7 in7pad in8 motion idlefrom<scratch>/p3rev/.rmandleakrun outside the flag block, with their own push and pop. The offline check isnode <scratch>/p3rev/failclosed.js; it hangs on the leaked interval in its last case, which is the point; stop it after it prints.