Skip to content

Isolate bus listeners so one handler cannot abort an emit - #324

Merged
sandesh-sp merged 2 commits into
developmentfrom
fix/bus-emit-listener-isolation
Aug 25, 2026
Merged

Isolate bus listeners so one handler cannot abort an emit#324
sandesh-sp merged 2 commits into
developmentfrom
fix/bus-emit-listener-isolation

Conversation

@sandesh-sp

Copy link
Copy Markdown
Collaborator

Why

mmgisAPI.emit was mitt's emit directly. mitt iterates its handler array and calls each one unguarded, so the first listener that throws takes the whole emit down with it — every listener registered after it is silently skipped, and the exception surfaces at the emitter, which has nothing to do with the fault.

That is a poor property for a bus whose entire purpose is decoupling. A plugin subscribing to layer:toggle can currently suppress core's own handling of it.

What

emit walks the handler list itself and runs each subscriber in a try/catch. A handler that throws is reported with console.error naming the event, and the emit continues. Wildcard listeners get the same treatment.

The list is copied before iterating, so a handler that subscribes or unsubscribes during the emit cannot corrupt the walk it is part of.

This reaches into events.all, mitt's internal Map of event name to handler array. That is implementation, not documented API, and the comment says so — a mitt upgrade is a place to re-check this.

Scope

One method. No caller changes, no signature change.

Testing

tests/unit/mmgisAPIBus.spec.js covers a throwing listener not blocking the ones after it, the error being reported, and subscribe/unsubscribe during an emit.

1413 tests passing, tsc --noEmit clean.

Related

First of a three-part stack. This one is independent of the rest and can merge on its own — it is a live bug fix. The panel and plugin command work builds on it in #322.

@sandesh-sp
sandesh-sp force-pushed the fix/bus-emit-listener-isolation branch from ff7f4ac to 92a96fd Compare August 19, 2026 14:33
Dispatching from events.all rather than through mitt's own emit is what lets a
throwing listener be caught and skipped, and it puts three of mitt's rules in
this file's hands: the subscriber list is copied before iteration, specific
listeners run before wildcards, and wildcards are called with (type, data).
Only the wildcard signature was tested. Removing the copy, reordering the two
passes, or handing every listener the wrong arguments all left the suite green,
and so did dropping the try/catch around the wildcard pass.

Tests for each of those, plus the plainest promise of the lot — that an
ordinary listener receives the payload it was emitted with.

Cleanups move to afterEach. Run after the assertions, an unsubscribe is skipped
by the first failure, and the next test in the file inherits a live listener on
a bus that outlives it.

@CarsonDavis CarsonDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving, but maybe worth a follow-up: this PR re-implements mitt's delivery, leaving mitt doing only the storage. That storage is just a Map and two functions we could own ourselves and drop the dependency (needs an off() miss-guard, and the bus in its own module so the test mock drops mitt too).

@sandesh-sp
sandesh-sp merged commit 554d5b6 into development Aug 25, 2026
4 checks passed
@sandesh-sp
sandesh-sp deleted the fix/bus-emit-listener-isolation branch August 25, 2026 19:20
sandesh-sp added a commit that referenced this pull request Aug 27, 2026
## Why

Panels and plugins can only be driven from inside core. `PanelManager_`
and `ToolControllerModern_` are reachable by direct import, which a
sandboxed plugin cannot do, so a plugin that wants to collapse the panel
it lives in — or reveal a sibling plugin — has no way to read the state
or act on it.

The state owners were also awkward to drive even from inside core. They
signalled refusal by throwing, which does not survive a bus boundary,
and panels offered only `togglePanelCollapsed`: a toggle acts on
whatever it finds rather than on what the caller asked for, so a control
working from a listing it read a moment ago flips the wrong way, and a
retry undoes itself.

## What

**Results instead of exceptions.** `setPanelState` returns `{ ok: true,
state, changed }` or `{ ok: false, reason }`. `canSetState` exposes the
same constraints, so a control can decide whether to draw itself at all
rather than discovering the answer by being clicked. `showPanel`
replaces the toggle and names the state it resolves to.

**Plugin lifecycle as states.** `ToolControllerModern_`'s separate
loaded and hidden flags become one of `unloaded`, `hidden`, `visible`,
reachable through `setPluginState`. Asking for `visible` on an unloaded
plugin loads it.

**Eight handlers on the bus** plugins already use for everything else:

| | |
|---|---|
| `panels:getAll` / `plugins:getAll` | the current listing |
| `panels:setState` / `plugins:setState` | the primitive |
| `panels:show` / `panels:hide` | sugar, resolving a target state |
| `plugins:show` / `plugins:hide` | same |

Handlers register at module load and stay registered. An absent layout
is reported as a `layout-inactive` **result**, not a missing handler, so
a caller sees one failure vocabulary whichever layer refused. The
managers are read through getters rather than captured at registration,
since the UI clears them on teardown.

**Two events.** `panels:changed` and `plugins:changed` carry the same
listing the `getAll` handlers return, so a subscriber never has to
re-request. `panels:changed` replaces `mmgis-panel-layout-changed`.
Payloads are frozen projections holding no core references, so they
survive a structured clone — which is what a future sandbox bridge will
have to do to them.

Matching typed wrappers land in
`src/essence/Tools/_shared/adapters/mmgisAPI.ts`, the one place the bus
name strings appear.

## Scope

Mostly additive, with two breaking changes worth knowing about before
merge:

- `PanelManager_.togglePanelCollapsed` is removed, from both the class
and the interface. A toggle acts on whatever it finds, which is the
problem this PR exists to fix; `showPanel` and `setPanelState` name the
state they resolve to instead.
- `mmgis-panel-layout-changed` is renamed to `panels:changed`.

Everything else is additive. The existing `mmgisAPI.showPanel` /
`hidePanel` / `togglePanel` methods and the `core:*` command events all
still work — they are reimplemented on the new primitives here and
removed in #325.

## Defects fixed along the way

Two, both in the state owners this PR reworks:

- `panels:show` on an already-visible panel resolved a target state and
so could *shrink* it. Every stock layout gives the left panel an
`iconified` default, so showing an expanded panel iconified it. Show now
only ever lifts a panel out of `collapsed`.
- A failed plugin transition reported `not-found`, pointing a reader at
the plugin id — the one thing already known to be good, since the id was
resolved before the transition was attempted. It reports
`transition-failed`.

## Testing

Provider specs for all eight handlers (refusal vocabulary, malformed
payloads, absent layout, the float-position restriction),
`PanelManager_` command specs, plugin state-transition specs, and the
typed wrappers.

**1458 tests passing, `tsc --noEmit` clean.** Every commit in the stack
was verified green in isolation, not just the tip.

## Related

Second of a three-part stack: #324 → **this** → #325.

Note for #323: this replaces the API that branch was written against,
and the break lands when **this** PR merges — not #325.
`panels:toggleCollapsed` is not registered (toggle was dropped
deliberately, see above), `collapsible` is not on the listing, and the
event is now `panels:changed`. #323 needs a follow-up to move onto
`panels:show` / `panels:hide` driven off the `state` it already reads.
sandesh-sp added a commit that referenced this pull request Aug 27, 2026
## Why

With the request surface in place (#322), there were three ways to
collapse a panel: a direct `PanelManager_` call, an `mmgisAPI.hidePanel`
method, and a `core:hidePanel` event. Two of them are strictly worse
than the third.

`core:*` events are fire-and-forget, so a refusal has nowhere to go — a
config naming a panel that does not exist produced nothing at all. The
direct `mmgisAPI` methods cannot cross a sandbox boundary, since
`postMessage` cannot carry a function reference. Both are removed here.

## What

**Removed:** `_initCoreCommandDispatcher` and the seven `core:*` command
events; `showPanel`, `hidePanel`, `togglePanel`, `showPlugin`,
`hidePlugin`, `loadPlugin`, `unloadPlugin`, `isPluginLoaded`,
`isPluginHidden` from `mmgisAPI`; `togglePanelCollapsed` from
`PanelManager_`.

**Panel controls** issue requests and log the refusal reason rather than
clicking dead, and decide whether to draw themselves from `canSetState`
instead of reading `allowedStates` directly — which missed the
float-position restriction, so a float panel offered a Minimize button
that stranded it.

**Config action strings** resolve through one shared resolver, so a
mission author reads the same request names the API documents rather
than a second tool-specific vocabulary. `core:` is reserved rather than
reused: its verbs have no expressible successor here (`togglePanel` is
gone; `unloadPlugin` needs a state argument a colon-delimited string
cannot carry), so a config still carrying `core:showPlugin:LayersTool`
gets a warning naming the supported actions instead of emitting an event
nothing listens for.

**Consumers migrated:** Chart (`plugins:show` on analysis-ready,
`plugins:setState` on close), AOI (unload on close), Title (through the
resolver), AddTempLayer.

**Teardown announces itself.** `panels:changed` and `plugins:changed`
were silent when a layout came down, on the reasoning that teardown
destroys the subscribers with it. The bus outlives the layout, so a
plugin that subscribed is still subscribed and still holding a roster of
panels and plugins that no longer exist. Both events now fire once more
with an empty listing, which agrees with what the providers report from
that point on. `PanelManager_` gains `clear()` so that goes out once
rather than once per panel.

## Regression fixed

AddTempLayer was calling `window.mmgisAPI.showPlugin` / `hidePlugin`
through a locally declared optional type. Those methods are removed
here, and optional chaining would have turned both calls into silent
no-ops. The tool starts hidden and ships no trigger of its own, so its
only door would have stopped opening and its close button would have
stopped closing — with no error anywhere. It goes through the shared
client's plugin wrappers instead, which report a refusal.

Two other defects turned up while tracing impact — `panels:show`
shrinking an already-visible panel, and a failed plugin transition
reporting `not-found`. Both are in the state owners #322 reworks, so
they are fixed there and written up in that PR's body; nothing about
them is in this diff.

## Testing

Specs for the panel controls, the config resolver, and each migrated
consumer, including a negative check run on every new test — the
behaviour was broken to confirm the test goes red, then restored.

**1485 tests passing, `tsc --noEmit` clean.**

## Related

Third of a three-part stack: #324#322 → **this**. Merge in order.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants