Skip to content

Feature/component garbage collection#138

Open
janvojt wants to merge 8 commits into
teamapps-org:masterfrom
janvojt:feature/component-garbage-collection
Open

Feature/component garbage collection#138
janvojt wants to merge 8 commits into
teamapps-org:masterfrom
janvojt:feature/component-garbage-collection

Conversation

@janvojt

@janvojt janvojt commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Garbage collection of unused components within live sessions

Problem

SessionContext keeps a strong reference to every client object (component) ever rendered, for the whole lifetime of the session. In long-lived sessions that repeatedly rebuild parts of their UI (dashboards, wizards, record detail views, …), all discarded component trees accumulate on the server and on the client, since the client-side counterparts are never destroyed either. Memory usage grows monotonically until the session ends.

Solution

This PR makes the session's client-object registry weak-reference based, so components that are neither displayed nor referenced by application code get garbage collected during the session's lifetime, and their client-side counterparts are destroyed automatically.

The feature is opt-in via a new configuration flag (default off):

TeamAppsConfiguration config = new TeamAppsConfiguration();
config.setClientObjectGarbageCollectionEnabled(true);

With the flag off, behavior is unchanged: every registered client object is pinned at registration time and stays strongly referenced until the session is destroyed (exact historical behavior, covered by dedicated tests).

How it works

  • Weak registry. SessionContext.clientObjectsById now holds WeakReferences (a ConcurrentHashMap keyed by component id) backed by a ReferenceQueue. Collected entries are drained opportunistically on every registerClientObject(...) call and periodically by the session manager's housekeeping loop (which also covers idle sessions). For each drained entry, a DestroyComponentCommand is sent so the client releases the DOM/state too. A two-arg remove(key, reference) guards against races with explicit unregistration — an explicitly unrendered component is never destroyed twice.
  • Pinning keeps displayed UI alive. Components that are displayed without necessarily being referenced by application code are strongly referenced ("pinned") by the session context:
    • root panels (addRootPanel*) — permanently, since the client appends root panels and never removes them,
    • shown Windows, Popups and Notifications — pinned on show, unpinned on close (whether server-initiated or user-initiated on the client),
    • context menu content (Table, Tree, InfiniteItemView/2, ChatDisplay, MediaSoupV3WebRtcClient) and ToolbarButton drop-down content — retained by the owning component while the client may still display it,
    • session expired/error/terminated windows.
  • Reference-counted pins. SessionContext.pinClientObject(...) / unpinClientObject(...) are public API and reference-counted, so an object pinned for multiple independent reasons stays pinned until every reason released it. Spurious unpins are no-ops (saturating at zero). Window/Popup/Notification acquire their show-pin through the new idempotent AbstractComponent.pinWhileDisplayed() / releaseDisplayPin() helpers, so racing double-releases (e.g. a server-side close() plus the client's closed event) cannot underflow another pin. Custom component authors can use the same helpers for transient components that are referenced only from the client.
  • In-flight events tolerated (only when enabled). An event or query can legitimately race the collection of its target component. With GC enabled, such events are logged and ignored, and queries answer null. With GC disabled, the historical fail-fast behavior is kept: TeamAppsComponentNotFoundException destroys the session, since an unknown id then indicates a real client/server desync.
  • Observability. UiSessionStats gains two values, updated by the housekeeping loop: clientObjectCount (currently registered, gauge) and collectedClientObjectCount (collected during the session's lifetime, cumulative).

Popup close-on-escape / close-on-click-outside

Popup.closeOnEscape and closeOnClickOutside existed in the API and DTO but were never implemented on the client — the flags were dead. Correct unpinning requires the server to learn about user-initiated closes, so this PR implements them: UiPopup.ts now closes on escape / click-outside (mirroring UiWindow) and fires a new closed DTO event; the server unpins and fires the new Popup.onClosed event.

Behavior changes to be aware of (even with the flag off)

  1. Popups now really close on escape / click-outside when those flags are set (previously silently ignored), and Popup.onClosed is available.
  2. ToolbarButton drop-down suppliers are cached: the supplier's result is reused across re-render cycles instead of being re-invoked (the client requests the content only once anyway). Use updateDropDownComponent(...) to replace displayed content.
  3. SessionContext's constructor takes an additional boolean and UiSessionStats has two new methods — relevant only to code reaching into framework internals.

Adopting garbage collection in applications

Nothing is required for standard usage — the framework pins everything it displays itself. To benefit from the flag and stay safe:

  1. Dispose listeners on application-scoped objects when discarding components. A shared model or application-wide Event strongly references its listeners, and a listener typically references its component — such components cannot be collected until the listener is removed (use the Disposable returned by Event.addListener(...)). This is the most common reason for collectedClientObjectCount staying near zero.
  2. Audit custom components handing out createUiReference() of transient components. If the only remaining reference lives in client-side state, pin the component while it is displayed (pinWhileDisplayed() / pinClientObject(...)) and release it on close, as Window/Popup/Notification do.
  3. Do not use the registry as storage. Keep server-side references to any component the application will interact with later; getClientObject(id) may return null once GC is enabled. Custom client-side components should tolerate a null query result.

Recommended rollout: enable in staging, watch the two new session stats, and exercise the main UI flows.

Testing

  • New SessionContextClientObjectGcTest (22 tests): unreferenced components are collected and destroyed exactly once on the client; displayed UI (root panels, shown windows/popups/notifications) survives GC and becomes collectable after each close path (server-side and client-side); reference-counting semantics (double pin/unpin, spurious unpin, close/closed-event races); registry stays bounded under repeated content replacement; unknown-id tolerance with the flag on and fail-fast with the flag off; exact historical behavior with the flag off.
  • Full teamapps-ux module: 251 tests, 0 failures.
  • Note: the GC tests rely on System.gc() triggering a collection and will not work under -XX:+DisableExplicitGC.
  • The UiPopup.ts changes mirror UiWindow.ts but were authored on a machine without a working frontend build — please smoke-test popup escape/click-outside closing during review.

🤖 Generated with Claude Code

janvojt and others added 8 commits July 8, 2026 21:44
Components used to stay strongly referenced in SessionContext's registry
for the whole session lifetime, so long-lived browser tabs accumulated
every component ever rendered and eventually caused server OOM (the
browser-side registry leaked identically).

The registry now holds weak references: while a component is displayed
(reachable from a root panel, shown window/popup/notification, context
menu, toolbar dropdown) or referenced by application code, it stays
alive; once unreachable, the JVM collects it and a ReferenceQueue drain
sends UiRootPanel.destroyComponent so the client frees its DOM and
registry entry too. Draining happens on component registration and every
10 seconds in TeamAppsSessionManager's housekeeping loop.

Gated by TeamAppsConfiguration.clientObjectGarbageCollectionEnabled,
default OFF. With the flag off, behavior is strictly historical (every
registered component is pinned; unpin is a no-op), with one deliberate
exception: events/queries for unknown component ids now log a warning
instead of throwing TeamAppsComponentNotFoundException, which used to
destroy the entire session.

With the flag ON, note two behavior changes:
- render-and-forget of NOT-displayed components breaks: apps must hold a
  Java reference to any component they want to reuse later
- custom components referencing a temporary component only through a
  queued command must pin it via the new
  SessionContext.pinClientObject/unpinClientObject escape hatch

New observability: SessionContext.getClientObjectCount() /
getCollectedClientObjectsCount(), surfaced in UiSessionStats.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntics with a test

The client-side UiRootPanel.buildRootPanel appends the root panel to the
container element instead of replacing an existing one, so every component
attached via SessionContext.addRootPanel stays displayed for the rest of the
session and must stay strongly referenced. Clarify this in the Javadoc of
attachedRootComponents (it must not be keyed by container selector) and add a
test asserting that two root panels added for the same selector both stay
referenced under client-object garbage collection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hen GC is disabled

The client-object GC commit unconditionally changed handling of UI
events/queries referencing unknown client object ids from fail-fast to
warn-and-continue. That tolerant path is only justified when garbage
collection is enabled, where an event/query can legitimately race with
collection of its target. With the flag off (the default), an unknown id
indicates a genuine client/server desync, so the historical fail-fast
behavior is restored: TeamAppsComponentNotFoundException is thrown and
the session is destroyed with SERVER_SIDE_ERROR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SessionContext.showNotification() added a SelfDisposingEventListener to
notification.onClosed on every call, but a server-initiated
Notification.close() never fires onClosed, so repeated show/close cycles
on the same Notification instance accumulated listeners. Move the
pin/unpin bookkeeping into Notification itself: showNotification() pins
via the idempotent pinUntilClosed(), and Notification unpins exactly
once per cycle from either close path (close() or UI_NOTIFICATION_CLOSED),
without registering any per-show listeners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- SessionContext: reuse the uxJacksonSerializationTemplate field in
  onUiQuery instead of allocating a new template per query
- Extend the lastContextMenuComponent field comment in Table, Tree,
  InfiniteItemView, InfiniteItemView2, MediaSoupV3WebRtcClient and
  ChatDisplay to document the deliberate retention semantics
- TeamAppsSessionManager: note that client object counts may lag one
  housekeeping cycle behind the asynchronous drain
- SessionContextClientObjectGcTest: make the pre-collection assertion
  robust against natural GC, shut down created session executors after
  each test, and document the System.gc() dependence
- TeamAppsConfiguration: document that listener registrations on
  application-scoped objects keep components strongly reachable
- ToolbarButton: document the drop-down component supplier caching

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntObject

Pinning was set-based, so an object pinned for two independent reasons
(e.g. a shown popup additionally pinned by application code) was fully
released by a single unpin, allowing a still-displayed component to be
garbage collected and destroyed client-side. Pins are now reference-
counted in SessionContext (saturating at zero), and Window, Popup and
Notification guard their show/close pin with a pinnedWhileShowing flag
so each show/close cycle pins and unpins exactly once, even on
double-close, close-without-show, or a racing client-side close event.
The stacking one-shot onClosed listener in showNotification is replaced
by direct unpinning in Notification.handleUiEvent.

Also adds the missing CurrentSessionContext.throwIfNotSameAs guard to
SessionContext.unregisterClientObject, which mutates the non-thread-safe
pin structure and registry like the other guarded mutators.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
UiPopup gains a `closed` DTO event, mirroring UiWindow's. The client
UiPopup now actually implements closeOnEscape / closeOnClickOutside —
these config flags existed and were transmitted to the client, but were
dead there (no client-side close behavior at all). A user-initiated
close (escape key or click outside the content) removes the popup and
fires the new event; the server-side Popup handles UI_POPUP_CLOSED by
unpinning itself from the SessionContext client-object registry (the
pin created in SessionContext.showPopup*()) and firing its new public
onClosed event. Without this, a client-closed popup would have stayed
strongly referenced (pinned) until session death when client-object
garbage collection is enabled.

A server-initiated Popup.close() after a client-side close remains
harmless: pinned client objects are held in a Set, so the double unpin
is a no-op (covered by a new test).

Note: the TypeScript changes could not be compiled or tested on this
machine (the frontend yarn build is broken here by read-only bind
mounts); they mirror the existing UiWindow.ts escape/click-outside
implementation as closely as possible and should be smoke-tested on a
machine with a working frontend build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Window, Popup, and Notification each duplicated the same pinnedWhileShowing
guard protecting the reference-counted pin registry from double-release
(e.g. a server-side close racing the client's close event). Move the flag
and the pin/release pair into AbstractComponent as pinWhileDisplayed() /
releaseDisplayPin(), so the invariant lives in one place and custom
component authors get a documented keep-alive facility for components
referenced only by the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant