Skip to content

SR weaver subclass survives session teardown on Unity's container window (confirmed) — the editor-killer's mechanism #284

Description

@dfattal

STATUS 2026-09-02 — the fatal attribution is REFUTED

What is confirmed: the stale subclass (observed directly) and the escalation (46 → 2,109 lines across one Play→Stop cycle, measured).

What is refuted: that this mechanism is what kills the editor. A live wedged editor in the exact signature-C symptom state (main window gone, process spinning, log ending on WeaverBaseImpl) was captured with a non-invasive debugger attach across 221 threads: zero CallWindowProc/DispatchMessage/SendMessage frames, and zero SimulatedReality* frames executing anywhere in the process. The main thread was in mono_runtime_invoke — a managed call that never returned.

So this issue is a log flood and a window-subclass leak — both real, both worth fixing — and not, on current evidence, a crash. The editor-killer is mono-side during shutdown and is tracked in #264.

Severity was deliberately not raised, because the reasoning for "fatal on window close" ran through that wedge and the wedge's own stack contradicts it. It goes back up if the open question below resolves toward SR.

Split out of #264. This is the one of that batch with a confirmed mechanism — see the status banner above for what that mechanism is and is not now shown to cause.

Found with a partner integrator (Amazon Lab126 / LeiaViewer port) on plugin v2.16.0.

Confirmed, not inferred

An in-process editor probe on the partner's rig, run 25+ minutes after Lifecycle Shutdown with no session running:

[#264wp] ==== wndproc ownership sweep, pid 45204 ====
[#264wp] HIT class='UnityContainerWndClass' proc=0x7FFC3F9437E0
         owner=SimulatedRealityDirectX.dll        (base 0x7FFC3F920000, end 0x7FFC3F974000)
[#264wp] windows examined=10

SimulatedRealityDirectX.dll still owns GWLP_WNDPROC on a Unity editor container window, long after the provider went silent (zero DisplayXR-PROV lines after Lifecycle Shutdown). SR modules are all still mapped, so this is the survivable form, not a dangling proc.

Cross-process GetWindowLongPtr returns 0 — Windows blocks it — so this had to be an in-process editor script. Worth knowing before anyone tries to reproduce the probe from outside.

The mechanism, end to end

Our side (displayxr_win32.c, child-glue window creation):

CHILD-GLUE (#740): born as a WS_CHILD of Unity's container window instead of a
parentless top-level. GA_ROOT(child) == the container = the window the SR SDK
resolves as its phase anchor ... this window is OURS: the container is stable
across tab re-hosts, so the child survives them; we recreate it each session.

So on the docked texture path — the editor default since v2.8.0:

  1. Our Provider: default to an app-owned window; demote self-host to a diagnostic flag #173 weave window is a WS_CHILD of Unity's container.
  2. The SDK anchors to GA_ROOT, which is the container, not our child.
  3. LifecycleStop destroys our child. The container is Unity's; we neither own nor destroy it.
  4. The subclass therefore survives on a window we were never going to clean up — and we create a fresh child and a fresh weaver against that same container every session.

The property that makes child-glue robust (the container is stable across tab re-hosts) is exactly what lets subclass layers stack.

Teardown contract — three hops, and the failing one has no owner:

xrDestroySession -> destroys the DP        runtime    VERIFIED unconditional & synchronous
                                                      (d3d12_compositor_destroy ->
                                                       xrt_display_processor_d3d12_destroy)
DP destroy       -> destroys the weaver    plug-in contract
weaver destroy   -> restores GWLP_WNDPROC  SR SDK contract   <-- NOT ENFORCED

The runtime's half is satisfied — traced in source, and PR #1012's deferred-destroy graveyard is service-path-only (grep graveyard src/ hits only comp_d3d11_service.cpp), so the in-process D3D12 path this provider uses has no retired-DP window at all and #964's shape cannot reproduce here. The failure is downstream of xrt_display_processor_d3d12_destroy — in the plug-in's DP destroy, or in the SDK's weaver destructor not restoring GWLP_WNDPROC.

Our LifecycleStop comment previously compressed all three hops into "xrDestroySession unhooked the SR weaver subclass". Corrected in-tree.

Observed symptoms

Benign form (reproduced on v2.16.0, non-fatal): after 12 LoadScene cycles with a live session and a normal Play-stop — no scripted resizes, so not a layout artefact:

provider:  GfxStop -> Lifecycle Stop -> Lifecycle Shutdown -> 0 further DisplayXR-PROV lines
weaver:    "WeaverBaseImpl: no handler for this window" x46 over ~6 min, then STOPPED
pattern:   groups of 3, ~3ms / ~3ms / ~100ms within a group, clusters every 5-30 s
editor:    alive and healthy throughout

Message-driven rather than frame-driven (an idle editor is not rendering at 60fps), surviving complete provider shutdown, self-limiting when message traffic stops.

Fatal form — REFUTED as an explanation of the observed wedge. The hypothesis was that a new weaver on the same HWND makes the chain self-referencing (SR wndproc -> CallWindowProcA -> SR wndproc -> ...) until the stack is gone. A falsifier was published in advance — "the spinning thread is somewhere else entirely" — and it fired: no SR frames execute anywhere in the wedged process.

The symptom profile was reproduced deliberately (fresh editor → Play → 12 LoadScene cycles → Stop → Play → Stop → CloseMainWindow), and it wedged at 2,174 lines where history showed 5,955 — so the burst count was never a threshold. But the wedge is a managed hang, not a wndproc recursion.

Still open — one structural argument survives, on a different artefact. Dump Unity.exe.23896 shows a repeating 7,344-byte frame pair — and a pair is exactly the unit a self-referencing subclass produces (wndproc -> CallWindowProcA -> wndproc). The mechanism that would produce self-reference rather than a finite chain: every SR layer is the same function at the same address, so weaver 2 subclassing an already-subclassed window reads GWLP_WNDPROC, gets SR's own proc, and stores it as the proc to forward to — arming unbounded recursion at two layers. Three reads settle it, all from that one dump:

  1. Do the repeating frames land inside SimulatedRealityDirectX.dll?
  2. Are the two return addresses in the pair identical (self-reference) or different (finite chain)?
  3. StackBase - StackLimit — ~1 MB implies ~419 B/frame against the observed 3,672 (8.7x off, a different recursion); ~8 MB gives ~2,280 frames (consistent with the recorded ~2500).

Tests, with predictions published in advance

A — docked/child-glue, Play → Stop → Play, then re-probe. Expect the SR wndproc still on UnityContainerWndClass, and burst rate/count escalating per cycle rather than resetting. A reset to baseline, or the SR hit disappearing, kills it.

B — the strong falsifier, DISPLAYXR_PROV_EXTERNAL_WINDOW=1. Undocked PRESENT mode makes our window a visible top-level one, where GA_ROOT == self and we do destroy it at LifecycleStop. Expect no SR wndproc on any Unity window afterwards and no burst. A burst here too means the GA_ROOT/ancestor theory is wrong.

B can destroy the mechanism outright where A can only corroborate, so B is the more valuable run. If B is clean it is also an immediate workaround for affected integrators.

Fix options

  1. Restore GWLP_WNDPROC on weaver destroy (SR SDK / leia-plugin) — the correct fix, upstream.
  2. Anchor the weaver to a window we own and destroy on the docked path, so the subclass cannot outlive the session — removes the accumulation by construction rather than by relying on an unenforced contract.
  3. Refuse to create a weaver against a window that already carries an SR subclass — the neighbouring code already knows this shape: d3d12_split_retire carries the comment "One weaver per HWND — a create against a window that still has one is refused, and the failure is silent weaving."

Related

A near-miss worth keeping, verbatim

While searching the wedge capture for SR frames, the investigator found five SimulatedReality hits — and checked before reporting. They were the lm module list, not stack frames.

Recording it because that search would have "confirmed" the fatal attribution to anyone who wanted it confirmed, and it was caught by the person whose own hypothesis it would have supported. Anyone grepping a stack capture for a module name should expect the module list to match first.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions