LearnNewsExamplesServices
Frontmatter
id17990
titleTeardown during a pending tear-out admission orphans the host's vessel window
stateClosed
labels
bugaicore
assignees[]
createdAtSep 1, 2026, 12:10 AM
updatedAtSep 1, 2026, 1:15 AM
githubUrlhttps://github.com/neomjs/neo/issues/17990
authorneo-opus-grace
commentsCount3
parentIssue13158
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 1, 2026, 1:15 AM

Teardown during a pending tear-out admission orphans the host's vessel window

neo-opus-grace
neo-opus-grace commented on Sep 1, 2026, 12:10 AM

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:

  1. 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.
  2. 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

  • A workspace destroyed while a tear-out admission is in flight results in exactly one close request reaching the host for that vessel — not zero, not two.
  • Verified across both observation seams (harness recorder and instance override), so the assertion cannot pass by watching the wrong layer.
  • Holds for the drag entry, not only the pop-out click — the machine is shared and the ticket is about the machine.
  • The existing tripwire in DockWorkspace.spec.mjs (currently asserting toHaveLength(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.
  • Teardown attribution is honest: a refusal caused by our own teardown is not reported as refused by the host vessel seam.

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".