A second full Workstation root opened without an opener kills the first root''s renderer: its sparklines draw through a lazily started canvas worker that is a SharedWorker
19040 The no-opener two-root renderer kill has no committed witness
19046 Three canvas remotes have no callers left, and removal is an API break
subIssuesCompleted
2
subIssuesTotal
2
contentTrust
projected
quarantined
0
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
closedAt
Sep 23, 2026, 1:50 AM
A second full Workstation root opened without an opener kills the first root's renderer: its sparklines draw through a lazily started canvas worker that is a SharedWorker
Current state, 2026-09-22. This body is the record; the thread below it is history. Add a finding here, not as a comment.
BLOCKED — nothing implements against AC-2 or AC-6 until D#18730 folds. @tobiu, 2026-09-21: do not touch this ticket; the Discussion is the replacement — shared worker groups, one multi-window app open in several browser windows in parallel. The ticket-scoped NEO_INLINE_CODE_3 for the G presentation shape was never a Discussion fold, and the "UNBLOCKED" banner that stood here from 20:26Z to this edit read it as one — that reading was wrong.
AC-2 and AC-6 below are the G presenter contract, applied on 2026-09-21 from @neo-opus-ada's §11 proposal and withdrawn by its author the same night: the presenter transport gives up transferControlToOffscreen's zero-copy presentation — measured at +9.4 ms per frame on a DPR2 viewport in Chromium, 56 % of the frame budget — and worker groups keep it. They stay in the body as the measured record of the rejected shape, not as a contract. AC-1's reproduction (#19040 / PR #19041) is unaffected: it reds under every candidate shape.
The defect
A second full Workstation root on the same origin, opened as an unrelated tab with no opener, kills the first root's renderer. It happens about 0.4 s into the second root's boot, right after its dock projects. Chromium's browser log names the kill:
Terminating render process for bad Mojo message: Received bad user message: Invalid client ID
Terminating renderer for bad IPC message, reason 123
The second root finishes booting. Two independent full Workstation roots on one SharedWorker therefore cannot coexist, which also blocks #18379, the rendered receipt #18303's criterion 10 needs.
Measured
Reproduction (e2e harness, real Chromium, dev@a1a2a781cb, 2026-09-12):
arm
how root B is opened
root A after ~6 s
control
no root B
alive, 31 canvases, canvas worker present
opener
window.open(WS) from root A
alive, 31 canvases, canvas worker present
no opener
context.newPage() → goto(WS)
Page crashed within 4.7 s
The variable is whether root B has an opener. A window.open popup joins its opener's renderer process, while an unrelated same-origin tab gets its own. This three-arm probe is not committed yet (AC-1).
The trigger is root B's OffscreenCanvases (2026-09-05). When transferControlToOffscreen is made to throw in root B only, both roots live and A still answers a click. Made to throw in root A only, A dies as before.
The canvases run in a lazily started canvas worker that is a SharedWorker.apps/workstation/neo-config.json declares useCanvasWorker: false, yet the live boot reads true, with a canvas SharedWorker holding all 31 canvases. A property-setter trap caught the single writer:
component/Sparkline.mjs#initAsync, like app/SharedCanvas.mjs, calls worker.Manager#startWorker({name: 'canvas'}).
startWorker sets useCanvasWorker = true and calls createWorker.
createWorker applies isShared = sharedWorkersEnabled && useSharedWorkers to every worker, including this one.
So the sparklines are driven by a shared canvas worker, not by the App Worker this ticket's original title named.
Affected apps are those with useSharedWorkers: true that draw canvases: apps/portal, which declares useCanvasWorker: true, and apps/workstation, where the worker starts lazily. The chain and this census were re-checked on dev@03219a75b7.
Stated, not measured: the Chromium-internal reason. An OffscreenCanvas frame sink must be created by the renderer process that owns its placeholder canvas. A canvas owned by B's process and driven from the shared worker in A's process fails that check, and Chromium terminates A's process. The same kill is on record for Electron (electron/electron#47705). The reproduction is consistent with this, but doesn't prove it.
Ruled out
--disable-features=IsolateOrigins,site-per-process as the variable.chrome://version shows the flag reaching Chromium in last position (Playwright's own --disable-features comes first), and the no-opener kill reproduces with the flag active.
The app's neo-config.json as the effective useCanvasWorker.MicroLoader loads the file correctly; startWorker rewrites the flag at runtime.
"22 of 23 shared-worker configs are affected". That count included configs without a canvas worker, but a lazily started worker ignores the declaration. The affected set is the two apps above.
The engine matrix, measured 2026-09-15
@tobiu's AC-2 direction: do not split the canvas worker on this evidence alone. One canvas realm that several windows share is a capability we want — animation logic in loops, canvases in different windows that can talk because they live in the same realm. A dedicated worker per window trades that away, so the boundary has to be known before it is drawn.
So the question was taken to the engines, without Neo. Three arms — root alone, a popup the root opens, an unrelated same-origin tab with no opener — against a 60-line page that transfers its canvas to a SharedWorker which paints every adopted canvas on a 16 ms loop. Source at the bottom of this section; the 2026-09-05 probe was lost by living outside a durable artifact.
Control in every cell: a plain port.postMessage({type: 'ping'}) answered pong in all three engines, in every arm. The connection and port messaging always work, so a canvas that never arrives is the transfer failing, not the worker.
engine
root alone
+ popup (opener, same process)
+ unrelated tab (no opener)
Chromium 153.0.8010.12
adopted, painting
both adopted, 2 canvases in one realm, both painting, root alive
tab adopted as the 3rd canvas, then the root's renderer crashes
Firefox 155.0
adopted, painting
both adopted, 2 canvases in one realm, root alive
the tab's canvas is never adopted — silently, no throw — the root paints on
WebKit 26.6
never adopted, 0 frames
never adopted
never adopted; nothing crashes
Three readings, and each one moves the decision:
The capability is real inside one renderer process, in two engines. Chromium and Firefox both let a popup and its opener share one canvas realm. A per-window canvas worker would take that away from both — which is why the split is not the starting point.
No engine supports the realm across processes. Chromium is the only one that adopts a second process's canvas, and that adoption is what kills the first renderer. Firefox drops it silently; WebKit adopts nothing at all. So the multi-process shared canvas realm is not a Neo bug to fix but a platform boundary to detect.
WebKit has no such capability today. Whatever shape we pick must already answer "no canvas worker here", because Safari is in that column permanently until WebKit ships the transfer.
Where the kill sits — the transfer, the context, or the paint? Same rig, Chromium only, root A always painting, the unrelated tab B running one mode per launch:
what the worker does with B's canvas
root A
holds it, never calls getContext
alive, 222 frames
calls getContext('2d'), never paints
alive, 222 frames
paints one frame into it
dead
The boundary is the first paint, not the transfer and not the context. The frame sink is created on first commit, and that is what fails the client-id check and terminates the worker's host process — root A, not the tab that owns the foreign canvas. So a shared canvas worker may hold canvases from any window; what it may not do is paint into one its host process does not own. That is the fix surface: the realm keeps the state, the logic and the loop — the pixel commit is what has to be per-process. It also rules out a "paint one frame and see" handshake as the classifier: the measurement is the kill.
Can a window tell which side it is on (AC-5)? The one non-destructive signal available at boot is opener lineage, so it was measured as a fourth arm: a popup opened with noopener, which reports window.opener === null while still being a popup.
Chromium 153 arm
canvas adopted
root A
popup with an opener
yes, 2 in the realm, both painting
alive
popup with noopener
yes, 3rd in the realm
dead
unrelated tab, no opener
yes, 3rd in the realm
dead
In Chromium, opener lineage tracks the boundary exactly across these arms — and Neo already reads it: Main#resolveNativeWindowRoute reaches win.opener.Neo.Main for the native-window route, so the mechanism is in-tree, not new. The honest bound: this is a measured correlate over three arms, not a platform guarantee. Chromium may split an opener pair (COOP/COEP, a cross-site navigation, process limits), and being wrong costs a terminated renderer — so the gate should be opener lineage plus the check that the opener is the window whose client the realm already paints for, and the realm should treat anything it cannot establish as foreign.
The same arm says the other two engines need no such gate. Firefox adopted the noopener popup's canvas and kept painting both, while refusing the unrelated tab's — it enforces the boundary itself, silently. WebKit adopts nothing in any arm. So the gate is Chromium-shaped; elsewhere a window whose canvas is never adopted simply paints locally, which the shape has to support regardless.
Upstream: Mozilla's own test asserts the refusal — dom/canvas/test/test_offscreencanvas_sharedworker.html expects postMessage to throw "OffscreenCanvas cannot transfer to shared worker". Firefox 155 no longer throws for the same-process case measured above, so that test's premise is narrower than its comment. The Chromium kill has one public report, electron/electron#47705, same two log lines (Received bad user message: Invalid client ID, Terminating renderer for bad IPC message, reason 123), for a transferable crossing an OOP-iframe boundary; it was closed as not planned with no upstream number attached. No Chromium tracker entry for the SharedWorker case was found.
run.mjs drives chromium, firefox and webkit in turn: one context, root A → popup via window.open from A → unrelated context.newPage(), reading window.__probe and 1 + 1 from A after each, with a page.on('crash') listener.
"dock layouts spawn popups, and they must use the same workers group. however, if we open the same app inside multiple browser tabs (or the portal app), we want isolation."
A SharedWorker instance is keyed by origin, script URL and name. Neo passes a name already — worker.Manager#createWorker builds new cls(filePath, {name, type: 'module'}) with name = 'neomjs-app-worker' — but it is a constant, so every tab of an origin lands in one worker set. Making that name carry a window-group id is therefore not a new API, a new signature, or a capability question: it is one string.
Two corrections to the sketch, both measured rather than read:
There is no three-argument constructor. The spec's second parameter is (DOMString or WorkerOptions): a bare string sets name and leaves no room for type: 'module'. The object form carries both, and it is what Neo already uses.
The object form works everywhere we care about, including Safari's engine: new SharedWorker(url, {name, type: 'module'}) constructed and loaded a module worker in Chromium 153, Firefox 155 and WebKit 26.6 in the runs below (ctor: object-form ok in every cell).
Grouping by name, three arms per engine — root g1, a popup it opens in g1, an unrelated tab in g2. The worker reports a realm id minted once per worker instance, so equal ids mean one instance:
engine
root (g1)
popup (g1, opened by the root)
unrelated tab (g2)
root afterwards
Chromium 153
realm fn961z, 1 canvas
same realm fn961z, 2 clients, 2 canvases
own realm 9zhum8, its own canvas
alive, 304 frames, no crash
Firefox 155
realm qial5g
same realm, 2 clients, 2 canvases
own realm k93202
alive, 272 frames
WebKit 26.6
realm 9q5t4y
same realm, 2 clients
own realm imaca0
alive; canvases still never adopt
The kill disappears as a side effect, exactly as predicted. The second tab's canvas now belongs to a worker hosted in the second tab's own process, so no realm ever paints across a process boundary, and the arm that reliably killed root A leaves it painting. Grouping also gives Firefox back the second window's canvas — it adopted g2's canvas into g2's realm, where before it silently dropped it. WebKit is unchanged: the grouping works, the transfer still does not, so a local-paint path is still required there.
The carrier is already the right one, and it is already in the tree. A group id must reach a popup, survive that popup's reload, and be denied to an unrelated tab. sessionStorage does exactly that, identically in all three engines:
window
inherits the id
mints its own
same process (measured above)
root
—
yes
—
popup with an opener
yes, at boot, before its own scripts run
—
yes
the same popup reloaded
yes
—
yes
popup with noopener
no
yes
no
unrelated tab
no
yes
no
The session-storage copy boundary and the renderer-process boundary coincide in every arm above.
They do not coincide everywhere, and @tobiu challenged exactly this. Opening the portal in two tabs the ordinary way gives each its own session storage, so those two are isolated — that case holds. But two browser actions copy a session into a context with no live relationship to the original: duplicating a tab, and restoring a session. Both hand a foreign process the root's group id. Measured, Chromium, an unrelated tab that merely presentsg1:
arm
realms
root A
foreign tab presenting the root's id
joined the root's realm 67tjcq, 2 clients
dead, crash recorded
foreign tab with its own id (control)
own realm ncr47t
alive
So a group id answers "same tab lineage", never "same process". A storage-only carrier is therefore necessary but not sufficient.
The signals that separate a duplicate from a real popup are available at boot, identically in all three engines:
window
navigation.type
live window.opener
stored id
root, first load
navigate
no
none → mints
popup
navigate
yes
inherited
the same popup reloaded
reload
yes
inherited
noopener popup
navigate
no
none → mints
unrelated tab
navigate
no
none → mints
duplicated tab (not testable here)
navigate
no
inherited ← the one combination nothing else produces
Hence the rule: an inherited id is trusted only when a live opener still carries it, or when this navigation is a reload of the same context. A duplicate presents navigate + inherited id + no opener, so it re-mints and gets its own worker set; a reloading root keeps its id, so its live popups stay grouped.
Session restore is the honest residual: it cannot be exercised from this harness, and a restored tab may well report reload. That is the reason the paint gate below is not redundant with grouping — grouping makes the crash structurally unreachable for the cases we can classify, and the gate is what catches a client whose id crossed a process boundary anyway.
Neo already carries a group id this way — Main#setTopologyIdentity writes {groupId, workspaceKey, generationToken} into sessionStorage['neo-topology-identity'], propagates it into windows it opens (Main.mjs, the openedWindow.sessionStorage.setItem line), clears it per group on close, and worker.Manager#readTopologyIdentity reads it back. It cannot be reused as-is for the worker name, because the App Worker mints it after the workers exist, while the name is needed before the first worker is constructed. The worker-group id has to be minted in the main thread at boot; the topology identity can then adopt it instead of minting a second one.
What this shape decides, beyond the crash:
Dock popups keep one worker set, which is the behaviour the dock depends on today.
Two tabs of one app stop sharing App, Data, VDom and Canvas workers. That is the isolation asked for, and it is a behaviour change for anyone relying on the opposite; no learn/ guide advertises cross-tab sharing, and Neo's multi-window demos all open popups.
Cost: N tabs mean N worker sets. A config — 'window-tree' versus today's 'origin' — keeps the old behaviour available and names the choice instead of hiding it.
The Neural Link connects to an app worker per origin today; with groups there can be several, so its routing needs the group id.
learn/guides/fundamentals/WorkerArchitecture.md and learn/guides/advanced/CanvasArchitecture.md describe the current one-worker-set model and would need the group concept.
Decided: the fix shape
D#18730 decided it, not this ticket. Of the five shapes weighed here on 2026-09-15, per-window workers and a documented constraint are retired; paint-by-process and the WebKit refusal are subsumed by G + A, where the worker never paints into a DOM canvas anywhere and every window presents locally, so nothing needs classifying and no engine needs an exception; one worker group per window tree is D#18730's Option B, still open there and not this ticket's gate. The engine matrix and the worker-group measurements above are carried there as fixed input.
Acceptance Criteria
AC-1 — A reproduction lives in the repository: two roots of a canvas-drawing SharedWorker app in one browser context, reporting root A alive after root B has booted through the no-opener launch path, with a no-second-root control and an opener control that both stay green. Measured 2026-09-21 at a29a2e3935 against apps/portal (three arms, 21.4 s: both controls alive, the no-opener arm Page crashed), so it needs neither Workstation nor the Neural Link fixture and can be CI-gated; lands as its own leaf with Refs #18376.
⛔ AC-2 and AC-6 are the withdrawn G presenter shape — see the banner. Kept as the measured record; do not implement from them. D#18730 supplies the replacement contract when it folds.
AC-2 — the presenter contract. Under G + A the worker plane keeps the scene, the contexts and the render loop, and never owns a DOM-backed canvas; each window presents published frames into its own canvas locally. Each item is checkable:
No inward transfer of a DOM canvas stays reachable.component.Canvas#afterSetMounted -> DomAccess#transferCanvasToWorker is the only live one. DomAccess#getOffscreenCanvas and worker.Canvas#retrieveCanvas are the uncalled pre-#9479 path and go with it, along with worker/Manager.mjs:183's route to the undefined onGetOffscreenCanvas. Leaving any of them reachable preserves the original failure behind a new mechanism.
Coordinate-space preservation, in logical CSS pixels. The presenter inverse-maps a pointer coordinate ONCE into the producer's logical scene space before it reaches renderer.updateMouseState / renderer.onMouseMove. devicePixelRatio is backing-store resolution, not an input multiplier, so a DPR-only change mis-aims nothing and must not be applied to a coordinate; only presentation scaling or letterboxing is inverted. Pinned by a known displayed point reaching the existing RPC at its intended logical coordinate after EACH of: attach, logical resize, DPR change, rehome — and with the presenter at a nonzero document offset. Without it every forwarded coordinate addresses the wrong point in the shared scene, and it fails silently: a hover highlighting the wrong bar, not an error.
Generation binding and stale-frame rejection. A published frame names its destination and its generation. A frame arriving after a rehome, a resize or a detach does not paint its successor.
Attach / resize / rehome / detach, with bounded outstanding frames. The hooks exist: afterSetMounted attaches and detaches, onDomResize fires resize, afterSetWindowId is the rehome, destroy is the last detach. Logical resize stays renderer-layout input, not merely a resized bitmap. Detach releases the presenter's resources and its registration for THAT WINDOW ONLY; worker scene state survives.
Named consumers green:component.Sparkline, app.SharedCanvas, app.header.Canvas, and Workstation's 31 sparklines.
Reuse, not a second plane: the existing renderer and input RPCs, and the existing canvas identity and resize ownership. The frame transport is a transferable RGBA ArrayBuffer presented as ImageData — the only arm measured working for foreign and reopened clients in all three engines (D#18730 transport matrix). Delivering an ImageBitmap across the realm boundary is Chromium-only: Firefox and WebKit answered messageerror with zero frames for foreign and reopened clients, which is exactly the client this ticket is about; transferToImageBitmap() additionally clears its producing bitmap, so incremental rendering needs an explicit disposition. A bitmap fast path may be added where it is cost-justified, but no client may depend on it as its only path. Constructing a bitmap locally inside the presenting document (createImageBitmap(imageData) + drawImage, the scaling escape putImageData lacks) is a different operation from delivering one across the boundary — stated, not measured; it needs its own arm before anything relies on it. No second scene graph, no worker-group controller.
AC-3 — Under G, two full Workstation roots boot and stay alive 5 of 5 with the AC-1 reproduction green, and the sparklines render in BOTH roots. That claim holds in all three engines including WebKit, where no canvas is adopted in any arm today and local paint is the only path that works (D#18730 OQ6).
AC-4 — WorkstationTopologyGroupsNL.spec.mjs's docblock names the mechanism instead of "crash the shared worker", its wording on dev@03219a75b7 and still its wording at a29a2e3935.
AC-5 — A window can be classified as sharing the canvas worker's renderer process or not, by a named mechanism, before any shape that depends on it is written.Withdrawn: premise dissolved, not satisfied. The AC was conditional on a shape that depends on a classifier; G gates nothing, so nothing foreign is ever painted and nothing needs classifying. D#18730's OQ7 gives the general form — a sound classifier can only ever answer "safe", never "dangerous", so every classifier-gated shape inherits a correlate where it needs a guarantee, and the graduation explicitly does not adopt A's classifier. The opener-lineage measurement stays in this body as evidence.
AC-6 — cost and transport, as binding implementation evidence. The criterion is end-to-end cost of the RGBA transport against a named app baseline, on representative DISTINCT scenes — component.Sparkline owns a renderer item per canvas with its own values, so Workstation's 31 canvases are 31 distinct scenes, not one scene fanned out 31 ways. D#18730's measurement 9 (flat 2.0 ms at N = 1, 8 and 31) is not the baseline: it priced one-scene fanout on the bitmap arm, and the shipping transport allocates and reads back every submitted frame (getImageData + a transferable buffer). Today's sparse updates and adaptive throttling are preserved rather than replaced by a publish-every-tick loop, and the production transport is demonstrated in all three engines.
Rebuilding the probe
The 2026-09-05 probe was written outside the tree and is gone; the arms above are enough to rebuild it.
Serve both roots from the tree you edit. A project-scoped built-in server, such as the JetBrains one on :63342, routes by project name and can serve another checkout. A stack line that doesn't match your file is the tell.
Open root B with context.newPage() for the red arm. window.open from root A is the control that must stay alive.
Out of Scope
The rendered two-full-root drag receipt for #18303 criterion 10: #18379, blocked on this.
The "Duplicate remote method definition" sighting of 2026-09-05 from a stale checkout: a different mechanism.
Related
#18303, the epic whose criterion 10 needs two live full roots
The defect
A second full Workstation root on the same origin, opened as an unrelated tab with no opener, kills the first root's renderer. It happens about 0.4 s into the second root's boot, right after its dock projects. Chromium's browser log names the kill:
The second root finishes booting. Two independent full Workstation roots on one SharedWorker therefore cannot coexist, which also blocks #18379, the rendered receipt #18303's criterion 10 needs.
Measured
Reproduction (e2e harness, real Chromium,
dev@a1a2a781cb, 2026-09-12):window.open(WS)from root Acontext.newPage()→goto(WS)Page crashedwithin 4.7 sThe variable is whether root B has an opener. A
window.openpopup joins its opener's renderer process, while an unrelated same-origin tab gets its own. This three-arm probe is not committed yet (AC-1).The trigger is root B's OffscreenCanvases (2026-09-05). When
transferControlToOffscreenis made to throw in root B only, both roots live and A still answers a click. Made to throw in root A only, A dies as before.The canvases run in a lazily started canvas worker that is a SharedWorker.
apps/workstation/neo-config.jsondeclaresuseCanvasWorker: false, yet the live boot readstrue, with a canvasSharedWorkerholding all 31 canvases. A property-setter trap caught the single writer:component/Sparkline.mjs#initAsync, likeapp/SharedCanvas.mjs, callsworker.Manager#startWorker({name: 'canvas'}).startWorkersetsuseCanvasWorker = trueand callscreateWorker.createWorkerappliesisShared = sharedWorkersEnabled && useSharedWorkersto every worker, including this one.So the sparklines are driven by a shared canvas worker, not by the App Worker this ticket's original title named.
Affected apps are those with
useSharedWorkers: truethat draw canvases:apps/portal, which declaresuseCanvasWorker: true, andapps/workstation, where the worker starts lazily. The chain and this census were re-checked ondev@03219a75b7.Stated, not measured: the Chromium-internal reason. An OffscreenCanvas frame sink must be created by the renderer process that owns its placeholder canvas. A canvas owned by B's process and driven from the shared worker in A's process fails that check, and Chromium terminates A's process. The same kill is on record for Electron (electron/electron#47705). The reproduction is consistent with this, but doesn't prove it.
Ruled out
--disable-features=IsolateOrigins,site-per-processas the variable.chrome://versionshows the flag reaching Chromium in last position (Playwright's own--disable-featurescomes first), and the no-opener kill reproduces with the flag active.neo-config.jsonas the effectiveuseCanvasWorker.MicroLoaderloads the file correctly;startWorkerrewrites the flag at runtime.The engine matrix, measured 2026-09-15
@tobiu's AC-2 direction: do not split the canvas worker on this evidence alone. One canvas realm that several windows share is a capability we want — animation logic in loops, canvases in different windows that can talk because they live in the same realm. A dedicated worker per window trades that away, so the boundary has to be known before it is drawn.
So the question was taken to the engines, without Neo. Three arms — root alone, a popup the root opens, an unrelated same-origin tab with no opener — against a 60-line page that transfers its canvas to a
SharedWorkerwhich paints every adopted canvas on a 16 ms loop. Source at the bottom of this section; the 2026-09-05 probe was lost by living outside a durable artifact.Control in every cell: a plain
port.postMessage({type: 'ping'})answeredpongin all three engines, in every arm. The connection and port messaging always work, so a canvas that never arrives is the transfer failing, not the worker.Three readings, and each one moves the decision:
Where the kill sits — the transfer, the context, or the paint? Same rig, Chromium only, root A always painting, the unrelated tab B running one mode per launch:
getContextgetContext('2d'), never paintsThe boundary is the first paint, not the transfer and not the context. The frame sink is created on first commit, and that is what fails the client-id check and terminates the worker's host process — root A, not the tab that owns the foreign canvas. So a shared canvas worker may hold canvases from any window; what it may not do is paint into one its host process does not own. That is the fix surface: the realm keeps the state, the logic and the loop — the pixel commit is what has to be per-process. It also rules out a "paint one frame and see" handshake as the classifier: the measurement is the kill.
Can a window tell which side it is on (AC-5)? The one non-destructive signal available at boot is opener lineage, so it was measured as a fourth arm: a popup opened with
noopener, which reportswindow.opener === nullwhile still being a popup.noopenerIn Chromium, opener lineage tracks the boundary exactly across these arms — and Neo already reads it:
Main#resolveNativeWindowRoutereacheswin.opener.Neo.Mainfor the native-window route, so the mechanism is in-tree, not new. The honest bound: this is a measured correlate over three arms, not a platform guarantee. Chromium may split an opener pair (COOP/COEP, a cross-site navigation, process limits), and being wrong costs a terminated renderer — so the gate should be opener lineage plus the check that the opener is the window whose client the realm already paints for, and the realm should treat anything it cannot establish as foreign.The same arm says the other two engines need no such gate. Firefox adopted the
noopenerpopup's canvas and kept painting both, while refusing the unrelated tab's — it enforces the boundary itself, silently. WebKit adopts nothing in any arm. So the gate is Chromium-shaped; elsewhere a window whose canvas is never adopted simply paints locally, which the shape has to support regardless.Upstream: Mozilla's own test asserts the refusal —
dom/canvas/test/test_offscreencanvas_sharedworker.htmlexpectspostMessageto throw "OffscreenCanvas cannot transfer to shared worker". Firefox 155 no longer throws for the same-process case measured above, so that test's premise is narrower than its comment. The Chromium kill has one public report, electron/electron#47705, same two log lines (Received bad user message: Invalid client ID,Terminating renderer for bad IPC message, reason 123), for a transferable crossing an OOP-iframe boundary; it was closed as not planned with no upstream number attached. No Chromium tracker entry for the SharedWorker case was found.Probe source —
index.html,shared-worker.js,run.mjs(serve the directory,node run.mjs)<!-- index.html --> <canvas id="c" width="120" height="60"></canvas> <script> window.__probe = {ping: 'pending', transfer: 'pending', worker: 'pending', frames: 0, detail: null}; const canvas = document.getElementById('c'), offscreen = canvas.transferControlToOffscreen(), worker = new SharedWorker('./shared-worker.js', {name: 'canvas-probe'}); worker.port.onmessage = ({data}) => { if (data.type === 'frame') {window.__probe.frames = data.count} else if (data.type === 'pong') {window.__probe.ping = 'pong'} else {window.__probe.worker = data.type; window.__probe.detail = data.detail || null} }; worker.port.start(); worker.port.postMessage({type: 'ping'}); // the control: no transfer try { worker.port.postMessage({type: 'canvas', canvas: offscreen, label: location.search}, [offscreen]); window.__probe.transfer = 'posted' } catch (e) { window.__probe.transfer = 'throw: ' + e.name + ': ' + e.message } </script>// shared-worker.js const entries = []; let frames = 0, painting = false; function paint() { frames++; entries.forEach(({canvas, ctx}, i) => { ctx.fillStyle = frames % 2 ? '#123456' : '#654321'; ctx.fillRect(0, 0, canvas.width, canvas.height) }); entries.forEach(({port}) => port.postMessage({type: 'frame', count: frames})); setTimeout(paint, 16) } self.onconnect = event => { const port = event.ports[0]; port.onmessage = ({data}) => { if (data.type === 'ping') { port.postMessage({type: 'pong'}) } else if (data.type === 'canvas') { try { const canvas = data.canvas, ctx = canvas.getContext('2d'); entries.push({canvas, ctx, port}); port.postMessage({type: 'adopted', detail: `${entries.length} canvas(es) in this realm, ${canvas.width}x${canvas.height}`}); if (!painting) {painting = true; paint()} } catch (e) { port.postMessage({type: 'adopt-error', detail: e.name + ': ' + e.message}) } } }; port.start() };run.mjsdriveschromium,firefoxandwebkitin turn: one context, root A → popup viawindow.openfrom A → unrelatedcontext.newPage(), readingwindow.__probeand1 + 1from A after each, with apage.on('crash')listener.Worker groups: @tobiu's direction, measured 2026-09-15
A
SharedWorkerinstance is keyed by origin, script URL and name. Neo passes a name already —worker.Manager#createWorkerbuildsnew cls(filePath, {name, type: 'module'})withname = 'neomjs-app-worker'— but it is a constant, so every tab of an origin lands in one worker set. Making that name carry a window-group id is therefore not a new API, a new signature, or a capability question: it is one string.Two corrections to the sketch, both measured rather than read:
(DOMString or WorkerOptions): a bare string setsnameand leaves no room fortype: 'module'. The object form carries both, and it is what Neo already uses.new SharedWorker(url, {name, type: 'module'})constructed and loaded a module worker in Chromium 153, Firefox 155 and WebKit 26.6 in the runs below (ctor: object-form okin every cell).Grouping by name, three arms per engine — root
g1, a popup it opens ing1, an unrelated tab ing2. The worker reports a realm id minted once per worker instance, so equal ids mean one instance:fn961z, 1 canvasfn961z, 2 clients, 2 canvases9zhum8, its own canvasqial5gk932029q5t4yimaca0The kill disappears as a side effect, exactly as predicted. The second tab's canvas now belongs to a worker hosted in the second tab's own process, so no realm ever paints across a process boundary, and the arm that reliably killed root A leaves it painting. Grouping also gives Firefox back the second window's canvas — it adopted
g2's canvas intog2's realm, where before it silently dropped it. WebKit is unchanged: the grouping works, the transfer still does not, so a local-paint path is still required there.The carrier is already the right one, and it is already in the tree. A group id must reach a popup, survive that popup's reload, and be denied to an unrelated tab.
sessionStoragedoes exactly that, identically in all three engines:noopenerThe session-storage copy boundary and the renderer-process boundary coincide in every arm above.
They do not coincide everywhere, and @tobiu challenged exactly this. Opening the portal in two tabs the ordinary way gives each its own session storage, so those two are isolated — that case holds. But two browser actions copy a session into a context with no live relationship to the original: duplicating a tab, and restoring a session. Both hand a foreign process the root's group id. Measured, Chromium, an unrelated tab that merely presents
g1:67tjcq, 2 clientsncr47tSo a group id answers "same tab lineage", never "same process". A storage-only carrier is therefore necessary but not sufficient.
The signals that separate a duplicate from a real popup are available at boot, identically in all three engines:
navigation.typewindow.openernavigatenavigatereloadnoopenerpopupnavigatenavigatenavigateHence the rule: an inherited id is trusted only when a live opener still carries it, or when this navigation is a reload of the same context. A duplicate presents
navigate+ inherited id + no opener, so it re-mints and gets its own worker set; a reloading root keeps its id, so its live popups stay grouped.Session restore is the honest residual: it cannot be exercised from this harness, and a restored tab may well report
reload. That is the reason the paint gate below is not redundant with grouping — grouping makes the crash structurally unreachable for the cases we can classify, and the gate is what catches a client whose id crossed a process boundary anyway.Neo already carries a group id this way —
Main#setTopologyIdentitywrites{groupId, workspaceKey, generationToken}intosessionStorage['neo-topology-identity'], propagates it into windows it opens (Main.mjs, theopenedWindow.sessionStorage.setItemline), clears it per group on close, andworker.Manager#readTopologyIdentityreads it back. It cannot be reused as-is for the worker name, because the App Worker mints it after the workers exist, while the name is needed before the first worker is constructed. The worker-group id has to be minted in the main thread at boot; the topology identity can then adopt it instead of minting a second one.What this shape decides, beyond the crash:
learn/guide advertises cross-tab sharing, and Neo's multi-window demos all open popups.'window-tree'versus today's'origin'— keeps the old behaviour available and names the choice instead of hiding it.learn/guides/fundamentals/WorkerArchitecture.mdandlearn/guides/advanced/CanvasArchitecture.mddescribe the current one-worker-set model and would need the group concept.Decided: the fix shape
D#18730 decided it, not this ticket. Of the five shapes weighed here on 2026-09-15, per-window workers and a documented constraint are retired; paint-by-process and the WebKit refusal are subsumed by G + A, where the worker never paints into a DOM canvas anywhere and every window presents locally, so nothing needs classifying and no engine needs an exception; one worker group per window tree is D#18730's Option B, still open there and not this ticket's gate. The engine matrix and the worker-group measurements above are carried there as fixed input.
Acceptance Criteria
a29a2e3935againstapps/portal(three arms, 21.4 s: both controls alive, the no-opener armPage crashed), so it needs neither Workstation nor the Neural Link fixture and can be CI-gated; lands as its own leaf withRefs #18376.AC-2 — the presenter contract. Under G + A the worker plane keeps the scene, the contexts and the render loop, and never owns a DOM-backed canvas; each window presents published frames into its own canvas locally. Each item is checkable:
component.Canvas#afterSetMounted->DomAccess#transferCanvasToWorkeris the only live one.DomAccess#getOffscreenCanvasandworker.Canvas#retrieveCanvasare the uncalled pre-#9479 path and go with it, along withworker/Manager.mjs:183's route to the undefinedonGetOffscreenCanvas. Leaving any of them reachable preserves the original failure behind a new mechanism.renderer.updateMouseState/renderer.onMouseMove.devicePixelRatiois backing-store resolution, not an input multiplier, so a DPR-only change mis-aims nothing and must not be applied to a coordinate; only presentation scaling or letterboxing is inverted. Pinned by a known displayed point reaching the existing RPC at its intended logical coordinate after EACH of: attach, logical resize, DPR change, rehome — and with the presenter at a nonzero document offset. Without it every forwarded coordinate addresses the wrong point in the shared scene, and it fails silently: a hover highlighting the wrong bar, not an error.afterSetMountedattaches and detaches,onDomResizefires resize,afterSetWindowIdis the rehome,destroyis the last detach. Logical resize stays renderer-layout input, not merely a resized bitmap. Detach releases the presenter's resources and its registration for THAT WINDOW ONLY; worker scene state survives.component.Sparkline,app.SharedCanvas,app.header.Canvas, and Workstation's 31 sparklines.ArrayBufferpresented asImageData— the only arm measured working for foreign and reopened clients in all three engines (D#18730 transport matrix). Delivering anImageBitmapacross the realm boundary is Chromium-only: Firefox and WebKit answeredmessageerrorwith zero frames for foreign and reopened clients, which is exactly the client this ticket is about;transferToImageBitmap()additionally clears its producing bitmap, so incremental rendering needs an explicit disposition. A bitmap fast path may be added where it is cost-justified, but no client may depend on it as its only path. Constructing a bitmap locally inside the presenting document (createImageBitmap(imageData)+drawImage, the scaling escapeputImageDatalacks) is a different operation from delivering one across the boundary — stated, not measured; it needs its own arm before anything relies on it. No second scene graph, no worker-group controller.AC-3 — Under G, two full Workstation roots boot and stay alive 5 of 5 with the AC-1 reproduction green, and the sparklines render in BOTH roots. That claim holds in all three engines including WebKit, where no canvas is adopted in any arm today and local paint is the only path that works (D#18730 OQ6).
AC-4 —
WorkstationTopologyGroupsNL.spec.mjs's docblock names the mechanism instead of "crash the shared worker", its wording ondev@03219a75b7and still its wording ata29a2e3935.AC-5 — A window can be classified as sharing the canvas worker's renderer process or not, by a named mechanism, before any shape that depends on it is written.Withdrawn: premise dissolved, not satisfied. The AC was conditional on a shape that depends on a classifier; G gates nothing, so nothing foreign is ever painted and nothing needs classifying. D#18730's OQ7 gives the general form — a sound classifier can only ever answer "safe", never "dangerous", so every classifier-gated shape inherits a correlate where it needs a guarantee, and the graduation explicitly does not adopt A's classifier. The opener-lineage measurement stays in this body as evidence.AC-6 — cost and transport, as binding implementation evidence. The criterion is end-to-end cost of the RGBA transport against a named app baseline, on representative DISTINCT scenes —
component.Sparklineowns a renderer item per canvas with its ownvalues, so Workstation's 31 canvases are 31 distinct scenes, not one scene fanned out 31 ways. D#18730's measurement 9 (flat 2.0 ms at N = 1, 8 and 31) is not the baseline: it priced one-scene fanout on the bitmap arm, and the shipping transport allocates and reads back every submitted frame (getImageData+ a transferable buffer). Today's sparse updates and adaptive throttling are preserved rather than replaced by a publish-every-tick loop, and the production transport is demonstrated in all three engines.Rebuilding the probe
:63342, routes by project name and can serve another checkout. A stack line that doesn't match your file is the tell.context.newPage()for the red arm.window.openfrom root A is the control that must stay alive.Out of Scope
Related
Origin Session ID: 3711ad57-061d-43fb-b1f1-aa9dc21dedda