unowned-rationale: parked deliberately. It is @tobiu's challenge and the natural continuation of my #18556, but I hold three open PRs (#18557 merged, #18565, plus reviews owed) and this wants the manifest widening and the hoist done in one uninterrupted pass — AC-2's positive control is the kind of thing a sliced attention span skips, and skipping it yields an arm that passes whether or not the mirror runs. Written to be picked up cold: the blocker, the mechanism that resolves it, and the silent-no-op trap are all named with anchors. I will take it when my current lanes close if nobody has.
Context
@tobiu's challenge on #18557, after it merged, and it is correct: "we have worker.Base. main threads do not extend it, but app, data, vdom, canvas and tasks workers do. now, if they are shared workers, only app sends messages to main threads. which raises the question => moving related logic into the base class could enable error logging for all used shared workers."
#18556 gave the App worker a main-thread error mirror. The premise of that ticket — "a SharedWorker's console output goes to its own inspector context, which no page can read" — is not specific to the App worker. It is true of every SharedWorker, and Neo runs up to five.
The Problem
All five worker types become SharedWorkers together.worker/Manager.mjs:250:
createWorker is the single path for every worker, so useSharedWorkers: true makes App, Data, VDom, Canvas and Task all SharedWorkers, each with its own unreadable inspector context.
Only one of them intercepts console at all.grep -ln interceptConsole src/worker/*.mjs → src/worker/App.mjs alone. So an error raised in the Data, VDom, Canvas or Task worker reaches neither the page nor the Neural Link. It is not merely un-mirrored; it is uninstrumented. That is a wider gap than #18556 closed, and worse than the one it closed, because the Neural Link at least covered the App worker.
And the existing per-worker onerror is not a fallback.Manager.createWorker wires (isShared ? worker.port : worker).onerror = me.onWorkerError, which is:
onWorkerError(e) {
// starting a worker from a JS module will show JS errors in a correct way
!useMjsFiles && console.log('Worker Error:', e)
}
Two reasons it does not cover this: it logs at log level, and it runs only when !useMjsFiles — deferring to browser behaviour that the comment states as general. That deferral is measured true for a dedicated module worker (a Blob worker's console.error reaches page.on('console') with no forwarding) and false for a SharedWorker, which is the whole reason #18556 existed. The comment is right about the case it was written for and silent about the case that matters here.
The Architectural Reality
The blocker, and the reason "move it to worker.Base" is not sufficient on its own:
Neo.Main.log is not callable from the other four workers.src/Main.mjs declares remote: {app: [...]} — app is the only key. worker/mixin/RemoteMethodAccess.mjs:188 gates proxy generation on remote.destination === Neo.workerId, and the worker ids are app / data / vdom / canvas / task. So Neo.Main does not exist in four of the five.
⚠️ And it would fail silently.forwardErrorToMainThread reads Neo.Main?.log?.({…})?.catch?.(…). Hoisted to worker.Base unchanged, that optional chaining evaluates to undefined and returns — no throw, no log, no mirror, in exactly the four workers the change is for. The guard I wrote to keep a diagnostic from becoming a fault of its own would instead keep it from being observable. This is the first thing to get right.
The mechanism does support the fan-out.core/Base.mjs:988:
The manifest key is the target worker id, and the fan-out is already existence-guarded. @tobiu raised exactly this concern — "we only should check if they exist inside the current app, since some like canvas and task are optional" — and the check is if (origin.hasWorker(worker)) in the loop above, where Manager.hasWorker(name) is !!this.getWorker(name), a live lookup rather than a config read.
That matters for cost: useCanvasWorker: false, useTaskWorker: false and useVdomWorker are all defaults-off flags, so most apps run two of the five. Adding manifest keys for absent workers is inert — promiseRemotes skips any worker hasWorker does not find, silently and by design. No conditional manifest, no per-app variant, no new guard. Transport is there too: Manager.createWorker wires a port per worker in both directions.
The Fix
Move the console interception and the error mirror from worker/App.mjs to worker/Base.mjs, and widen Main's remote manifest so the other workers can reach log.
Order matters, because the middle step is the one that can pass while doing nothing:
Widen the manifest first. Expose log — and only log — to canvas, data, task, vdom in src/Main.mjs. Unconditionally: the hasWorker guard above already makes an absent worker's key inert, so a conditional manifest would add a second existence check behind the one that works. This is a public-surface change and wants a Contract Ledger row.
Then hoist.interceptConsole, serializeConsoleArgs, forwardErrorToMainThread, mirrorEnvironments and isForwardingError move to worker/Base.mjs. The Neo.ai?.Client branch stays behaviourally App-only in effect, since only the App worker constructs a client — but it must not be structurally App-only, or the split reintroduces the coupling #18556 removed.
Prefix per worker.'App Worker: ' becomes worker-derived (Neo.workerId), so a mirrored line says which heap raised it. Four workers writing an identical prefix into one console is worse than no prefix.
Acceptance Criteria
AC-1 — an error in a NON-App SharedWorker reaches the page console. Verified in at least one of Data / VDom / Canvas / Task, on a useSharedWorkers: true app. This is the whole ticket; the App case already works and re-proving it is not evidence.
AC-2 — the silent-no-op path is closed, and proven closed. With the manifest widened, Neo.Main.log resolves in the worker under test. ⚠️ Positive control required: assert it resolves before asserting the mirror fires, because Neo.Main?.log?.() returns undefined rather than throwing — an arm that only checks "no error appeared in the page" would pass identically whether the mirror works or is absent. That failure mode is the reason this AC exists.
AC-3 — the mirrored line names its worker. A Data-worker error is distinguishable from an App-worker error in the page console without reading the stack.
AC-4 — dedicated mode is still not mirrored, for all five. The browser already forwards a dedicated worker's console; #18557 established this by measurement and the base-class move must not reintroduce the double-log for four more workers. Control: useSharedWorkers: false, assert no mirrored line.
AC-5 — the environment allowlist survives the hoist.mirrorEnvironments stays an allowlist; a new environment gets no mirror until someone decides it should. Regression-prone because the denylist version of this shipped once and dist/esm escaped it (#18557 RA-1, @neo-opus-vega).
AC-6 — Manager.onWorkerError is retired or rewritten; its guard is provably obsolete. @tobiu challenged the !useMjsFiles condition as dating from "times when webpack had no JS module support" and asked for verify-before-assert. Confirmed, with the commit:
when
what
2019-11-17 8ccc8e7d7b
worker = !Neo.config.isExperimental ? new Worker(filePath) : new Worker(filePath, {type: 'module'}) — classic was the DEFAULT, module was experimental, with a // todo: … once browsers are ready. The comment "starting a worker from a JS module will show JS errors in a correct way" is from this same commit, written to explain why the classic branch needed a manual log.
the classic branch is deleted — ? new cls(filePath, {name, type: 'module'}) : new cls(filePath, {name}) becomes one unconditional line — and the same commit renames hasJsModules → useMjsFiles inside the guard, without revisiting it
So the guard outlived the branch it existed for, by one hunk of the diff that removed it. Every worker is a module worker in every environment today, which makes !useMjsFiles a condition about which file is loaded wearing a comment about module semantics. Two renames carried the name away from the meaning.
⚠️ And a -S trap worth not repeating:git log -S "type: 'module'" -- src/worker/Manager.mjs returns only the 2019 commit, which reads as "never conditional". -S counts occurrences, and the string sat in the experimental branch from the start, so deleting the conditional never changed the count. I drew the wrong conclusion from it before checking git show 8ccc8e7d7b: on the file. Use -S on the removed text, or read the revision.
AC-7 — Contract Ledger row for the widened Main remote surface, on this ticket, before implementation.
AC-8 — an app running only App+Data is unaffected. Control for the optional workers: with useCanvasWorker and useTaskWorker at their false defaults, the widened manifest produces no registration traffic for them and no console noise. Asserted rather than assumed, because "inert" is a claim about hasWorker and not about the manifest.
Out of Scope
The test gate that fails a spec on a mirrored error.#18554 owns it and stays blocked on the same thing: no page-only trigger for a worker error exists. Widening to four more workers does not supply one.
Routing the other workers through the App worker instead of widening Main's manifest. Worker-to-worker channels exist (worker/Base.mjs:458me.channelPorts[dest]), so it is a real alternative — it keeps Main's remote surface narrow at the cost of making four workers depend on the App worker being alive. Recorded as the rejected alternative with its trade, not dismissed.
unhandledrejection, still unhooked anywhere. Named on #18556 and unchanged; it would be new coverage rather than a new path for existing coverage.
Whether Data/VDom/Canvas/Task actually raise errors worth seeing. Unmeasured. This ticket makes them visible; how often they fire is what visibility is for.
Avoided Traps
Hoisting the method unchanged. The optional chaining makes it a silent no-op in precisely the four workers it is for. AC-2's positive control exists because "no error appeared" is indistinguishable from "nothing ran".
Treating Manager.onWorkerError as existing coverage. It is log-level, !useMjsFiles-only, and defers to a browser behaviour that does not hold for SharedWorkers.
Reusing the 'App Worker: ' prefix for all five.#18554's gate matches that literal; four workers sharing it makes the mirrored line unattributable and the future gate unable to say which heap failed.
Decision Record impact
none for the hoist. The widened Main remote surface is a contract change and takes the Contract Ledger row in AC-7 rather than an ADR.
Related
#18556 / #18557 — the App-worker mirror this generalises, and @neo-opus-vega's RA-1 on its environment allowlist
#18554 — the test gate, still blocked on a page-only trigger
#18551 — the regression that made the blind spot concrete
Handoff Retrieval Hints
query_raw_memories: "worker Base console interception mirror shared worker Main remote manifest widen log"
Anchors: worker/Manager.mjs:250 (all workers become shared together) · Manager.onWorkerError (the non-fallback) · src/Main.mjsremote: {app: […]} (the blocker) · worker/mixin/RemoteMethodAccess.mjs:188 (why it is a blocker) · core/Base.mjs:988 (why the fan-out works) · worker/App.mjsinterceptConsole (what moves)
Reproduce the premise: grep -ln interceptConsole src/worker/*.mjs → one file
unowned-rationale:parked deliberately. It is @tobiu's challenge and the natural continuation of my #18556, but I hold three open PRs (#18557 merged, #18565, plus reviews owed) and this wants the manifest widening and the hoist done in one uninterrupted pass — AC-2's positive control is the kind of thing a sliced attention span skips, and skipping it yields an arm that passes whether or not the mirror runs. Written to be picked up cold: the blocker, the mechanism that resolves it, and the silent-no-op trap are all named with anchors. I will take it when my current lanes close if nobody has.Context
@tobiu's challenge on #18557, after it merged, and it is correct: "we have worker.Base. main threads do not extend it, but app, data, vdom, canvas and tasks workers do. now, if they are shared workers, only app sends messages to main threads. which raises the question => moving related logic into the base class could enable error logging for all used shared workers."
#18556 gave the App worker a main-thread error mirror. The premise of that ticket — "a SharedWorker's console output goes to its own inspector context, which no page can read" — is not specific to the App worker. It is true of every SharedWorker, and Neo runs up to five.
The Problem
All five worker types become SharedWorkers together.
worker/Manager.mjs:250:isShared = me.sharedWorkersEnabled && NeoConfig.useSharedWorkers, cls = isShared ? SharedWorker : Worker,createWorkeris the single path for every worker, souseSharedWorkers: truemakes App, Data, VDom, Canvas and Task all SharedWorkers, each with its own unreadable inspector context.Only one of them intercepts console at all.
grep -ln interceptConsole src/worker/*.mjs→src/worker/App.mjsalone. So an error raised in the Data, VDom, Canvas or Task worker reaches neither the page nor the Neural Link. It is not merely un-mirrored; it is uninstrumented. That is a wider gap than #18556 closed, and worse than the one it closed, because the Neural Link at least covered the App worker.And the existing per-worker
onerroris not a fallback.Manager.createWorkerwires(isShared ? worker.port : worker).onerror = me.onWorkerError, which is:onWorkerError(e) { // starting a worker from a JS module will show JS errors in a correct way !useMjsFiles && console.log('Worker Error:', e) }Two reasons it does not cover this: it logs at
loglevel, and it runs only when!useMjsFiles— deferring to browser behaviour that the comment states as general. That deferral is measured true for a dedicated module worker (a Blob worker'sconsole.errorreachespage.on('console')with no forwarding) and false for a SharedWorker, which is the whole reason #18556 existed. The comment is right about the case it was written for and silent about the case that matters here.The Architectural Reality
The blocker, and the reason "move it to
worker.Base" is not sufficient on its own:Neo.Main.logis not callable from the other four workers.src/Main.mjsdeclaresremote: {app: [...]}—appis the only key.worker/mixin/RemoteMethodAccess.mjs:188gates proxy generation onremote.destination === Neo.workerId, and the worker ids areapp/data/vdom/canvas/task. SoNeo.Maindoes not exist in four of the five.⚠️ And it would fail silently.
forwardErrorToMainThreadreadsNeo.Main?.log?.({…})?.catch?.(…). Hoisted toworker.Baseunchanged, that optional chaining evaluates toundefinedand returns — no throw, no log, no mirror, in exactly the four workers the change is for. The guard I wrote to keep a diagnostic from becoming a fault of its own would instead keep it from being observable. This is the first thing to get right.The mechanism does support the fan-out.
core/Base.mjs:988:Object.entries(remote).forEach(([worker, methods]) => { if (Neo.workerId !== worker) { origin = Neo.workerId === 'main' ? Neo.worker.Manager : Neo.currentWorker; if (origin.hasWorker(worker)) { … registerRemote … } } });The manifest key is the target worker id, and the fan-out is already existence-guarded. @tobiu raised exactly this concern — "we only should check if they exist inside the current app, since some like canvas and task are optional" — and the check is
if (origin.hasWorker(worker))in the loop above, whereManager.hasWorker(name)is!!this.getWorker(name), a live lookup rather than a config read.That matters for cost:
useCanvasWorker: false,useTaskWorker: falseanduseVdomWorkerare all defaults-off flags, so most apps run two of the five. Adding manifest keys for absent workers is inert —promiseRemotesskips any workerhasWorkerdoes not find, silently and by design. No conditional manifest, no per-app variant, no new guard. Transport is there too:Manager.createWorkerwires a port per worker in both directions.The Fix
Move the console interception and the error mirror from
worker/App.mjstoworker/Base.mjs, and widenMain's remote manifest so the other workers can reachlog.Order matters, because the middle step is the one that can pass while doing nothing:
log— and onlylog— tocanvas,data,task,vdominsrc/Main.mjs. Unconditionally: thehasWorkerguard above already makes an absent worker's key inert, so a conditional manifest would add a second existence check behind the one that works. This is a public-surface change and wants a Contract Ledger row.interceptConsole,serializeConsoleArgs,forwardErrorToMainThread,mirrorEnvironmentsandisForwardingErrormove toworker/Base.mjs. TheNeo.ai?.Clientbranch stays behaviourally App-only in effect, since only the App worker constructs a client — but it must not be structurally App-only, or the split reintroduces the coupling #18556 removed.'App Worker: 'becomes worker-derived (Neo.workerId), so a mirrored line says which heap raised it. Four workers writing an identical prefix into one console is worse than no prefix.Acceptance Criteria
AC-1 — an error in a NON-App SharedWorker reaches the page console. Verified in at least one of Data / VDom / Canvas / Task, on a
useSharedWorkers: trueapp. This is the whole ticket; the App case already works and re-proving it is not evidence.AC-2 — the silent-no-op path is closed, and proven closed. With the manifest widened,
Neo.Main.logresolves in the worker under test. ⚠️ Positive control required: assert it resolves before asserting the mirror fires, becauseNeo.Main?.log?.()returnsundefinedrather than throwing — an arm that only checks "no error appeared in the page" would pass identically whether the mirror works or is absent. That failure mode is the reason this AC exists.AC-3 — the mirrored line names its worker. A Data-worker error is distinguishable from an App-worker error in the page console without reading the stack.
AC-4 — dedicated mode is still not mirrored, for all five. The browser already forwards a dedicated worker's console; #18557 established this by measurement and the base-class move must not reintroduce the double-log for four more workers. Control:
useSharedWorkers: false, assert no mirrored line.AC-5 — the environment allowlist survives the hoist.
mirrorEnvironmentsstays an allowlist; a new environment gets no mirror until someone decides it should. Regression-prone because the denylist version of this shipped once anddist/esmescaped it (#18557 RA-1, @neo-opus-vega).AC-6 —
Manager.onWorkerErroris retired or rewritten; its guard is provably obsolete. @tobiu challenged the!useMjsFilescondition as dating from "times when webpack had no JS module support" and asked for verify-before-assert. Confirmed, with the commit:8ccc8e7d7bworker = !Neo.config.isExperimental ? new Worker(filePath) : new Worker(filePath, {type: 'module'})— classic was the DEFAULT, module was experimental, with a// todo: … once browsers are ready. The comment "starting a worker from a JS module will show JS errors in a correct way" is from this same commit, written to explain why the classic branch needed a manual log.7ab8fdcfb0isExperimentalchecks replaced byenvironment53ba60daf8devMode→hasJsModules72bdde6445(#8859)? new cls(filePath, {name, type: 'module'}) : new cls(filePath, {name})becomes one unconditional line — and the same commit renameshasJsModules→useMjsFilesinside the guard, without revisiting itSo the guard outlived the branch it existed for, by one hunk of the diff that removed it. Every worker is a module worker in every environment today, which makes
!useMjsFilesa condition about which file is loaded wearing a comment about module semantics. Two renames carried the name away from the meaning.⚠️ And a
-Strap worth not repeating:git log -S "type: 'module'" -- src/worker/Manager.mjsreturns only the 2019 commit, which reads as "never conditional".-Scounts occurrences, and the string sat in the experimental branch from the start, so deleting the conditional never changed the count. I drew the wrong conclusion from it before checkinggit show 8ccc8e7d7b:on the file. Use-Son the removed text, or read the revision.AC-7 — Contract Ledger row for the widened
Mainremote surface, on this ticket, before implementation.AC-8 — an app running only App+Data is unaffected. Control for the optional workers: with
useCanvasWorkeranduseTaskWorkerat theirfalsedefaults, the widened manifest produces no registration traffic for them and no console noise. Asserted rather than assumed, because "inert" is a claim abouthasWorkerand not about the manifest.Out of Scope
Main's manifest. Worker-to-worker channels exist (worker/Base.mjs:458me.channelPorts[dest]), so it is a real alternative — it keepsMain's remote surface narrow at the cost of making four workers depend on the App worker being alive. Recorded as the rejected alternative with its trade, not dismissed.unhandledrejection, still unhooked anywhere. Named on #18556 and unchanged; it would be new coverage rather than a new path for existing coverage.Avoided Traps
Manager.onWorkerErroras existing coverage. It islog-level,!useMjsFiles-only, and defers to a browser behaviour that does not hold for SharedWorkers.'App Worker: 'prefix for all five. #18554's gate matches that literal; four workers sharing it makes the mirrored line unattributable and the future gate unable to say which heap failed.Decision Record impact
nonefor the hoist. The widenedMainremote surface is a contract change and takes the Contract Ledger row in AC-7 rather than an ADR.Related
Handoff Retrieval Hints
query_raw_memories: "worker Base console interception mirror shared worker Main remote manifest widen log"worker/Manager.mjs:250(all workers become shared together) ·Manager.onWorkerError(the non-fallback) ·src/Main.mjsremote: {app: […]}(the blocker) ·worker/mixin/RemoteMethodAccess.mjs:188(why it is a blocker) ·core/Base.mjs:988(why the fan-out works) ·worker/App.mjsinterceptConsole(what moves)grep -ln interceptConsole src/worker/*.mjs→ one fileOrigin Session ID: 0dd7d644-f279-4b53-a515-522346f5f28f