Context
I found this while building #19583's witness. I ran it headless at dev f1fbfe5c08 on FiveBeat's ordinary stage (viewport 1280×800, screen 1920×1080), driving NativeGestureDriver#executeStackReturnStep, the executor FiveBeat scene 4 runs in CI:
| Vessel |
Home tabs node |
Result |
metrics alone |
right-top-tabs, in a split with right-bottom-tabs |
hangs, every run (≥ 6) |
commits alone |
scale-tabs, in a split with heavy-tabs |
hangs, every run (≥ 4) |
metrics + queues |
right-top-tabs, split intact |
hangs (1/1) |
metrics + commits (scene 4's staging) |
right-top-tabs; the commits tear-out collapsed right-bottom-tabs |
settles in 0.6 s, every run |
A hang has these symptoms:
- The pane is adopted into main's document.
lastCrossWindowTransfer.phases stops at ["documents-adopted"].
- The Neural Link call that drives the gesture times out at 10 s.
- The emptied vessel stays open, and
TourController#getCueLog reports main's refresh pending.
Main then stays frozen. A later setActiveItem commit lands in the document and calls projectDockZoneDocument, but no refreshDockWorkspace starts within 3 s.
The Problem
The mouseup commits transferNode. VesselWorkspace#commitCrossWindowTransfer awaits main's refreshPromise before it records projections-settled (apps/workstation/view/VesselWorkspace.mjs:650), so the close never comes. Every later main projection chains onto that promise (src/dashboard/dock/Workspace.mjs:2295), so main's dock stops rendering.
The predicate is the home node's place in a split, not the vessel's item count. Two earlier readings of the stall, Mnemosyne's single-item note and my corroboration on #19583, were proxies. FiveBeat's staging never meets the predicate.
The Architectural Reality
transferNode admits the stable-topology path: VesselWorkspace#getRefreshOptions sets retainTopology, then Reconciler.reconcileProjection calls reconcileStableTopology.
reconcileStableTopology writes flex and wrapperStyle with setSilent on every retained node whose projection carries flex (src/dashboard/dock/projection/Reconciler.mjs:276-281). A silent write leaves needsVdomUpdate: true and schedules no flight (src/mixin/VdomLifecycle.mjs:1318-1319).
- The stable branch of
reconcileProjection awaits commitAncestors and then commitBars (Reconciler.mjs:494-502), before the host commit that would publish those writes (:503-505).
- A committed bar's
promiseUpdate() goes through mergeIntoParentUpdate (VdomLifecycle.mjs:955). It finds an ancestor flagged, registers as merged, and waits for that ancestor's update. Only the host commit after the await starts that update, so neither side moves.
- Measured with temporary trace markers, since reverted: the hanging refresh stops at the
commitBars await. At the call and again 3 s later, the target bar, its tab container and the split container above it all hold needsVdomUpdate: true, and none is updating. The flags' writer is Reconciler.mjs:277. In the settling case, the bar's ancestors hold no flag.
- The awaited bar commit publishes silent bar-membership changes. It existed as
materializedBars; #18025 (2f8fc79792) renamed it commitBars and widened it to both sides of a cross-bar move. commitAncestors serves same-window native reparenting. Either one hangs under a flex-bearing retained ancestor.
- Design authority: this is a hang that contradicts the surfaces' own JSDoc:
VdomLifecycle#promiseUpdate: "settles once that render has landed in the DOM";
executeStackReturnStep: it "waits through atomic transferNode, main projection, native close request, and physical topology exit".
- The Dock guide also lists stack return among the "four roads home" (
learn/guides/uibuildingblocks/DockLayoutsWindows.md:85).
The Fix
In Reconciler.mjs:
reconcileStableTopology returns its flex writes as geometry instead of applying them in mid-scan.
reconcileProjection applies them silently right before its depth -1 host commit, which publishes them in the same flight as today.
This adds no flight, and no descendant commit merges into an ancestor update that nothing has scheduled. width and height stay as they are: a non-silent set starts its own flight, which a descendant awaits through isParentUpdating, not through a merge.
Rejected alternatives:
- Flushing the written ancestors before the bars adds flights to every stable projection.
- A non-silent
flex costs one flight per split child on every splitter drag.
- Changing
mergeIntoParentUpdate changes engine-wide semantics for every silent write.
Acceptance Criteria
The evidence is in #19595, merged as 6188a7b049 with Emmy's approval:
DockStableSplitCommit.spec.mjs is red without the fix ("did not settle within 5000 ms").
WorkstationStackReturnSplitNL.spec.mjs is red 3/3 without the fix ("Request timed out").
- The full unit suite and CI's e2e job are green.
Out of Scope
- #19583: the close path and identity of re-opened windows. It gets re-measured on the fixed engine.
- Native-return adoption of a single-item vessel (Mnemosyne's third note on #19583).
VdomLifecycle merge semantics.
Related
#19583 · #19569 · #15252 · #18025 · #19188
Sweeps:
- Live latest-open sweep: the latest 20 open issues at 04:2xZ, plus open-issue searches for "stack return never settles", "reconcileStableTopology", "stack return hangs", "emptied vessel stays open", "commitBars" and "refreshPromise pending", and open PRs. No equivalent found.
- A2A in-flight sweep: the last 30 messages, all states. No claim on the dock reconciler or stack return.
- MC sweep: "stack return home never settles emptied vessel window stays open main refresh pending reconcileProjection commitBars flex", 6 results. No prior decision found; #19188 fixed ownership after a whole-stack return, always on the composed staging.
- Own-assignment sweep: #19583 and #19569 are mine and on the same surface. Both are confounded by this hang; neither covers it.
Origin Session ID: a6673f82-f995-4e40-83a9-3093d4ecd357
Retrieval Hint: "stack return split tabs node hang reconcileStableTopology silent flex commitBars mergeIntoParentUpdate"
Context
I found this while building #19583's witness. I ran it headless at
devf1fbfe5c08on FiveBeat's ordinary stage (viewport 1280×800, screen 1920×1080), drivingNativeGestureDriver#executeStackReturnStep, the executor FiveBeat scene 4 runs in CI:metricsaloneright-top-tabs, in a split withright-bottom-tabscommitsalonescale-tabs, in a split withheavy-tabsmetrics+queuesright-top-tabs, split intactmetrics+commits(scene 4's staging)right-top-tabs; thecommitstear-out collapsedright-bottom-tabsA hang has these symptoms:
lastCrossWindowTransfer.phasesstops at["documents-adopted"].TourController#getCueLogreports main's refreshpending.Main then stays frozen. A later
setActiveItemcommit lands in the document and callsprojectDockZoneDocument, but norefreshDockWorkspacestarts within 3 s.The Problem
The mouseup commits
transferNode.VesselWorkspace#commitCrossWindowTransferawaits main'srefreshPromisebefore it recordsprojections-settled(apps/workstation/view/VesselWorkspace.mjs:650), so the close never comes. Every later main projection chains onto that promise (src/dashboard/dock/Workspace.mjs:2295), so main's dock stops rendering.The predicate is the home node's place in a split, not the vessel's item count. Two earlier readings of the stall, Mnemosyne's single-item note and my corroboration on #19583, were proxies. FiveBeat's staging never meets the predicate.
The Architectural Reality
transferNodeadmits the stable-topology path:VesselWorkspace#getRefreshOptionssetsretainTopology, thenReconciler.reconcileProjectioncallsreconcileStableTopology.reconcileStableTopologywritesflexandwrapperStylewithsetSilenton every retained node whose projection carriesflex(src/dashboard/dock/projection/Reconciler.mjs:276-281). A silent write leavesneedsVdomUpdate: trueand schedules no flight (src/mixin/VdomLifecycle.mjs:1318-1319).reconcileProjectionawaitscommitAncestorsand thencommitBars(Reconciler.mjs:494-502), before the host commit that would publish those writes (:503-505).promiseUpdate()goes throughmergeIntoParentUpdate(VdomLifecycle.mjs:955). It finds an ancestor flagged, registers as merged, and waits for that ancestor's update. Only the host commit after the await starts that update, so neither side moves.commitBarsawait. At the call and again 3 s later, the target bar, its tab container and the split container above it all holdneedsVdomUpdate: true, and none is updating. The flags' writer isReconciler.mjs:277. In the settling case, the bar's ancestors hold no flag.materializedBars; #18025 (2f8fc79792) renamed itcommitBarsand widened it to both sides of a cross-bar move.commitAncestorsserves same-window native reparenting. Either one hangs under a flex-bearing retained ancestor.VdomLifecycle#promiseUpdate: "settles once that render has landed in the DOM";executeStackReturnStep: it "waits through atomictransferNode, main projection, native close request, and physical topology exit".learn/guides/uibuildingblocks/DockLayoutsWindows.md:85).The Fix
In
Reconciler.mjs:reconcileStableTopologyreturns itsflexwrites asgeometryinstead of applying them in mid-scan.reconcileProjectionapplies them silently right before its depth-1host commit, which publishes them in the same flight as today.This adds no flight, and no descendant commit merges into an ancestor update that nothing has scheduled.
widthandheightstay as they are: a non-silentsetstarts its own flight, which a descendant awaits throughisParentUpdating, not through a merge.Rejected alternatives:
flexcosts one flight per split child on every splitter drag.mergeIntoParentUpdatechanges engine-wide semantics for every silent write.Acceptance Criteria
retainTopologyprojection that adds a tab to a tabs node inside a split settles, and the new header button renders. Without the fix, the test hangs.executeStackReturnStepbrings a single-item vessel home into a split-held tabs node (metrics→right-top-tabs,commits→scale-tabs). Each return passes the four phases in order, the emptied vessel closes, and the pane is in its home node.metrics+queues).The evidence is in #19595, merged as
6188a7b049with Emmy's approval:DockStableSplitCommit.spec.mjsis red without the fix ("did not settle within 5000 ms").WorkstationStackReturnSplitNL.spec.mjsis red 3/3 without the fix ("Request timed out").Out of Scope
VdomLifecyclemerge semantics.Related
#19583 · #19569 · #15252 · #18025 · #19188
Sweeps:
Origin Session ID: a6673f82-f995-4e40-83a9-3093d4ecd357 Retrieval Hint: "stack return split tabs node hang reconcileStableTopology silent flex commitBars mergeIntoParentUpdate"