LearnNewsExamplesServices
Frontmatter
id18390
titleA destroyed DemoB leaves both its native sources live on the Group
stateClosed
labels
bugairegressiondashboard
assigneesneo-opus-ada
createdAtSep 6, 2026, 8:31 PM
updatedAtSep 6, 2026, 9:13 PM
githubUrlhttps://github.com/neomjs/neo/issues/18390
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 6, 2026, 9:13 PM

A destroyed DemoB leaves both its native sources live on the Group

Closed v13.2.0/archive-v13-2-0-chunk-19 bugairegressiondashboard
neo-opus-ada
neo-opus-ada commented on Sep 6, 2026, 8:31 PM

Context

#18314 moved DemoB's tear-out lifecycle onto its Group. That port introduced a second registerSource call site in DemoBWorkspace — one for vessels (popup:<itemId> keys), one for stages (the workspace id itself) — because this host speaks two workspace-key shapes and a source maps an item to exactly one key.

The teardown did not follow. Live on dev @ 3a0773c9a5:

$ git show origin/dev:examples/dashboard/crossWindow/DemoBWorkspace.mjs | grep -c 'registerSource('    → 2
$ git show origin/dev:examples/dashboard/crossWindow/DemoBWorkspace.mjs | grep -c 'unregisterSource'   → 0
$ git show origin/dev:src/dashboard/dock/Workspace.mjs                  | grep -c 'unregisterSource'   → 1

I authored the defect. #18383 merged before the follow-up commit existed, so it is on dev now.

Live latest-open sweep: checked the latest 20 open issues at 2026-09-06T18:30Z; no equivalent found. A2A in-flight claim sweep over the last ~60 min found no competing claim on this scope (@neo-gpt's open claims are #18388 VesselConversion and preview customization). Own-assignment sweep: no overlap.

The Problem

Neo.manager.transaction.NativeLifecycle holds each source's effects — open, close, prepare, bound, released — in a Map keyed by an opaque source id, and those effects are closures over the host component. The Group is deliberately longer-lived than any view that contributes to it: that is what lets a native window survive a re-render, and it is the whole reason unregisterSource exists as a separate verb from clearing ownership.

So a host that registers and never withdraws leaves the Group holding two callback sets pointing at a destroyed component. Nothing collects them, and nothing reports them: the registries stay well-formed, so no assertion fires and no error surfaces. The cost is silent retention plus the latent risk that a later Group event invokes an effect whose me is gone.

dashboard.dock.Workspace#destroy has always done this correctly for its single source. The pattern existed in the engine; the second consumer to register sources did not copy it.

The Architectural Reality

  • src/manager/transaction/NativeLifecycle.mjs — registerSource(sourceId, effects) / unregisterSource(sourceId); unregisterSource sets source.active = false and deliberately retains admissions / connections / owners / retirements.
  • src/dashboard/dock/Workspace.mjs — destroy() calls me.nativeWindows?.unregisterSource(me.id). One source, correctly withdrawn.
  • examples/dashboard/crossWindow/DemoBWorkspace.mjs — bindNativeWindowSources() registers vesselSourceId and stageSourceId; destroy() withdraws neither.

Withdrawal is not repossession. The correct teardown deactivates the effects while leaving ownership with the Group, because the Group may still own native windows the host no longer projects.

The Fix

Three lines in DemoBWorkspace#destroy, ahead of the existing TransactionManager.un(...):

me.nativeWindows?.unregisterSource(me.vesselSourceId);
me.nativeWindows?.unregisterSource(me.stageSourceId);
me.nativeWindows = null;

Plus the ratchet re-baseline the three lines require (2619 → 2622).

Acceptance Criteria

  • DemoBWorkspace#destroy withdraws both registered sources; grep -c unregisterSource on the file is ≥ 2.
  • A unit arm asserts both halves: every registered source reports active === false after destroy(), and its admissions registry still exists — an implementation that discarded the registries must fail this arm.
  • The arm is red against origin/dev's own DemoBWorkspace.mjs and green with the fix, failing on the active assertion rather than a downstream symptom.
  • buildScripts/util/file-size-baseline.json names the measured count, with a size-guard-growth: declaration in the PR body.

Out of Scope

  • The …expired or revoked reservation… unit failure. It reproduces on codex/18314-demo-writer with no merges applied and passes in isolation, so it is serial-block state leakage, not this defect and not reservation logic.
  • Any change to NativeLifecycle's own contract. unregisterSource already behaves correctly; only the caller was missing.
  • The wider #18304 façade-absorption work.

Avoided Traps

Building the red control from my own branch. Reverting my fix and re-running would prove the arm can go red; it would not prove the shipped code is broken. The control has to substitute origin/dev's actual file — for a "this is broken in production" claim, the live instance is the shipped one.

Asserting only active === false. That single assertion passes on an implementation that throws the registries away, which would be a worse bug than the leak. Both halves are required or the arm certifies the wrong property.

Related

  • #18314 — where the defect was introduced (closed).
  • #18383 — the PR that merged it to dev.
  • PR #18386 — the fix, open, with the red-first receipt.
  • Reported independently by @neo-opus-grace after #18383 merged.

Origin Session ID: 95f9ff6e-1ba5-43b6-860a-208fbf7f4442

Retrieval Hint: "DemoB registerSource without unregisterSource destroyed workspace leaves effects on Group"