LearnNewsExamplesServices
Frontmatter
id19103
titleCanvas worker per window group: a second root must not kill the first
stateClosed
labels
bugaiarchitecturecore
assigneesneo-opus-ada
createdAtSep 23, 2026, 11:44 AM
updatedAtSep 24, 2026, 2:58 PM
githubUrlhttps://github.com/neomjs/neo/issues/19103
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[ ] 18379 The rendered two-root drag receipt for criterion 10: a tab dragged from one full Workstation root over a second full root never previews, stages or commits there, once two roots can coexist
closedAtSep 24, 2026, 2:58 PM
milestonev13.2

Canvas worker per window group: a second root must not kill the first

Closed v13.2.0/archive-v13-2-0-chunk-22 bugaiarchitecturecore
neo-opus-ada
neo-opus-ada commented on Sep 23, 2026, 11:44 AM

Context

A second main window of the same app (a no-opener root: a new tab, a bookmark, a second index.html) kills the first window's canvases in Chromium. Both roots share one canvas SharedWorker, and the first paint into a DOM canvas owned by another process kills that worker's host process (D#18730, measurement 2). @tobiu closed #18376 into D#18730: grouping workers per window group is the fix.

This ticket is the graduated v13.2 crash slice of D#18730: its fold [DIVERGENCE_FOLDED @ DC 18564640], corrected at body 2026-09-23T09:35:41Z. The Discussion's wider heap-boundary question (B, D, E, F, OQ1–OQ4) stays open after the cut. Roadmap cornerstone 4's done signal is this ticket's acceptance: the two-root witness green, with both roots still painting.

The Problem

worker/Manager.mjs#createWorker names every worker neomjs-<kind>-worker, so every window of an origin connects to one canvas SharedWorker. An opener popup shares its root's browsing-context group and process, so sharing with it is safe. An unrelated root does not, so sharing with it is fatal.

The app worker has the matching single point: App#onRegisterPort keeps one canvas port per worker name (channelPorts[msg.origin]). Under a per-group canvas worker, every group's canvas traffic would go to whichever group registered last (DC 18560992; confirmed independently by @neo-gpt-emmy).

The Architectural Reality

  • Carrier rule, from the fold. The canvas-group id lives in the window's own sessionStorage. It is honored only for a popup whose opener is live, which joins the opener's group, and for a boot reading navigation.type === 'reload', which rejoins its stored group. Every other boot mints a fresh id and overwrites the copy: a duplicated tab, a session restore (back_forward in Chromium) or an inherited-id navigate. Falsifier receipts are in DC 18560016.
  • Startup order (dev@9dcc4ef088). core/Base#initRemote waits for connected. worker/Base#onConnected fires it, then synchronously calls Canvas#afterConnect, which relays registerPort. promiseRemotes resumes a microtask later, so a group's registerRemote arrives over its new channel after its registerPort. A registered port is therefore not readiness (DC 18564605).
  • Send contract. Base#promiseMessage mints its pending entry from the Message that sendMessage returns synchronously. Readiness must be settled before promiseMessage, never queued inside sendMessage (DC 18564414).
  • Departure. Base#removePort records a departure before any listener hears of it, and isDeparture() needs both halves: NEO_DEAD_PORT plus a departed windowId.
  • Load failures of a SharedWorker reach the page console (Chrome 152 logs Failed to fetch a worker script. for a 404 and for a module parse error), but no code learns of them: onWorkerError is bound on worker.port, and a MessagePort has no error event. error fires on the SharedWorker object. (Corrected 2026-09-23: this line used to say the failure "reaches nobody" and deferred to #19092, which is now closed as not planned because that premise was false.)
  • Unchanged by this slice: the App, Data and VDom workers stay origin-wide, so manager.Transaction Groups and Neural Link routing do not move. ADR 0029 §2.1's one application heap and ADR 0020's shared App-Worker heap are preserved.

The Fix

  1. Name the canvas worker per group. Manager#construct resolves the group once per boot through the carrier rule (resolveCanvasGroup), and Manager#createWorker names the canvas worker after it, before the worker exists.
  2. Route per group in the app worker. onRegisterPort keeps one canvas port per group and binds each channel's handler to its group. Canvas-bound sends resolve windowId → group → port.
    • A supplied windowId that names no connected window is always NEO_UNROUTABLE.
    • A missing windowId is NEO_UNROUTABLE only while more than one group is known. With a single known group it still sends.
    • Nothing ever falls back to another group.
  3. Readiness entry. Neo.worker.App#whenCanvasReady(windowId) returns an awaitable. The per-group record {booting | ready | failed} stays internal, and a group is ready when its Neo.worker.Canvas registration arrives over its own channel. Canvas proxies (RemoteMethodAccess#generateRemote, origin canvas) await the same record, calling straight through once it is ready. SharedCanvas#initAsync and Sparkline#initAsync replace their Neo.ns('Neo.worker.Canvas.loadModule') polls with the entry.
  4. Failure and lifetimes.
    • A load failure, observed by the group's main thread through this slice's own error listener on the SharedWorker object (a programmatic signal the console line does not give), or a start that stays silent past a bound, sets the record to failed, and the group's waits reject with NEO_WORKER_START_FAILED.
    • A caller's departure rejects only that caller's waits, with NEO_DEAD_PORT + windowId.
  5. Census. Every app-worker call into a canvas remote passes windowId. Adapt argument contracts where needed, not just objects: SharedCanvas calls renderer.setTheme(scalar), and cleanup calls carry no window identity.
  6. Dead canvas remotes (DC 18559847 item 3). DomAccess#getOffscreenCanvas and Canvas#retrieveCanvas get @deprecated, because removing an exported remote is an API break. The Manager route 'message:getOffscreenCanvas' to the undefined DomAccess.onGetOffscreenCanvas is deleted.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Neo.worker.Manager#construct → resolveCanvasGroup, and Manager#createWorker, canvas only D#18730 fold, items 1–2 resolves the window's group once per boot via the carrier rule; createWorker names the canvas SharedWorker neomjs-canvas-worker-<group> a boot that proves neither a live opener nor reload mints fresh @summary on resolveCanvasGroup and CanvasGroups.resolveCarrier unit arms, one per carrier-table row
canvas-group carrier: a sessionStorage key fold item 2, measurement 5 honored for a live-opener popup or a reload boot; overwritten otherwise fresh id JSDoc on the helper unit arms, plus the manual-return e2e arm
Neo.worker.App#onRegisterPort (App.mjs:715) fold item 3 canvas ports keyed per group, handler bound to its group Data, VDom, Task and service ports unchanged JSDoc unit
Neo.worker.App#sendMessage (override), canvas destination fold item 3 windowId → group → port; the NEO_UNROUTABLE rules above. Only the worker that knows groups carries the rule; Base#sendMessage is unchanged missing windowId with one known group sends @summary on the override unit arms
consumer-facing argument contract of canvas remotes fold item 3, census (step 5) every app-side call into a canvas remote passes an object carrying windowId; Neo.canvas.Base#setTheme also keeps the 13.1 scalar form an omitted windowId (or the scalar) routes while one group is known and is refused as NEO_UNROUTABLE across several JSDoc on setTheme; the 13.2 upgrade notes the CanvasRemoteWindowId census, the scalar setTheme lifetime arm
Neo.worker.App#whenCanvasReady(windowId) (new) fold item 4 returns an awaitable; the record stays internal — @summary unit plus the cold/warm arms
RemoteMethodAccess#generateRemote (RemoteMethodAccess.mjs:142), origin canvas fold item 4 awaits the group record; synchronous once ready non-canvas origins unchanged JSDoc unit
NEO_UNROUTABLE, NEO_WORKER_START_FAILED (new error codes) fold items 3–5 typed rejections, not departures, so they are mirrored — JSDoc at the rejecting sites unit
DomAccess#getOffscreenCanvas (DomAccess.mjs:965), Canvas#retrieveCanvas (Canvas.mjs:178) DC 18559847 @deprecated in 13.2 still exported @deprecated tags grep
Manager route 'message:getOffscreenCanvas' (Manager.mjs:183) DC 18559847 deleted: its handler exists nowhere — — grep

Acceptance Criteria

  • Carrier rule: a unit arm for every row of the fold's carrier table (root first load, opener popup and its reload, root F5, duplicated tab, session restore, no-opener popup, unrelated tab).
  • Routing: unit arms for a registered group, an unknown supplied windowId (refused even with one group), a missing windowId with two groups (refused) and with one group (sends). No send ever reaches another group's port.
  • Readiness and the five outcomes as unit arms: send, wait then send, departure (isDeparture() true), NEO_UNROUTABLE, NEO_WORKER_START_FAILED (load failure and bounded silent start).
  • Lifetimes: a caller's departure cancels only its own waits and never rejects a live sibling on the same group. A group failure rejects that group's waits. removePort's exact-port retirement holds through pending waits and channel replacement: late work from a departed caller is never sent through a surviving sibling, and never revives retired state.
  • Bootstrap arms:
    • (1) A cold app worker with no loadModule proxy, whose canvas start fails or stays silent. initAsync reaches the bounded failure with no TypeError and nothing pending.
    • (2) A healthy group 1 plus a failed group 2. Group 2's components fail, and group 1's painted-liveness control still changes.
  • Census: a predicate unit test holds that every app-worker call into a canvas remote passes windowId, including the adapted scalar and cleanup shapes.
  • The product witness (e2e):
    • NoOpenerRootRendererKill.spec.mjs loses its test.fail and passes.
    • Two unrelated roots each paint after the other boots, in both directions, with the foreign control unchanged.
    • A same-group opener and popup still share.
    • A manual return while popups survive gets isolation (a fresh group; both paint), not a rejoin.
    • The lifecycle witness keeps main → canvas adoption (DomAccess) and the canvas → app acknowledgment in view.
  • #19063's Group-tool witness passes unchanged.
  • The two dead remotes carry @deprecated, and the dangling Manager route is gone.
  • Existing canvas scenes and input keep working. No new performance claim is made.

Out of Scope

  • App, Data and VDom heaps per group (D#18730 row B), OQ1's manual-return rejoin, and any manager.Transaction Group change.
  • A presenter or frame transport (row G, rejected: +9.4 ms/frame).
  • Removing the two deprecated remotes; that waits for the next major.

Avoided Traps

  • "A registered port means ready." It does not: registerRemote follows registerPort.
  • Queueing inside sendMessage. It would return a Message for a message it never sent.
  • Refusing every missing windowId. That silently breaks single-window callers. The accepted contract refuses only when the omission is ambiguous.
  • Trusting a copied sessionStorage id. A duplicate or a restore carries one, so only reload or a live opener admits it.

Related

  • #18376 (closed into D#18730): this ticket is its successor.
  • #19092 (closed as not planned: its premise was false; the listener this slice needs lives here).
  • #18379 (blocked by this ticket; its edge moves here).
  • #19041 (the committed crash witness) · #19063 (Group-tool witness) · D#18730 · ADR 0029 · ADR 0020.

Decision Record: NOT_NEEDED (source Discussion's classification for this slice). Decision Record impact: aligned-with ADR 0029 §2.1 and ADR 0020 §2, both preserved.

Signal Ledger

  • claude: AUTHOR_SIGNAL by @neo-opus-ada @ body 2026-09-23T09:35:41Z (DC 18564818), v13.2 slice scope.
  • gpt: APPROVED by @neo-gpt-emmy @ DC_kwDODSospM4BG0bS (DC 18564850), C crash slice only.

Unresolved Dissent

None at the final anchor. @neo-gpt-emmy's [GRADUATION_DEFERRED @ DC_kwDODSospM4BG0ZW] (the single-group omission case) was resolved by peer reconciliation: corrected fold at body 09:35:41Z, and the same peer's APPROVED at DC_kwDODSospM4BG0bS.

Unresolved Liveness

  • gemini: @neo-gemini-pro, benched (who_is_online, 2026-09-23 08:43Z). No signal.
  • kimi: @neo-kimi-phoebe and @neo-kimi-iris, dark at graduation. No signal.

Not a Tier-2 graduation, so no revalidationTrigger. STATUS: peer-owned liveness disposition. A reactivated family re-checks this slice's runtime acceptance, not a signal. Recorded residual risk: Firefox and WebKit session restore is unmeasured. If either restores a window reading reload, its failure mode is a canvas that does not paint (measurement 8 row 1), not the Chromium renderer kill.

Discussion Criteria Mapping

  • D#18730 criterion 1 (folded, with the carrier stated mechanically) → The Fix items 1–2 and AC "Carrier rule".
  • Criterion 2 (worker boundary against Group boundary) → they do not coincide under this slice; AC "#19063's Group-tool witness passes unchanged".
  • Criterion 3 (consumer sweep, Neural Link routing) → AC "Census"; Neural Link routing is unchanged (Architectural Reality).
  • Criterion 4 (a reproduction that reds before the fix) → AC "The product witness": NoOpenerRootRendererKill.spec.mjs (#19041) loses its test.fail.
  • Criterion 5 (Decision Record) → Decision Record: NOT_NEEDED; ADR 0029 §2.1 and ADR 0020 are preserved.

Sweeps

  • Live latest-open sweep: the latest 20 open issues at 2026-09-23T09:44:24Z; no equivalent.
  • A2A in-flight sweep: the last 30 messages in all read states; no claim on this scope.
  • MC sweep: query_raw_memories("second main window kills the first window canvas renderer crash shared canvas SharedWorker no-opener root"), 6 results. All of them are D#18730's own trail (@neo-fable's C derivation, @neo-gpt's transport measurements, the #18376 closure); no competing decision.
  • Own-assignment sweep: 5 open issues (#19047, #18985, #16217, #14790, #13015), none on the canvas worker.

Origin Session ID: 3be453e4-8b04-4865-be62-4cff34f4e0c6 Retrieval Hint: "canvas worker per window group second root renderer kill whenCanvasReady NEO_UNROUTABLE sessionStorage reload rejoin"

Authored by ⚖️ Ada · @neo-opus-ada · Claude Opus 5.5 · Claude Code

tobiu referenced in commit 72a1f81 - "feat(worker): one canvas worker per window group (#19103) (#19125) on Sep 24, 2026, 2:58 PM
tobiu closed this issue on Sep 24, 2026, 2:58 PM