Skip to content

MYFACES-4751 Destroy @ViewScoped beans of views that are never saved#995

Merged
melloware merged 1 commit into
apache:4.1.xfrom
BalusC:fix_issue_4751
May 29, 2026
Merged

MYFACES-4751 Destroy @ViewScoped beans of views that are never saved#995
melloware merged 1 commit into
apache:4.1.xfrom
BalusC:fix_issue_4751

Conversation

@BalusC
Copy link
Copy Markdown

@BalusC BalusC commented May 29, 2026

MYFACES-4751

Problem

Discovered in eclipse-ee4j/mojarra#5753 (comment). A Facelets view rendered without writing its state — e.g. a page with no UIForm, so no view state token is emitted — can never be restored. Unlike a saved view, it is never registered in the session SerializedViewCollection, so the usual evict view → destroy its view scope cleanup never runs for it. Any @ViewScoped beans created while rendering it therefore linger in the session (ViewScopeContextualStorageHolder) until the session expires; no @PreDestroy is ever invoked.

Requesting such a page repeatedly within one session leaks one view-scope storage per request, unbounded. With a moderately large view (e.g. a ~2000-row table bound to a @ViewScoped bean) refreshed a few hundred times, this exhausts the heap (OutOfMemoryError). A heap dump shows ~N retained ContextualStorage / @ViewScoped bean instances (one per request) where only NUMBER_OF_VIEWS_IN_SESSION should be retained.

Root cause

Server-side @ViewScoped cleanup is driven solely by SerializedViewCollection eviction (ViewScopeContext.destroyAll(viewScopeId)). A view whose state is never written is never put into that collection (StateCacheServerSide's save path isn't reached), so its lazily-created view-scope storage is orphaned. In FaceletViewDeclarationLanguage.renderView this is the "GET case without any form that triggers state saving" branch, which never destroyed the view scope.

Fix

In that branch, destroy the view scope of a non-transient view whose state was not written, at the end of the request, via viewMap.clear() — which publishes PreDestroyViewMapEvent and destroys the (CDI or non-CDI) @ViewScoped storage.

Test

ViewScopeFormlessLeakTestCase renders a formless page bound to a @ViewScoped bean N times in one session and asserts the bean is destroyed N times (its @PreDestroy runs) instead of accumulating. It fails without the fix (expected: <5> but was: <0>) and passes with it.

Notes

Reproduced and verified on a real deployment (TomEE WebProfile, server-side state saving): before the fix the formless GET-refresh OOM-thrashed; after, the retained @ViewScoped storage stays bounded and the workload completes in a 512 MB heap. The issue is not 4.1.x-specific — the 4.0.x line exhibits it too — so it may warrant backporting to 4.0.x.

A view rendered without writing its state - e.g. a page with no UIForm, so no
view state token is emitted - can never be restored, and unlike a saved view it
is never registered in the session SerializedViewCollection. Consequently the
normal "evict view -> destroy its view scope" path never ran for such a view,
and any @ViewScoped beans created while rendering it lingered in the session
(ViewScopeContextualStorageHolder) until the session expired. Requesting such a
page repeatedly within one session therefore leaked one view-scope storage per
request, unbounded.

FaceletViewDeclarationLanguage.renderView now destroys the view scope of a
non-transient view whose state was not written, at the end of the request,
publishing PreDestroyViewMapEvent and destroying its (CDI or non-CDI)
@ViewScoped storage.

Add ViewScopeFormlessLeakTestCase: rendering a formless page bound to a
@ViewScoped bean N times now destroys the bean N times instead of accumulating
it in the session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tandraschko
Copy link
Copy Markdown
Member

thanks @BalusC!

@melloware can you please port it to 4.0 and 5.0, assign the targets to the issue and merge all + close the issue?

@melloware
Copy link
Copy Markdown
Contributor

@tandraschko yep i will take care of it!

@melloware melloware merged commit d78b382 into apache:4.1.x May 29, 2026
melloware added a commit to melloware/myfaces that referenced this pull request May 29, 2026
…ved (apache#995)

A view rendered without writing its state - e.g. a page with no UIForm, so no
view state token is emitted - can never be restored, and unlike a saved view it
is never registered in the session SerializedViewCollection. Consequently the
normal "evict view -> destroy its view scope" path never ran for such a view,
and any @ViewScoped beans created while rendering it lingered in the session
(ViewScopeContextualStorageHolder) until the session expired. Requesting such a
page repeatedly within one session therefore leaked one view-scope storage per
request, unbounded.

FaceletViewDeclarationLanguage.renderView now destroys the view scope of a
non-transient view whose state was not written, at the end of the request,
publishing PreDestroyViewMapEvent and destroying its (CDI or non-CDI)
@ViewScoped storage.

Add ViewScopeFormlessLeakTestCase: rendering a formless page bound to a
@ViewScoped bean N times now destroys the bean N times instead of accumulating
it in the session.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
melloware added a commit that referenced this pull request May 29, 2026
…ved (#995) (#997)

A view rendered without writing its state - e.g. a page with no UIForm, so no
view state token is emitted - can never be restored, and unlike a saved view it
is never registered in the session SerializedViewCollection. Consequently the
normal "evict view -> destroy its view scope" path never ran for such a view,
and any @ViewScoped beans created while rendering it lingered in the session
(ViewScopeContextualStorageHolder) until the session expired. Requesting such a
page repeatedly within one session therefore leaked one view-scope storage per
request, unbounded.

FaceletViewDeclarationLanguage.renderView now destroys the view scope of a
non-transient view whose state was not written, at the end of the request,
publishing PreDestroyViewMapEvent and destroying its (CDI or non-CDI)
@ViewScoped storage.

Add ViewScopeFormlessLeakTestCase: rendering a formless page bound to a
@ViewScoped bean N times now destroys the bean N times instead of accumulating
it in the session.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
melloware added a commit that referenced this pull request May 29, 2026
…ved (#995) (#996)

A view rendered without writing its state - e.g. a page with no UIForm, so no
view state token is emitted - can never be restored, and unlike a saved view it
is never registered in the session SerializedViewCollection. Consequently the
normal "evict view -> destroy its view scope" path never ran for such a view,
and any @ViewScoped beans created while rendering it lingered in the session
(ViewScopeContextualStorageHolder) until the session expired. Requesting such a
page repeatedly within one session therefore leaked one view-scope storage per
request, unbounded.

FaceletViewDeclarationLanguage.renderView now destroys the view scope of a
non-transient view whose state was not written, at the end of the request,
publishing PreDestroyViewMapEvent and destroying its (CDI or non-CDI)
@ViewScoped storage.

Add ViewScopeFormlessLeakTestCase: rendering a formless page bound to a
@ViewScoped bean N times now destroys the bean N times instead of accumulating
it in the session.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@BalusC BalusC deleted the fix_issue_4751 branch May 29, 2026 22:20
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.

3 participants