Context
Found while delivering RA-3 of the #17970 review (pop-out header action, #17947). The RA asked for teardown arms proving "no unhandled rejection, no post-destroy terminal commit, and exactly-once vessel cleanup". The first two hold. The third does not — and it is not the pop-out leaf's doing: the gap is in the shared tear-out machine, and the drag gesture reaches it the same way.
Live latest-open sweep: checked the latest 20 open issues at 2026-08-31T22:09:10Z; no equivalent found. A2A in-flight claim sweep at 22:09Z: no overlapping [lane-claim].
The Problem
Neo.dashboard.dock.window.TearOut's onDockTearOutExit opens the host's vessel and then awaits it. That await is unbounded — a host's platform seam can take arbitrarily long to answer. If the owning Neo.dashboard.dock.Workspace is destroyed inside that window, the host has a real open window that nothing ever asks it to close.
The coordinator already carries the machinery for exactly this case. onDockTearOutExit has a late-vessel branch:
if (state.invalidated || state.token !== admissionGeneration) {
if (!state.externallyRetired && vessel?.windowName) {
lateVessel = createVesselIdentity(data.itemId, vessel, state.token);
await retireVessel(lateVessel)
}
return false
}That branch never runs on teardown, because nothing invalidates the pending admission when the workspace goes away. Workspace#destroy calls retireTearOutState() and then nulls tearOutHandlers. retireTearOutState() sweeps tearOutPanes, tearOutConnects, tearOutAdmissions, tearOutRetirements and tearOutHandlers.activeVessel — but a pending admission lives in TearOut's own closure as pendingAdmission, which is none of those. The sweep sees nothing, teardown completes, and the vessel resolves afterwards into a coordinator whose state is already retired.
The compensation added on the consumer side in Workspace#handleDockPopOutAction does not cover it either, and cannot: it sits on the isDestroyed-after-admission branch, which is unreachable here because the coordinator answers the refused-admission path first. The handler reports Dock pop-out was refused by the host vessel seam — attributing to the host a refusal the workspace's own teardown caused.
The Architectural Reality
src/dashboard/dock/window/TearOut.mjs — onDockTearOutExit, pendingAdmission, invalidateAdmission(itemId) (already exists, currently reached only from onVesselRetired).
src/dashboard/dock/Workspace.mjs — destroy() (retireTearOutState() then tearOutHandlers = null), retireTearOutState(), retireTearOutVessel().
retireTearOutVessel() opens with key && me.tearOutRetirements.set(key, vessel). On a destroyed instance those maps are gone, so even a late retirement that did fire would throw before reaching closeTearOutVessel() — the close seam is unreachable from the far side of teardown by two independent mechanisms, not one.
Reproducer
Pinned as an executable tripwire in test/playwright/unit/dashboard/DockWorkspace.spec.mjs, arm "#17947 an ADMISSION that lands after teardown closes its vessel exactly once": park the handler in the admission await via a deferred openTearOutVessel, call a real destroy(), then resolve the vessel.
Measured across both close collectors — the harness's own closeRequests seam and an instance-level closeTearOutVessel override — each empty. The second collector matters: the first alone cannot distinguish "never closed" from "closed through a seam I was not watching", and destroy() clears the harness field, so reading it post-teardown would report an empty observation rather than an observed absence.
The Fix
Teardown must invalidate in-flight admissions so the coordinator's existing late-vessel path runs, and the close must survive the workspace that started it. Two halves, both needed:
- Expose an admission-invalidation entry on the handler surface (
invalidateAdmission already exists internally) and call it from Workspace#destroy before tearOutHandlers is nulled, so a late vessel takes the state.invalidated branch.
- Make the retirement path teardown-tolerant: on a destroyed instance,
retireTearOutVessel() must still reach closeTearOutVessel(vessel) and skip the bookkeeping reconciliation that has nothing left to reconcile — rather than throwing on absent maps.
Ordering is the crux: the host's close seam has to be callable after the component owning it is gone, so whatever holds it must not be torn down before the vessels it is responsible for.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
createDockTearOutHandlers(...) return object |
src/dashboard/dock/window/TearOut.mjs:147 |
Adds an admission-invalidation entry to the returned handler set |
Absent entry ⇒ current behaviour (leak) |
TearOut.mjs seam JSDoc (seams.closeVessel block) |
New unit arm must go from red to green |
Workspace#destroy |
src/dashboard/dock/Workspace.mjs |
Invalidates pending admissions before nulling tearOutHandlers |
No pending admission ⇒ unchanged |
destroy() JSDoc |
Existing teardown arms stay green |
Workspace#retireTearOutVessel |
src/dashboard/dock/Workspace.mjs:633 |
Tolerates a destroyed instance: closes, skips reconciliation |
Live instance ⇒ unchanged |
method JSDoc |
Reproducer arm above |
Decision Record impact
none — this repairs an unhandled lifecycle race inside the existing tear-out contract; it does not change what that contract promises.
Acceptance Criteria
Out of Scope
- The pop-out header action itself (#17947 / #17970) — that leaf pins the behaviour and does not repair the machine.
- Any change to admission policy (
admitTearOutConnection) or to the commit grammar. This is teardown ordering only.
- Vessel-conversion (
onDockVesselConversion*) paths, unless the same fix covers them for free.
Avoided Traps
- Compensating in the consumer handler. Already attempted in #17970:
me.tearOutHandlers?.retireActiveVessel?.() on the post-admission branch. It cannot work — destroy() has already nulled tearOutHandlers, so the optional chain silently no-ops, and the coordinator refuses before that branch is reached anyway. A guard that can only ever no-op reads as protection while providing none.
- Asserting the desired behaviour and calling it covered. The reproducer deliberately pins what is TRUE today and is marked as a leak, so the gap cannot be laundered into a green suite.
- Fixing only
retireTearOutVessel's throw. That makes the crash go away without closing the window — a strictly worse outcome than the current failure, because it removes the only signal.
Related
- #17947 — the leaf that surfaced this; #17970 carries the reproducer.
- #13158 — QT-parity docking polish epic.
Handoff Retrieval Hints
query_raw_memories: "tear-out pending admission teardown orphaned vessel window"
- Commit anchor:
fe19ebad44 on grace/17947-dock-popout — adds the two teardown arms and the tripwire.
- The arms are the fastest way back into this:
npm run test-unit -- test/playwright/unit/dashboard/DockWorkspace.spec.mjs -g "17947".
Context
Found while delivering RA-3 of the #17970 review (pop-out header action, #17947). The RA asked for teardown arms proving "no unhandled rejection, no post-destroy terminal commit, and exactly-once vessel cleanup". The first two hold. The third does not — and it is not the pop-out leaf's doing: the gap is in the shared tear-out machine, and the drag gesture reaches it the same way.
Live latest-open sweep: checked the latest 20 open issues at 2026-08-31T22:09:10Z; no equivalent found. A2A in-flight claim sweep at 22:09Z: no overlapping
[lane-claim].The Problem
Neo.dashboard.dock.window.TearOut'sonDockTearOutExitopens the host's vessel and then awaits it. That await is unbounded — a host's platform seam can take arbitrarily long to answer. If the owningNeo.dashboard.dock.Workspaceis destroyed inside that window, the host has a real open window that nothing ever asks it to close.The coordinator already carries the machinery for exactly this case.
onDockTearOutExithas a late-vessel branch:if (state.invalidated || state.token !== admissionGeneration) { if (!state.externallyRetired && vessel?.windowName) { lateVessel = createVesselIdentity(data.itemId, vessel, state.token); await retireVessel(lateVessel) } return false }That branch never runs on teardown, because nothing invalidates the pending admission when the workspace goes away.
Workspace#destroycallsretireTearOutState()and then nullstearOutHandlers.retireTearOutState()sweepstearOutPanes,tearOutConnects,tearOutAdmissions,tearOutRetirementsandtearOutHandlers.activeVessel— but a pending admission lives in TearOut's own closure aspendingAdmission, which is none of those. The sweep sees nothing, teardown completes, and the vessel resolves afterwards into a coordinator whose state is already retired.The compensation added on the consumer side in
Workspace#handleDockPopOutActiondoes not cover it either, and cannot: it sits on theisDestroyed-after-admission branch, which is unreachable here because the coordinator answers the refused-admission path first. The handler reportsDock pop-out was refused by the host vessel seam— attributing to the host a refusal the workspace's own teardown caused.The Architectural Reality
src/dashboard/dock/window/TearOut.mjs—onDockTearOutExit,pendingAdmission,invalidateAdmission(itemId)(already exists, currently reached only fromonVesselRetired).src/dashboard/dock/Workspace.mjs—destroy()(retireTearOutState()thentearOutHandlers = null),retireTearOutState(),retireTearOutVessel().retireTearOutVessel()opens withkey && me.tearOutRetirements.set(key, vessel). On a destroyed instance those maps are gone, so even a late retirement that did fire would throw before reachingcloseTearOutVessel()— the close seam is unreachable from the far side of teardown by two independent mechanisms, not one.Reproducer
Pinned as an executable tripwire in
test/playwright/unit/dashboard/DockWorkspace.spec.mjs, arm "#17947 an ADMISSION that lands after teardown closes its vessel exactly once": park the handler in the admission await via a deferredopenTearOutVessel, call a realdestroy(), then resolve the vessel.Measured across both close collectors — the harness's own
closeRequestsseam and an instance-levelcloseTearOutVesseloverride — each empty. The second collector matters: the first alone cannot distinguish "never closed" from "closed through a seam I was not watching", anddestroy()clears the harness field, so reading it post-teardown would report an empty observation rather than an observed absence.The Fix
Teardown must invalidate in-flight admissions so the coordinator's existing late-vessel path runs, and the close must survive the workspace that started it. Two halves, both needed:
invalidateAdmissionalready exists internally) and call it fromWorkspace#destroybeforetearOutHandlersis nulled, so a late vessel takes thestate.invalidatedbranch.retireTearOutVessel()must still reachcloseTearOutVessel(vessel)and skip the bookkeeping reconciliation that has nothing left to reconcile — rather than throwing on absent maps.Ordering is the crux: the host's close seam has to be callable after the component owning it is gone, so whatever holds it must not be torn down before the vessels it is responsible for.
Contract Ledger Matrix
createDockTearOutHandlers(...)return objectsrc/dashboard/dock/window/TearOut.mjs:147TearOut.mjsseam JSDoc (seams.closeVesselblock)Workspace#destroysrc/dashboard/dock/Workspace.mjstearOutHandlersdestroy()JSDocWorkspace#retireTearOutVesselsrc/dashboard/dock/Workspace.mjs:633Decision Record impact
none— this repairs an unhandled lifecycle race inside the existing tear-out contract; it does not change what that contract promises.Acceptance Criteria
DockWorkspace.spec.mjs(currently assertingtoHaveLength(0)and labelled a leak) is inverted to the exactly-once assertion in the same PR. It is written to go red when this is fixed, precisely so it cannot be left behind.retireTearOutVessel()on a destroyed instance does not throw.refused by the host vessel seam.Out of Scope
admitTearOutConnection) or to the commit grammar. This is teardown ordering only.onDockVesselConversion*) paths, unless the same fix covers them for free.Avoided Traps
me.tearOutHandlers?.retireActiveVessel?.()on the post-admission branch. It cannot work —destroy()has already nulledtearOutHandlers, so the optional chain silently no-ops, and the coordinator refuses before that branch is reached anyway. A guard that can only ever no-op reads as protection while providing none.retireTearOutVessel's throw. That makes the crash go away without closing the window — a strictly worse outcome than the current failure, because it removes the only signal.Related
Handoff Retrieval Hints
query_raw_memories: "tear-out pending admission teardown orphaned vessel window"fe19ebad44ongrace/17947-dock-popout— adds the two teardown arms and the tripwire.npm run test-unit -- test/playwright/unit/dashboard/DockWorkspace.spec.mjs -g "17947".