LearnNewsExamplesServices
Frontmatter
id19186
titleA parked vessel can outlive the commit that consumed its pane
stateClosed
labels
bugairegressiondashboard
assigneesneo-fable
createdAtSep 24, 2026, 7:44 PM
updatedAtOct 1, 2026, 11:04 PM
githubUrlhttps://github.com/neomjs/neo/issues/19186
authorneo-fable
commentsCount7
parentIssuenull
subIssues
19278 Under a hand drag the parked vessel covers the target popup''s drop zones
subIssuesCompleted1
subIssuesTotal1
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtOct 1, 2026, 11:04 PM

A parked vessel can outlive the commit that consumed its pane

Closed v13.2.0/archive-v13-2-0-chunk-23 bugairegressiondashboard
neo-fable
neo-fable commented on Sep 24, 2026, 7:44 PM

Context

Re-scoped 2026-10-01 by the author; the original sighting and the park work it led to are kept below under Record.

The mask sighting this ticket opened with (2026-09-24) is settled: the operator's 2026-09-26 recording showed the mechanism — the park hid the converted vessel behind the target by z-order, and focus() raises nothing during a real OS drag — and #19278 / PR #19289 (merged 2026-09-27) made the pointer-path park target-clear, with ADR 0029 §2.8.6 amended. What remains is the one path the park work named and did not ship: a committed conversion whose parked vessel window is still alive afterwards.

Evidence: the operator's hand run of #19289's AC-5 on 2026-09-26 ~21:50Z, read through the Neural Link by @neo-opus-ada (the trail on PR #19289): the commit landed (metrics inside the alerts popup), lastVesselParkReceipt read parked / moved / cleared true, and the vessel window (neo-viewport-3 › neo-tab-container-9) stayed mounted with a pressed Metrics tab header over an empty body, lastVesselRestoreReceipt null. Whether that survivor is a #19289 regression or pre-existing is undetermined — a headless discrimination was planned and has no result on the record. Since #19278 a surviving parked vessel sits in a work-area corner, visible, no longer hidden behind the target.

The Problem

After a committed cross-popup conversion the parked source window must retire. A survivor keeps its stand-in (or an emptied tab header) on screen, and the next park on the same handle competes with it. The headless witnesses assert closure on both paths (test/playwright/e2e/workstation/WorkstationHumanPopupOverlapNL.spec.mjs:1338 polls sourcePage.isClosed() after the release into an existing popup; WorkstationNativePopupOverPopupNL.spec.mjs:458 expects sourceClosed: true in its commit poll — corrected 2026-10-01 17:2xZ by the author: the first version of this paragraph read that arm as recording only). Demo B's commit arm transfers from the MAIN window into a popup, so it has no source vessel to close. Nothing records the dispose outcome at runtime, so the hand sighting cannot be diagnosed from the operator's tab — a green witness proves the headless path, never the hand run.

The Architectural Reality

  • src/dashboard/dock/window/VesselPark.mjs:170-185 finishTerminal: a committed outcome calls disposeVessel exactly once; on strict refusal parked stays set — the module JSDoc (:28) says "a refusal retains exact retry authority" — and no caller exercises that authority.
  • src/dashboard/dock/window/NativeVesselTransaction.mjs:515 disposeVessel → descriptor.retireVessel → apps/workstation/view/VesselWorkspace.mjs:336 (and examples/dashboard/crossWindow/DemoBWorkspace.mjs:668) → src/dashboard/dock/window/TearOut.mjs:626 retireActiveVessel: refuses when the active vessel is null or its itemId / windowName mismatch; otherwise the host's close seam decides.
  • Receipts: lastVesselParkReceipt covers park / move / clear, the restore has its own receipt, the dispose has none.
  • ADR 0029 §2.8.6 (target-clear park on the pointer path, target-cover on the native title-bar path) is not touched.

The Fix

  1. A dispose receipt on the host — lastVesselDisposeReceipt with itemId, windowName, entry, nameMatches, admitted, attempts, stage, windowAlive and the refusal class (no-vessel · identity-mismatch · host-close-refused · vessel-replaced; the Contract Ledger below is the field authority) — published like the park receipt and readable through the Neural Link, so a hand run names its own failure.
  2. A bounded retry that stays exactly-once admitted (@neo-opus-ada's caution on this ticket, 2026-09-25): when the first dispose is refused while the vessel window still exists, one retry inside the park terminal after one macrotask, bound to the generation and window the first call addressed (the registry's token on every call, retireActiveVessel refusing a superseded one, the registry re-read before the second call — gone → no-vessel, replaced → vessel-replaced); a vessel already gone is never closed twice; the retry's outcome lands in the receipt.
  3. Witness parity: the pointer-path commit arms assert the dispose RECEIPT beside the closure they already assert (admitted: true, one attempt) — the receipt read over the Neural Link is the same read the hand run makes; the native arms already assert closure and their path keeps lastTearOutClose; Demo B has no vessel-commit arm to extend (corrected 2026-10-01, see The Problem).

Contract Ledger (added 2026-10-01 20:1xZ by the author, on the reviewer's round-1 finding)

Clause Contract
Receipt member lastVesselDisposeReceipt on the host (Workstation VesselWorkspace, static VESSEL_RECEIPT_MEMBERS.dispose; Demo B maps the same key inline). Published by NativeVesselTransaction.effectsFor().disposeVessel BEFORE its first retire call and amended in place, like the park and restore receipts. null until the first committed pointer-path conversion of that host.
Receipt fields admitted (the close happened) · attempts (retire calls made, 1 or 2) · entry (the registry named a vessel at the first call) · itemId · nameMatches (the registry's window name equals the requested one) · refusal (see below — the LAST refusal observed, kept as history when a retry then admits; null only when no call was refused) · stage (retiring → retired | refused) · windowAlive (the FIRST call's window still registered, re-read after the macrotask) · windowName.
Refusal classes no-vessel — the registry names no vessel for the item, at the first call OR when re-read before the retry (entry: true, attempts: 1 = retired externally between the attempts) · identity-mismatch — the registry's window name is not the requested one; never retried · host-close-refused — the host's exact retire refused without closing; retried once while the window lives · vessel-replaced — the re-read registry names a different window or lineage token for the item: a successor was admitted under the same name while the retry waited; never closed.
Retry identity Every retire call carries {generationToken?, itemId, windowName} captured at the FIRST call — the token when the registry entry holds one. TearOut.retireActiveVessel refuses a request whose token differs from the active vessel's (the onVesselRetired lineage rule); the effect additionally re-resolves the registry after the macrotask and makes no second call unless window AND token are unchanged.
Termination At most 2 calls; exactly-once admitted. Ends on: admitted · any refusal other than host-close-refused · the first call's window gone · the registry entry gone or replaced · a second host-close-refused.
Host mappings Workstation: publishReceipt: (key, receipt) => me[VESSEL_RECEIPT_MEMBERS[key]] = receipt, retireVessel: vessel => tearOutHandlers.retireActiveVessel(vessel) (the identity object passes through, token included). Demo B: the same two seams with an inline key map. The native title-bar path does not run this effect: it disposes through retireReturnedVessel → closeVessel and keeps lastTearOutClose; this member reads null there.
Evidence Unit: DockNativeVesselTransaction.spec dispose describe (8 arms: first-call success · retry admits · window gone never retried · second refusal · no-entry / mismatched-name classes · the token on every call · retired-between-attempts → no-vessel · successor-between-attempts → vessel-replaced), DockTearOut.spec (a stale lineage closes nothing; the exact one closes). E2E: WorkstationHumanPopupOverlapNL's POINTER-PATH commit arms read the receipt over the Neural Link beside the closure they assert — the provenance of the member's live reading; the title-bar witnesses stay on lastTearOutClose.

Acceptance Criteria

  • AC-1 Red-first unit arms on VesselPark and the host seam: a first dispose refused with the vessel alive → the bounded retry admits and the receipt reads attempts: 2, admitted: true; a vessel already retired → no second close call; a vessel retired or replaced between the attempts is never closed by the retry (no-vessel / vessel-replaced), and every call carries the lineage token the registry named first.
  • AC-2 lastVesselDisposeReceipt is published for every committed POINTER-PATH conversion (the path of the 2026-09-26 sighting; the native title-bar path disposes through the returned-vessel retirement and keeps its own lastTearOutClose receipt — corrected 2026-10-01 17:3xZ by the author after the native witnesses read null) and readable via the Neural Link; the operator's hand run reads it after a commit on his display (his receipt, never a Playwright stacking claim).
  • AC-3 WorkstationHumanPopupOverlapNL's pointer-path commit arms assert the dispose receipt (admitted: true, attempts: 1) beside the source closure they already assert, green headless where the matrix is green on dev (the two large-over-small reds are #19297, Ada's); WorkstationNativePopupOverPopupNL and DemoBCrossWindowDragNL stay at their dev baseline.
  • AC-4 The PR body states whether the 2026-09-26 survivor was reproduced (headless at dev, or by the hand run with the receipt) and whether #19289 regressed it or it pre-existed.

Out of Scope

The native title-bar park (keeps its target-cover binding); the film's screenplay and pacing; the provisional chrome (#19250) and the header proxy (#19248), both shipped.

Related

  • #19278 / PR #19289 — the target-clear park; resolved this ticket's original sighting
  • #19248 / PR #19254 — the tab-header proxy
  • #16117 — the popup-over-popup witness matrix; #18533 — the park / re-show transaction; #15252 — the film epic

Origin Session ID: b04b2ce2-c8b8-40ba-9f0a-4dd2585a5be1 (the re-scope); 4c0a5550-17ba-4752-9852-846afa537c86 (the 2026-09-25 readings) Retrieval Hint: "parked vessel outlives its commit disposeVessel refused no retry" · "lastVesselDisposeReceipt"

Record — the original sighting and the park work (2026-09-24 → 09-27)

Opened as "A vessel parked over another popup shows the stand-in mask instead of its pane": a headed film replay showed the parked source wearing the stand-in mask instead of its pane. The 09-25 readings settled that the stand-in inside the parked source is the design (the pane renders in the target's proxy; the source is hidden by z-order alone), that a refused park cannot paint the mask, and that the Playwright harness cannot witness stacking (document.hasFocus() emulated true in every page, occlusion backgrounding disabled — the compositor is the only witness; four film-stage runs held). The operator's 09-26 recording (frames below) showed the real failure under a hand drag: the park's focus() raise never happened, the vessel stayed on top of the target's zones and proxy, and the release committed it as its own popup. @neo-fable-clio ruled as §2.8.6's author (option 1, 2026-09-26 10:11Z): a target-clear park plus zones before the park; the amendment text below landed through PR #19289 (ADR 0029 §2.8.6 as of dev 2026-09-27). The bounded dispose retry named then as the leaf that ships alone is this ticket's scope now.

Operator recording, 2026-09-26 11:56 — the hand mechanism, frame by frame

The operator's screen recording (his primary display: the main Chrome window on the left, the popups on free desktop to the right, not over main) with #19242 and #19244 on dev:

  • 00:12–00:15: the Commits tab torn out; the vessel (a tall plain "Commits" header, the provisional chrome) rides the pointer at its top-left corner; while the corner is above the Metrics popup's content — over its title/URL bar — nothing claims (the claim reads the target's INNER rect; correct).
  • 00:16: the pointer enters the Metrics content. The vessel's body goes EMPTY — the live pane has been staged into the target's proxy, so the conversion ADMITTED — but the vessel window stays ABOVE the Metrics popup. Every affordance the conversion produced (the target's drop zones, the target-side proxy) is under it.
  • 00:17–00:18: the pane is back in the vessel, now with the committed dock header, and the vessel is committed as its own popup exactly where it stood, overlapping Metrics (undo count 2). The release did not commit into the target.

Reading: ADR 0029 §2.8.6's target-cover park hides the source "by z-order alone" — NativeVesselTransaction moves the vessel to the target's origin and then raises the target through Main#windowNativeFocus (win.focus() + a document.hasFocus() poll). Under a real OS mouse drag macOS Chrome does not bring the target above the dragged vessel on focus(). Every automated witness so far ran with focus emulated true and never captured the compositor, which is why five headed runs today "passed" while the hand fails.

Fork for the record's author (@neo-fable-clio) and the operator:

  1. Park by moving the vessel OUT of the target's frame (shrunk to Chrome's minimum extent, at a display edge or below the target) instead of behind it — keeps the vessel alive with no mid-gesture window.open, needs no z-order, and leaves the target's zones and the tab-header proxy (#19248) visible. Convert-out restores it exactly as today.
  2. Keep target-cover and find a raise that works under a drag (none known in the browser runtime; window.open('', name) re-acquisition is what §2.8.6 forbids).
  3. Render the target's zones before the park, as the native title-bar path does, so the hand sees where to drop even when the vessel stays on top — a mitigation, not a fix, since the vessel still covers the region under the pointer.

Receipts still wanted from the operator's session: lastVesselParkReceipt (refocused, compensated, refusedAt) — his Workstation tab joins the Neural Link bridge on reload (useAiClient: true).

§2.8.6 amendment text (as drafted 2026-09-26; landed through PR #19289 — ADR 0029 §2.8.6 is the authority now)

Replace the target-cover binding paragraph and its five steps with:

The Workstation browser-runtime park binding is target-clear (amended 2026-09-26, #19186; supersedes the target-cover binding of #16117 for the reason measured on the operator's display: under a real OS mouse drag window.focus() raises nothing, so a park that hides the vessel BEHIND the target by z-order leaves it in front, with every affordance of the conversion under it). Its admission still reads the source's live outer extent and the target's live inner extent; creation-time dimensions and equal-size assumptions are still not authority:

  1. resolve both connected generations from manager.Window and require opener-minted routes;
  2. require exact source position authority; require exact source resize authority only when the source outer frame exceeds the target inner frame on either axis;
  3. pause pointer-follow, drain already-issued physical moves, preserve the source's exact outer extent and origin, and — when needed — resize the same exact source handle to {width: min(sourceOuter.width, targetInner.width), height: min(sourceOuter.height, targetInner.height)};
  4. move the exact source route to the park position: the corner of the display's visible work area farthest from the target's inner rect, clamped so the whole frame stays inside the work area — never a far-negative or offscreen origin (the #16117 falsifier measured macOS/Chrome clamping those back onto the desktop);
  5. verify the target realm's observed outerWidth / outerHeight and the source's observed origin; the receipt records cleared — whether the parked frame intersects the target's inner rect — in place of the retired refocused.

No step focuses the target and no step polls document.hasFocus(): focus is not an input to admission any more. The order stays load-bearing: a resize refusal restores the original extent before pointer-follow resumes; a move refusal restores the original origin and extent. When the target's inner rect spans the display so that no corner is clear (cleared: false), the park is still admitted — the target's drop zones and its tab-header proxy render before the park (see below), so the hand sees where to drop under the parked frame's edge.

Add after "Park and re-show settlement re-enter the dock-blind DragCoordinator …":

Zones before park (amended 2026-09-26, #19186). A transition-owned frame whose pointer claim stands while the park is pending or refused still reaches onRemoteDragMove with embodyProxy: false, so the target renders its drop zones from the claim, as the native title-bar path does from its hover. The target-side proxy (#19248: the tab header) embodies only after the park admits; commit eligibility is unchanged.

Convert-out is unchanged: "Convert-out re-shows the same connected window … after restoring the exact pre-conversion outer extent" applies to the park position as it did to the target origin.

Acceptance (Clio's ruling): the park amendment ships together with zones-before-park, accepted on a hand receipt from the operator's display (lastVesselParkReceipt with cleared: true, and the hand seeing the zones); the bounded disposeVessel retry ships alone first.

Prior art the operator named: the legacy close-and-reopen path

Before dock layouts, apps/colors re-entered another window with the in-app proxy and joined that window's sort zone. The mechanism is still in the tree behind enableProxyToPopup: src/dashboard/Container.mjs:417 suspendWindowDrag CLOSES the popup when a remote target claims (Neo.Main.windowClose) and :387 resumeWindowDrag REOPENS it through openWidgetInPopup when the drag leaves the target; src/draggable/dashboard/SortZone.mjs:625 startRemoteDrag builds a DragProxyContainer in the target window and a placeholder in the target's sort zone. Its JSDoc calls itself "the legacy popup reacquisition path until vessel park/re-show replaces it": §2.8.6's park exists because a mid-gesture window.open is not reliably admitted (the operator remembers it as "partially working"). Option 1 keeps the vessel alive and needs no reopen; the proxy-plus-placeholder half is the precedent for #19248.

tobiu referenced in commit 53727b8 - "feat(dock): a committed conversion's dispose publishes a receipt and retries a refused close once (#19186) (#19352) on Oct 1, 2026, 11:04 PM
tobiu closed this issue on Oct 1, 2026, 11:04 PM