Skip to content

Regression audit + fixes - #747

Merged
brentyi merged 8 commits into
mainfrom
claude/regressions-v1-0-30-5wehcp
Aug 7, 2026
Merged

Regression audit + fixes#747
brentyi merged 8 commits into
mainfrom
claude/regressions-v1-0-30-5wehcp

Conversation

@brentyi

@brentyi brentyi commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes from the v1.0.30 pre-release regression audit, plus defects found by an adversarial review pass over this branch. Every behavioral fix is pinned by a regression test verified to fail on the pre-fix code.

Server / Python

  • request_share_url now closes and replaces a dead ("failed"/"closed") tunnel instead of returning None forever.
  • ViserTunnel.close() releases a never-connected tunnel's watcher thread, which previously pinned the mp.Manager and leaked one SyncManager child process per retry.
  • A replaced tunnel's stale ShareUrlUpdated(None) can no longer clobber the new tunnel's URL for connected clients or late joiners.
  • _share_tunnel is initialized before the atexit registration, so a failed __init__ can't recurse through the backwards-compat __getattr__ shim at interpreter exit.
  • GuiTabGroupHandle.remove() tombstones under the panel-lifecycle lock, so a racing add_tab can't register an orphan container for a dead group.
  • min/max_orbit_distance setters validate before the no-op short-circuit, with float("inf") accepted as the unbounded max (the pre-1.1.0 behavior).
  • _colors_to_int_tuple materializes its argument once, so generator inputs can't be half-consumed by the warning pass.
  • Async callback dispatch reachable from user threads uses run_coroutine_threadsafe instead of the non-thread-safe loop.create_task.

Client

  • Mobile-width loads read the media query synchronously on first render, so the canvas no longer mounts desktop-first and remounts (throwaway WebGL context + GPU re-upload).
  • The mobile bottom sheet subscribes to the panel list with value-level equality, so server-side visible/order/tab-metadata updates propagate (the comparator is now pinned via panelsEquality.ts).
  • InstancedMesh2.dispose() nulls instanceIndex and clears the retained geometry attribute — including every LOD level's own geometry — so a frame drawn in the dispose→swap window re-initializes instead of binding a deleted GL buffer.
  • onBeforeShadow gains the same null gate as onBeforeRender, fixing a TypeError when the shadow pass (which runs before the main pass) hits a just-disposed shadow-casting batched mesh.
  • The splat sort worker tracks buffer dirtiness, so an updateBuffer landing during a running sort is re-sorted even with a stationary camera.

Dock (spec-traced per dock-ux-spec.md)

  • All resize surfaces (region resizer, docked dividers, stack divider, window grips) share the one-gesture slot that move-drags use, so a second concurrent pointer can't start a second gesture (spec §4).
  • Vertical tab insertion gets the same proximity bound as the horizontal bar path, making rail merge reachable (spec §5.3).
  • The region side band yields across its overhang of a collapsed cell, so one seam yields one drop with no hint hop (D55).
  • Sub-8px bottom split/snap bands are removed rather than shrunk (P11).
  • The stack divider's cancel/motionless-press path commits the stored pinned height instead of the capped rendered height (P2/D56).
  • Escape during a region resize restores float positions pushed by pushFloatsAheadOfSeam, not just region widths (spec §4, P2).

Tests / CI

  • New e2e suites cover the mobile breakpoint (WebGL-context and canvas-removal churn against a desktop self-baseline, plus bottom-sheet updates) and touch-gesture exclusivity via real CDP multi-touch.
  • The playground e2e harness restarts its per-worker Vite dev server after two consecutive page-boot failures, self-healing the wedge that twice turned the py3.9 shard into 17 cascading timeouts.
  • The harness tears the dev server down by process group, fixing the orphaned-esbuild leak, and logs server output for diagnosis instead of discarding it.

brentyi added 2 commits July 26, 2026 15:19
Python:
- Initialize _share_tunnel before the atexit registration so a failed
  __init__ can't leave the atexit stop() recursing through the
  backwards-compat __getattr__ shim at interpreter exit.
- request_share_url now closes and replaces a dead ("failed"/"closed")
  tunnel instead of returning None forever; ViserTunnel.close()
  tolerates the failed connection's already-closed loop.
- GuiTabGroupHandle.remove() tombstones under _panel_lifecycle_lock
  (mirroring PanelHandle.remove) so a racing add_tab can't register an
  orphan container for a dead group.
- min/max_orbit_distance setters validate finiteness, positivity, and
  min<=max before the no-op short-circuit.
- _colors_to_int_tuple materializes its argument once so generator
  inputs can't be half-consumed by the warning pass.
- Async callback dispatch reachable from user threads (on_client_connect
  replay, value-setter update callbacks, form submit, pointer-done
  cleanups) now uses run_coroutine_threadsafe instead of the
  non-thread-safe loop.create_task.

Client:
- App.tsx reads the mobile media query synchronously on first render so
  mobile-width loads no longer mount the canvas inside the dock surface
  and immediately remount it (throwaway WebGL context + GPU re-upload).
- The mobile bottom-sheet panel list subscribes with value-level
  equality, so visible/order/tab-metadata updates propagate.
- InstancedMesh2.dispose() nulls instanceIndex and drops the geometry
  attribute so a frame drawn in the dispose->swap window re-initializes
  instead of binding a deleted GL buffer.
- The splat sort worker tracks buffer dirtiness so an updateBuffer that
  lands during a running sort is re-sorted even with a stationary
  camera.

Dock (spec-traced per dock-ux-spec.md):
- All resize surfaces (region resizer, docked dividers, stack divider,
  window grips) share the one-gesture slot move-drags already used, so
  a second pointer can't start a concurrent gesture (spec 4).
- Hit-testing: vertical tab insertion gets the same proximity bound as
  the horizontal bar path so rail merge is reachable (spec 5.3); the
  region side band yields across its overhang of a collapsed cell so
  one seam yields one drop with no hint hop (D55); sub-8px bottom
  split/snap bands are removed rather than shrunk (P11).
- The stack divider's cancel/motionless-press path commits the stored
  pinned height instead of the capped rendered height (P2/D56).
- Escape during a region resize restores float positions pushed by
  pushFloatsAheadOfSeam, not just widths (spec 4, P2).

Every behavioral fix is pinned by a regression test verified to fail on
the pre-fix code: 5 Python (tunnel recovery, orbit validation, color
coercion, atexit exit, tab-group race) and 11 client assertions across
hit-testing, divider cancel, float restore, splat staleness, and
dispose. Gates: ruff check/format clean, 689 Python tests pass; tsc,
eslint, prettier clean, 630 client tests pass.
The new setter validation rejected non-finite values, which removed the
only opt-out from the 1e4 default: infinity is the pre-1.1.0 unbounded
behavior and the camera-controls default, and large-scale scenes need
it. Accept +inf for the max bound (NaN and <= 0 still raise); the min
bound stays strictly finite.
@brentyi
brentyi force-pushed the claude/regressions-v1-0-30-5wehcp branch from 37b58d6 to e922562 Compare July 26, 2026 15:19
brentyi added 6 commits July 26, 2026 15:31
A local npm 10 re-resolve during the gate runs dropped the
@emnapi/core and @emnapi/runtime entries that @napi-rs/wasm-runtime
references, which npm 11's stricter 'npm ci' sync check rejects. No
dependency changes were intended; this restores main's lockfile
byte-for-byte.
Two coverage holes that hid this cycle's worst bugs:

- test_mobile_breakpoint.py: a mobile-width load must not mount the
  canvas desktop-first and remount it (counts WebGL context creations
  and canvas removals against a desktop self-baseline), and the mobile
  bottom sheet must reflect server-side panel visibility updates.
- test_touch_gesture_mutex.py: while a divider resize is in progress
  via one touch, a second concurrent touch on a floating window's grip
  must be ignored (spec 4, one active gesture at a time), driven as
  real multi-touch through CDP Input.dispatchTouchEvent.

Both verified to fail against a client built from the pre-fix sources
and pass against the fixed build.
…uite

Twice on CI (py3.9 shard 2: runs 30298026392 attempt 1 and 30418320431)
the per-worker Vite dev server wedged permanently partway through the
run: page.goto kept succeeding but the module graph never completed, so
the dock never mounted and all remaining playground tests on that worker
burned the full 60s timeout twice each -- 17 consecutive failures over
~30 minutes, with orphaned esbuild children left behind (the dependency
optimizer hanging is the prime suspect). The failure onset shifted
between the two runs, so it is not tied to a specific test, and unlike
the documented 2026-07-18 starved-runner incident it never recovers on
its own -- which means the existing generous-timeout hardening cannot
help. Waiting cannot rescue a wedged server; a restart can.

- tests/e2e/vite_manager.py: extract the dev-server process management
  from the conftest fixture into a restartable ViteServerManager. The
  server now runs in its own process group and teardown signals + waits
  on the WHOLE group (escalating to SIGKILL), fixing the esbuild orphan
  leak: `proc.terminate()` only killed the npx wrapper, and waiting on
  the direct child returned as soon as npx died while node/esbuild
  lived on. stdout/stderr now go to a log file instead of DEVNULL, and
  the tail is printed on restart so a future wedge is diagnosable.
- dock_helpers.open_playground: after the existing two page-boot
  attempts fail, restart the worker's Vite server and make one final
  attempt against the fresh process -- turning the 30-minute cascade
  into a ~2 minute self-heal with zero test failures. The third attempt
  only exists when a managed server was actually restarted.
- conftest: the vite_server fixture now just owns a manager's lifetime.

Validated end-to-end outside CI: normal boot, SIGSTOP-simulated wedge
(two failed attempts -> restart with log tail -> healed boot on the
same port), old process group fully reaped, and teardown leaving no
processes. ruff check/format clean; playground tests pass through the
new fixture path.
An advocate/critic subagent panel reviewed the PR's effective diff vs
main. The advocate independently re-ran the falsification protocol (all
new Python and client regression tests fail on pre-fix sources, pass on
the branch); the critics surfaced the defects fixed here, each verified
against the code before fixing.

Tunnel (should-fix): the dead-tunnel replacement path leaked one
SyncManager child process + watcher thread per request_share_url retry
while the share backend was unreachable. A tunnel that ends "failed"
never sets _connect_event, so on_connect's wait_job blocked forever,
pinning the mp.Manager proxies; pre-fix the leak was bounded at one
because the failed tunnel poisoned the slot, but the replacement branch
creates a fresh tunnel per retry. ViserTunnel.close() now releases the
watcher (wait_job gains a status guard so the release cannot fire the
connect callback; _connect_job stores "connected" before setting the
event, making the guard race-free). Pinned by
test_close_of_failed_tunnel_releases_watcher_thread, verified to fail
pre-fix. Also: the failed tunnel's on_disconnect no longer broadcasts
ShareUrlUpdated(None) when a newer tunnel owns the slot -- the message
persists in the broadcast buffer, so the stale None also hit late
joiners.

InstancedMesh2 (should-fix, vendored -- LOCAL_PATCHES.md updated):
a frame drawn in the dispose->unmount window crashed the shadow pass:
shadow maps render BEFORE the main pass, onBeforeShadow only guarded
its culling call, and the unconditional instanceIndex.update()
dereferenced the nulled attribute (any shadow-casting batched mesh,
the Python API default). onBeforeShadow now has the same null gate as
onBeforeRender. LOD levels are separate meshes with per-level
geometries, so dispose() now drops each level geometry's retained
instanceIndex attribute too, instead of leaving them binding the freed
GL buffer. Both pinned by new dispose tests, verified to fail pre-fix.

Mobile sheet test gap (should-fix): panelUpdates.test.ts pinned the
equality utilities but not the component wiring -- reverting
PanelsFallback to key-set equality left the suite green. The comparator
choice now lives in panelsEquality.ts (imported by the component), and
the test pins both the module identity and, via a ?raw source
assertion, the selector call site.

Harness nits: vite_manager falls back to direct-child terminate on
Windows (os.killpg/getpgid are POSIX-only; the old fixture worked
everywhere), and a failed mid-suite vite restart no longer masks the
page-boot diagnostic (open_playground re-raises the page error with the
restart failure chained).

Gates: ruff check/format clean; targeted Python suites 49/49 (new leak
test fails on pre-fix code); tsc, eslint, prettier clean; client suite
637/637 with the four new pins failing on pre-fix sources.
Conflict: LOCAL_PATCHES.md -- both sides appended new patch sections.
Kept main's frustum-culling/sorting patches as 2-4 and renumbered this
branch's dispose-window patch to 5.
@brentyi
brentyi force-pushed the claude/regressions-v1-0-30-5wehcp branch from 4d25090 to 555e0f4 Compare August 6, 2026 11:22
@brentyi brentyi changed the title Fix pre-release defects from the v1.0.30 regression audit Regression audit + fixes Aug 6, 2026
@brentyi
brentyi merged commit 12ac4ce into main Aug 7, 2026
23 checks passed
@brentyi
brentyi deleted the claude/regressions-v1-0-30-5wehcp branch August 7, 2026 06:25
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.

1 participant