Three separate sightings on 2026-09-15, all surfaced by #18554's worker-error fixture rather than by any assertion, and all the same shape — a remote call issued against a port whose window has already gone:
apps/colorsupdateGrid destructuring a null grid after teardown;
DockCrossWindowWitnessNL — Target worker 'cw-win-a' does not exist;
WorkstationNativePopupOverPopupNL — no live port for destination "…" (remoteMethod Neo.main.DomAccess.getBoundingClientRect) and the same for Neo.main.addon.ResizeObserver.register, plus Neo.tab.plugin.Overflow: project() threw against a live owner.
Individually each reads as a local race and gets patched locally. Together they name one missing distinction. The class was spotted by @neo-opus-grace across the three; the mechanism below is measured.
This is not "add a .catch". The engine already has the machinery; see The Problem.
⛔ Scope correction, 2026-09-15 — NEO_DEAD_PORT is ONE OF TWO teardown shapes
Found by e2e-engine on #18743 and measured at dev@e1f1d00307. This body says "four sites implement it correctly with an identical predicate". That predicate covers one of the two shapes this engine actually rejects with:
shape
set at
already read by
code: 'NEO_DEAD_PORT'
src/worker/Base.mjs:670 — no live port for a destination
the four sites named below
name: 'PortDisconnectedError'
src/worker/Base.mjs:508 — port disconnected mid-flight
worker/Base.mjs:508 does Object.assign(new Error(…), {name: 'PortDisconnectedError'}) — no code at all. So four other sites already discriminate on the second shape and the predicate this ticket propagates does not.
Consequence, measured on #18743: the Gap-1 handlers added with that predicate reported eight PortDisconnectedError rejections into the page console — a window closing mid-call, which is the exact case this ticket exists to silence. e2e/core/WorkerErrorGate.spec.mjs:76 caught it; the unit tier cannot, because its stub never disconnects a real port.
So Gap 1 is not "copy the predicate to the sites that lack it." It is: the predicate needs both shapes, and then the sites need it. That is also the case for extracting isExpectedTeardown(reason) onto worker/Base.mjs — six transcriptions exist and four of them never learned the second shape, which is what copies do.
The Problem
src/worker/Base.mjs:670 already types the condition, deliberately:
// A window got closed and its message port no longer exists (SharedWorkers).// Typed, so consumers can discriminate expected teardown from real failuresreject(Object.assign(newError(`… no live port for destination "${dest}" …`), {code: 'NEO_DEAD_PORT'}))
So the design is caller-side discrimination, and four sites implement it correctly with an identical predicate — src/table/Body.mjs:631, src/grid/Body.mjs:1287, src/draggable/DragZone.mjs:445, src/worker/Base.mjs:204:
Fire-and-forget; the typed rejection becomes an unhandled App Worker rejection. src/grid/Container.mjs:361 has the same shape.
Gap 2 — the right instinct, the wrong predicate.src/tab/plugin/Overflow.mjs:814 guards deliberately, and its comment explicitly refuses a blanket catch:
// … a throw against a LIVE owner is a programming defect — surface it rather than swallow it// silently (a blanket catch would hide real bugs behind a "just a teardown race" assumption).if (owner.mounted) {
console.error('Neo.tab.plugin.Overflow: project() threw against a live owner', error)
}
That reasoning is correct and must not be weakened. But the observed error is emitted from inside that if, so owner.mounted was true while the port was dead. The error is conditional on owner.mounted; it firing is the measurement.
mounted is App-Worker state; the port belongs to a window. A component stays mounted in the App Worker's model after the window hosting it has closed, so owner.mounted cannot separate live owner from owner whose window closed — exactly the distinction the guard reaches for. The predicate is one layer off the thing it tests.
That is why this is one ticket and not three patches: closing Gap 1 alone silences two sightings and leaves the guarded site still wrong.
The Architectural Reality
src/worker/Base.mjs:655-682 — promiseMessage, and the NEO_DEAD_PORT typing that already exists.
src/tab/plugin/Overflow.mjs:809-821 — the deliberate guard whose predicate is one layer off.
src/core/Observable.mjs:156 — Duplicate event handler attached, which co-occurs across all three arms of the popup-over-popup spec and may share a root with re-registration after a retired window; not assumed to be the same defect.
There is no predicate today that answers "is this component's window still connected?" at the App-Worker/port boundary. Whether that belongs on manager.Window, on the component, or as a worker.Base helper is the design question this ticket opens, not one it answers.
The Fix
Attribution before repair; the two gaps have different answers.
Establish the liveness predicate — is the window backing this component still connected? — and decide its owning surface. This is the load-bearing step.
src/tab/plugin/Overflow.mjs:814 adopts it in place of owner.mounted, preserving the intent of its comment: a throw against a genuinely live owner must still surface.
Gap-1 sites (manager/DomEvent.mjs:131, grid/Container.mjs:361) take the established four-site predicate.
Re-run WorkstationNativePopupOverPopupNL whole-file with the Brain runtime; :585 is the arm that reaches teardown unconditionally.
Decision Record impact
none — no ADR currently governs liveness at the App-Worker/window-port boundary. If step 1 lands a new shared predicate, that step should reassess whether it warrants one.
Acceptance Criteria
AC-1 — A liveness predicate exists that distinguishes live owner from owner whose window has closed, with its owning surface named and its choice justified against manager.Window / component / worker.Base alternatives.
AC-2 — src/tab/plugin/Overflow.mjs uses it; a throw against a genuinely live owner still reports, shown by an arm that fails when the predicate is inverted.
AC-3 — src/manager/DomEvent.mjs:131 and src/grid/Container.mjs:361 discriminate NEO_DEAD_PORT using the same predicate as the four existing sites; a real failure at those sites still reports.
AC-4 — WorkstationNativePopupOverPopupNL, whole-file with the Brain runtime wired and --workers=1, reports zerono live port for destination and zero project() threw against a live owner. Measured at dev@6efce3e976: 5 and 1 respectively, so this is a real state change and not a flake flip.
AC-4 corrected 2026-09-15, after implementation, by its author. It originally required the :585 arm to pass. That conflated two defects, and the run proved them separable: the dead-port class went 5 → 0 and the Overflow false positive 1 → 0, while Duplicate event handler attached stayed at 7, unchanged — the defect this ticket's Out of Scope already excluded as "co-occurs, root not established". An arm-level pass is therefore not reachable from this ticket's own scope, and was never this ticket's to deliver. The arm's green lives on #18731's AC-2, where the arm-level criteria are. This is a correction of a mis-specified AC, not a bar lowered to match what shipped — the new wording is strictly measurable and the delivered evidence is what falsifies or confirms it.
AC-5 — Red-first: each repair shown failing before and passing after, at one head.
Out of Scope
The resize-clamp defect in the same spec — #18731, already fixed on agent/18731-resize-headroom; that fix is what unmasked this by letting two more arms reach teardown.
Duplicate event handler attached — co-occurs, root not established. Dispositioned here rather than assumed: if step 1 does not resolve it, it needs its own attribution.
Changing mounted's own semantics. It has consumers throughout the engine; this ticket adds a distinction rather than redefining an existing one.
Related
#18731 (the spec that surfaced it, and whose AC-2 this blocks) · #18554 (the fixture that caught all three) · #18303 (the acceptance row #18731 discharges)
Live latest-open sweep: checked latest 20 open issues at 2026-09-15T14:14:05Z; no equivalent found. Archived-content grep for NEO_DEAD_PORT / no live port / teardown ordering returned #15664 (CLOSED, App Worker death on popup birth) and #16439 (CLOSED, filesystem rmdir race) — neither is this. A2A in-flight claim sweep over the latest 30 messages, all read-states: no overlapping claim; @neo-opus-grace explicitly stood down on this lane.
Filed by ⚖️ Ada · @neo-opus-ada · Claude Opus 5 · Claude Code
tobiu referenced in commit cc9a18d - "fix(worker): a call that outlives its window port reads as departure, not defect (#18741) (#18743) on Sep 15, 2026, 7:35 PM
Context
Three separate sightings on 2026-09-15, all surfaced by
#18554's worker-error fixture rather than by any assertion, and all the same shape — a remote call issued against a port whose window has already gone:apps/colorsupdateGriddestructuring a nullgridafter teardown;DockCrossWindowWitnessNL—Target worker 'cw-win-a' does not exist;WorkstationNativePopupOverPopupNL—no live port for destination "…" (remoteMethod Neo.main.DomAccess.getBoundingClientRect)and the same forNeo.main.addon.ResizeObserver.register, plusNeo.tab.plugin.Overflow: project() threw against a live owner.Individually each reads as a local race and gets patched locally. Together they name one missing distinction. The class was spotted by @neo-opus-grace across the three; the mechanism below is measured.
This is not "add a
.catch". The engine already has the machinery; see The Problem.⛔ Scope correction, 2026-09-15 —
NEO_DEAD_PORTis ONE OF TWO teardown shapesFound by
e2e-engineon#18743and measured atdev@e1f1d00307. This body says "four sites implement it correctly with an identical predicate". That predicate covers one of the two shapes this engine actually rejects with:code: 'NEO_DEAD_PORT'src/worker/Base.mjs:670— no live port for a destinationname: 'PortDisconnectedError'src/worker/Base.mjs:508— port disconnected mid-flightsrc/Neo.mjs:1255,1266·src/mixin/VdomLifecycle.mjs:388·src/worker/App.mjs:566worker/Base.mjs:508doesObject.assign(new Error(…), {name: 'PortDisconnectedError'})— nocodeat all. So four other sites already discriminate on the second shape and the predicate this ticket propagates does not.Consequence, measured on
#18743: the Gap-1 handlers added with that predicate reported eightPortDisconnectedErrorrejections into the page console — a window closing mid-call, which is the exact case this ticket exists to silence.e2e/core/WorkerErrorGate.spec.mjs:76caught it; the unit tier cannot, because its stub never disconnects a real port.So Gap 1 is not "copy the predicate to the sites that lack it." It is: the predicate needs both shapes, and then the sites need it. That is also the case for extracting
isExpectedTeardown(reason)ontoworker/Base.mjs— six transcriptions exist and four of them never learned the second shape, which is what copies do.The Problem
src/worker/Base.mjs:670already types the condition, deliberately:// A window got closed and its message port no longer exists (SharedWorkers). // Typed, so consumers can discriminate expected teardown from real failures reject(Object.assign(new Error(`… no live port for destination "${dest}" …`), {code: 'NEO_DEAD_PORT'}))So the design is caller-side discrimination, and four sites implement it correctly with an identical predicate —
src/table/Body.mjs:631,src/grid/Body.mjs:1287,src/draggable/DragZone.mjs:445,src/worker/Base.mjs:204:reason !== Neo.isDestroyed && reason?.code !== 'NEO_DEAD_PORT' && console.error(…)The sightings are the sites that do not. They split into two gaps, and only the first is mechanical.
Gap 1 — no handler at all.
src/manager/DomEvent.mjs:131:ResizeObserver.register({componentId: component.id, id: targetId, windowId})Fire-and-forget; the typed rejection becomes an unhandled App Worker rejection.
src/grid/Container.mjs:361has the same shape.Gap 2 — the right instinct, the wrong predicate.
src/tab/plugin/Overflow.mjs:814guards deliberately, and its comment explicitly refuses a blanket catch:// … a throw against a LIVE owner is a programming defect — surface it rather than swallow it // silently (a blanket catch would hide real bugs behind a "just a teardown race" assumption). if (owner.mounted) { console.error('Neo.tab.plugin.Overflow: project() threw against a live owner', error) }That reasoning is correct and must not be weakened. But the observed error is emitted from inside that
if, soowner.mountedwastruewhile the port was dead. The error is conditional onowner.mounted; it firing is the measurement.mountedis App-Worker state; the port belongs to a window. A component staysmountedin the App Worker's model after the window hosting it has closed, soowner.mountedcannot separate live owner from owner whose window closed — exactly the distinction the guard reaches for. The predicate is one layer off the thing it tests.That is why this is one ticket and not three patches: closing Gap 1 alone silences two sightings and leaves the guarded site still wrong.
The Architectural Reality
src/worker/Base.mjs:655-682—promiseMessage, and theNEO_DEAD_PORTtyping that already exists.src/manager/DomEvent.mjs:121-132—addResizeObserver, unhandled.src/grid/Container.mjs:361— same shape.src/tab/plugin/Overflow.mjs:809-821— the deliberate guard whose predicate is one layer off.src/core/Observable.mjs:156—Duplicate event handler attached, which co-occurs across all three arms of the popup-over-popup spec and may share a root with re-registration after a retired window; not assumed to be the same defect.manager.Window, on the component, or as aworker.Basehelper is the design question this ticket opens, not one it answers.The Fix
Attribution before repair; the two gaps have different answers.
src/tab/plugin/Overflow.mjs:814adopts it in place ofowner.mounted, preserving the intent of its comment: a throw against a genuinely live owner must still surface.manager/DomEvent.mjs:131,grid/Container.mjs:361) take the established four-site predicate.WorkstationNativePopupOverPopupNLwhole-file with the Brain runtime;:585is the arm that reaches teardown unconditionally.Decision Record impact
none— no ADR currently governs liveness at the App-Worker/window-port boundary. If step 1 lands a new shared predicate, that step should reassess whether it warrants one.Acceptance Criteria
AC-1 — A liveness predicate exists that distinguishes live owner from owner whose window has closed, with its owning surface named and its choice justified against
manager.Window/ component /worker.Basealternatives.AC-2 —
src/tab/plugin/Overflow.mjsuses it; a throw against a genuinely live owner still reports, shown by an arm that fails when the predicate is inverted.AC-3 —
src/manager/DomEvent.mjs:131andsrc/grid/Container.mjs:361discriminateNEO_DEAD_PORTusing the same predicate as the four existing sites; a real failure at those sites still reports.AC-4 —
WorkstationNativePopupOverPopupNL, whole-file with the Brain runtime wired and--workers=1, reports zerono live port for destinationand zeroproject() threw against a live owner. Measured atdev@6efce3e976: 5 and 1 respectively, so this is a real state change and not a flake flip.AC-5 — Red-first: each repair shown failing before and passing after, at one head.
Out of Scope
#18731, already fixed onagent/18731-resize-headroom; that fix is what unmasked this by letting two more arms reach teardown.Duplicate event handler attached— co-occurs, root not established. Dispositioned here rather than assumed: if step 1 does not resolve it, it needs its own attribution.mounted's own semantics. It has consumers throughout the engine; this ticket adds a distinction rather than redefining an existing one.Related
#18731(the spec that surfaced it, and whose AC-2 this blocks) ·#18554(the fixture that caught all three) ·#18303(the acceptance row#18731discharges)Live latest-open sweep: checked latest 20 open issues at 2026-09-15T14:14:05Z; no equivalent found. Archived-content grep for
NEO_DEAD_PORT/no live port/teardown orderingreturned#15664(CLOSED, App Worker death on popup birth) and#16439(CLOSED, filesystem rmdir race) — neither is this. A2A in-flight claim sweep over the latest 30 messages, all read-states: no overlapping claim; @neo-opus-grace explicitly stood down on this lane.Filed by ⚖️ Ada ·
@neo-opus-ada· Claude Opus 5 · Claude Code