LearnNewsExamplesServices
Frontmatter
id19056
titleA fire-and-forget call into a departed window surfaces as a worker error
stateClosed
labels
bugaitestingcore
assigneesneo-opus-grace
createdAtSep 23, 2026, 12:47 AM
updatedAtSep 23, 2026, 2:15 AM
githubUrlhttps://github.com/neomjs/neo/issues/19056
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 23, 2026, 2:15 AM

A fire-and-forget call into a departed window surfaces as a worker error

neo-opus-grace
neo-opus-grace commented on Sep 23, 2026, 12:47 AM

Context

The #17844 battery at dev@e66b6f8142 (census) has five red arms with one shape. A window closes. Teardown code that nobody awaits then calls into it. The call rejects with NEO_DEAD_PORT, and the App Worker mirrors the unhandled rejection to the page, where #18716's gate ("a worker or page error nobody named fails its test", 2026-09-15) fails the test.

The rejections are not new: NEO_DEAD_PORT has been typed since #17896 (08-31). They were silent until the gate. Three of the five are the "inherited" reds @neo-gpt-emmy baseline-reproduced for #19054 (receipt).

The Problem

After the window left, these calls reject and nobody handles them:

  • ScrollSync.unregister, grid/VerticalScrollbar.mjs:95
  • ResizeObserver.unregister, grid/Container.mjs:364 and :1054
  • the GridDragScroll / GridRowScrollPinning / GridHorizontalScrollSync unregisters, grid/ScrollManager.mjs:285, :307, :327, :357
  • DomAccess.transferCanvasToWorker, worker.Manager.startWorker, DomAccess.getLayoutRect

Arms (by title, dev@e66b6f8142): WindowOps › operates one exact popup generation…, WorkstationTopologyGroupsNL › warm F5 preserves the Workspace… and › A and its pop-out share one Group…, WorkstationReclaimEmbodimentNL › control: a vessel that connected and then closed…, SharedWorkerRemoteReplay › a second window and a reloaded window….

The Architectural Reality

  • worker.Base#promiseMessage (src/worker/Base.mjs:673-696) rejects a send with no live port as code: 'NEO_DEAD_PORT'. The error message carries the destination, which for window-bound remotes is the windowId.
  • worker.Base#isWindowDeparted (:415, #18741) answers at the instant of rejection, because removePort() records the departure before onDisconnect() fires. It is bounded to recent departures.
  • The unhandledrejection listener (:314-316) mirrors every rejection that is not defaultPrevented. Its comment states the current contract: Neo.mjs marks the rejections it expects as handled.
  • Six sites mark their rejections today, using three different predicates:
    • both halves: container/Viewport.mjs:144 (#19011)
    • code only: draggable/DragZone.mjs:445, grid/Body.mjs:1321, table/Body.mjs:631
    • checked before the call: manager/DomEvent.mjs:353, tab/plugin/Overflow.mjs:862 (#18741)

The Fix

Fork, open with @neo-opus-ada (#18716 / #18741 are her surface):

  • A — settle at the unhandled boundary (recommended). promiseMessage attaches destination to the NEO_DEAD_PORT rejection. The unhandledrejection listener drops a rejection only when it is NEO_DEAD_PORT and isWindowDeparted(destination) is true.
    • Awaited or caught calls still receive their rejection.
    • A never-known or still-live destination is still mirrored, so Overflow's rule that "a throw against a live owner is a defect" holds.
    • Per-site copies that exist only for this settlement become deletable.
  • B — per site. Apply the both-halves predicate at the nine new sites, as #19011 did. This is the precedent, and it is the fifteenth copy of one rule.

Falsifier for A: a teardown caller that must observe a departed-window rejection without handling it. None is known; any site that needs it already handles it, so A never reaches it.

Contract Ledger Matrix

Surface Authority Behavior Fallback Docs Evidence
worker.Base unhandled-rejection mirror #18716 gate contract; #18741 departure predicate A: a NEO_DEAD_PORT rejection to a departed destination is not mirrored; everything else unchanged Handled and awaited rejections unchanged The listener's comment and promiseMessage JSDoc Unit arms with a mutation control; the five battery arms
NEO_DEAD_PORT rejection promiseMessage Gains destination (additive) The message string is unchanged promiseMessage JSDoc Unit arm reads the field

Acceptance Criteria

  • AC-1 — The fork is recorded on this ticket before implementation merges (A or B, with the reason). 2026-09-22: A, approved by @neo-opus-ada (owner of #18716 / #18741) at 22:49Z. Her two conditions: settled drops stay countable (settledDepartureCount), and only per-site copies that exist solely for this settlement are deleted. None of the six does, so all stay (see AC-4).

  • AC-2 — Unit arms cover four cases:

    • an unawaited call to a destination that departed produces no mirrored error;
    • the same rejection to a never-known destination is mirrored;
    • a live destination's failure is mirrored;
    • a caught call still receives its rejection.

    Disabling the settlement turns the first arm red.

  • AC-3 — The four arms that fail on this class pass on the merge head, run as whole files with one worker and NEO_AGENTOS_RUNTIME_ROOT wired: WindowOps, SharedWorkerRemoteReplay, WorkstationReclaimEmbodimentNL › control: a vessel that connected and then closed… and WorkstationTopologyGroupsNL › A and its pop-out share one Group…. Amended 2026-09-22: the fifth arm, warm F5, still fails once this class is settled, on a different cause, and moved to #19060.

  • AC-4 — Under A, per-site handling whose only purpose is this settlement is removed, or kept with a named reason. Under B, every listed site uses the both-halves predicate, not the code-only one.

Out of Scope

  • The main-thread Target worker … does not exist (src/worker/Manager.mjs:612, DockCrossWindowWitnessNL): a different transport.
  • Neo.isDestroyed rejections.
  • Specs that provoke an error on purpose and do not name it for the gate (CreateInstance, the two WriteGuard specs).

Avoided Traps

  • A blanket catch at each call site. It hides live-owner defects; #18741 rejected it for that reason.
  • Code-only discrimination. NEO_DEAD_PORT means "unreachable", not "departed"; a port that never existed carries it too.

Decision Record impact

None. The policy stays inside worker.Base and reuses #18741's predicate.

Related

Sweeps: live latest-open at 2026-09-22T22:45Z and 22:47Z, no equivalent. A2A claims in the last 30 messages: none on this class. Memory Core: #18731/#18741 prior design (per-site predicate; blanket catch rejected). Own assignments: none overlapping.

Origin Session ID: 19051047-2f38-4bf0-be51-efd6b957564d

Retrieval Hint: query_raw_memories("NEO_DEAD_PORT departed window teardown unhandled rejection isWindowDeparted unregister")

tobiu referenced in commit a186e92 - "fix(worker): a teardown call into a departed window is settled, not mirrored as a defect (#19056) (#19061) on Sep 23, 2026, 2:15 AM
tobiu closed this issue on Sep 23, 2026, 2:15 AM