major
#29580
RowSourceObserver: a disposed observer is still notified and throws NPE (_scope null) from DerivedViewChannel and GlobalModelEventForwarder
Found in BOS/Consulting (React view layer, TopLogic 8.0.0-alpha8) while building a routing sidebar (ScopeRailControl, a RoutingParticipant that rebuilds its rail when the route's first segment changes).
Symptom 1 (HTTP 500): when the control replaced its rail inside onAttach (route resolution in RouteManager.tryResolvePending → ReactControl.onAttach → ViewServlet.renderPage), the outgoing rail's RowSourceObserver was disposed inside a channel notification, but DerivedViewChannel.recompute still notified the snapshot of listeners it had taken, so the disposed observer ran registerObjectListeners with _scope == null:
java.lang.NullPointerException: Cannot invoke "ModelScope.addModelListener(...)" because "this._scope" is null at RowSourceObserver.registerObjectListeners(RowSourceObserver.java:167) at DerivedViewChannel.recompute(DerivedViewChannel.java:147) at ScopeRailControl.activateRoute(...) at RouteManager.tryResolvePending(RouteManager.java:192) at ReactControl.onAttach(ReactControl.java:1070) at ViewServlet.renderPage(ViewServlet.java:492)
The application worked around it by deferring the disposal through ChannelNotificationScope.afterNotification (Consulting PR #192).
Symptom 2 (swallowed ERROR): the same disposed observer is reached by a second delivery path, a model event synthesised later in the same request:
ERROR EventBuilder - Failed to deliver change event to listener: RowSourceObserver@3a71961c java.lang.NullPointerException: Cannot invoke "ModelScope.removeModelListener(...)" because "this._scope" is null at RowSourceObserver.deregisterObjectListeners(RowSourceObserver.java:176) at RowSourceObserver.reEvaluate(RowSourceObserver.java:148) at RowSourceObserver.notifyChange(RowSourceObserver.java:139) at EventBuilder.notifyListeners(EventBuilder.java:474) at GlobalModelEventForwarder.synthesizeModelEvents(GlobalModelEventForwarder.java:165) at ReactServlet.handleCommand(ReactServlet.java:487)
EventBuilder catches and logs it; observed once, on the creation of the first project of a fresh installation.
Expected: a RowSourceObserver that has been disposed (its _scope cleared) must not act on a notification: either the observer checks its own liveness at the top of notifyChange/`reEvaluate`/`registerObjectListeners` and returns, or the channel/event dispatch drops listeners that were removed during the notification instead of notifying a CopyOnWriteArrayList snapshot. Any control that tears down a subtree inside a channel notification hits this today.
Reference: BOS/Consulting PRs #187 and #192 (Gitea), review reports of 2026-09-14.
Lösung
Ursache ist allgemein: Eine Benachrichtigung läuft über eine vorab gezogene Liste der Listener (CopyOnWriteArrayList-Snapshot bei Kanälen, gesammelte Empfängermenge im EventBuilder). Ob ein Listener darin noch angemeldet ist, lässt sich mit der add/`remove(listener)`-API nur durch Suche beantworten. Daher ist die Anmeldung auf Handles umgestellt:
- Eine Anmeldung liefert ein Registration-Handle (com.top_logic.basic.listener). dispose() markiert es als inaktiv und gibt die Referenz auf den Listener frei; eine laufende Benachrichtigung überspringt inaktive Anmeldungen in O(1). Ein während einer Benachrichtigung abgemeldeter Listener wird damit nicht mehr aufgerufen – für jeden Listener, nicht nur RowSourceObserver.
- Speicher ist AbstractObservable/`AbstractListeners` (com.top_logic.basic): Die Liste wird nur ergänzt, nie in-place verkleinert; eine Benachrichtigung läuft bis zur Länge bei ihrem Start, so dass während einer Benachrichtigung nichts kopiert werden muss. Abgemeldete Einträge werden amortisiert entfernt (sobald es mehr tote als lebende gibt, in eine neue Liste), so dass dispose() amortisiert O(1) kostet.
- ViewChannel.addListener und ModelScope.addModelListener liefern das Handle; DefaultViewChannel, DerivedViewChannel und DefaultModelScope bauen darauf auf (DefaultModelScope entfernt den Eintrag eines Objekts/Typs, sobald dessen letzte Anmeldung abgemeldet ist). Der EventBuilder sammelt Anmeldungen statt Listener, benachrichtigt jeden Listener höchstens einmal und überspringt inaktive.
- removeListener/`removeModelListener` bleiben für Code außerhalb der Engine erhalten (meldet alle Anmeldungen des Listeners ab), sind aber zugunsten von Registration.dispose() als deprecated markiert. Die Aufrufer in der Engine (View-Layer-Controls, LayoutComponent, ObservedObjects, HTMLTemplateFormProvider, …) halten das Handle ihrer Anmeldung und geben es frei. LayoutComponent meldet dabei genau die Beobachtungen ab, die es angemeldet hat (statt sie beim Abmelden aus dem aktuellen Modell neu zu berechnen).
- Kanäle sind nicht synchronisiert; sie werden unter der Interaktion der Session (Request bzw. Hintergrund-Job über ReactWindowReplay.inWindow) von jeweils einem Thread benutzt.
Hinweis für Entwickler: addListener/`addModelListener` haben einen anderen Rückgabetyp; Module, die dagegen kompiliert sind, müssen neu übersetzt werden (sonst NoSuchMethodError). removeListener/`removeModelListener` sind deprecated: das Handle des add-Aufrufs aufheben und dispose() aufrufen.