From 13040ebe19dca049b5241289b5e85cb685e64f89 Mon Sep 17 00:00:00 2001 From: melloware Date: Fri, 29 May 2026 14:54:44 -0400 Subject: [PATCH] MYFACES-4751 5.0 Destroy @ViewScoped beans of views that are never saved (#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) --- .../FaceletViewDeclarationLanguage.java | 18 +++++ .../ViewScopeFormlessLeakTestCase.java | 74 +++++++++++++++++++ .../viewscope/ViewScopeLeakProbeBean.java | 62 ++++++++++++++++ .../viewscope/formlessViewScoped.xhtml | 29 ++++++++ 4 files changed, 183 insertions(+) create mode 100644 impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeFormlessLeakTestCase.java create mode 100644 impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeLeakProbeBean.java create mode 100644 impl/src/test/resources/org/apache/myfaces/view/facelets/viewscope/formlessViewScoped.xhtml diff --git a/impl/src/main/java/org/apache/myfaces/view/facelets/FaceletViewDeclarationLanguage.java b/impl/src/main/java/org/apache/myfaces/view/facelets/FaceletViewDeclarationLanguage.java index 022a09c5a..107107c91 100644 --- a/impl/src/main/java/org/apache/myfaces/view/facelets/FaceletViewDeclarationLanguage.java +++ b/impl/src/main/java/org/apache/myfaces/view/facelets/FaceletViewDeclarationLanguage.java @@ -99,6 +99,7 @@ import org.apache.myfaces.core.api.shared.lang.Assert; import org.apache.myfaces.view.ViewDeclarationLanguageStrategy; import org.apache.myfaces.view.ViewMetadataBase; +import org.apache.myfaces.view.ViewScopeProxyMap; import org.apache.myfaces.view.facelets.compiler.Compiler; import org.apache.myfaces.view.facelets.compiler.SAXCompiler; import org.apache.myfaces.view.facelets.el.CompositeComponentELUtils; @@ -1897,6 +1898,23 @@ else if (stateWriter.isStateWrittenWithoutWrapper()) } } } + + // This view was rendered without writing its state (no UIForm, hence no + // view state token), so it can never be restored. Any @ViewScoped beans + // created while building it are unreachable once this request ends, but - + // unlike views whose state is saved - it is never registered in the session + // SerializedViewCollection, so the normal "evict view -> destroy its view + // scope" path never runs and the beans would linger until the session + // expires. Destroy the view scope now (publishes PreDestroyViewMapEvent). + if (!view.isTransient()) + { + Map viewMap = view.getViewMap(false); + if (viewMap instanceof ViewScopeProxyMap + && ((ViewScopeProxyMap) viewMap).getViewScopeId() != null) + { + viewMap.clear(); + } + } } } } diff --git a/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeFormlessLeakTestCase.java b/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeFormlessLeakTestCase.java new file mode 100644 index 000000000..49dcbf374 --- /dev/null +++ b/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeFormlessLeakTestCase.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.myfaces.view.facelets.viewscope; + +import jakarta.el.ExpressionFactory; +import jakarta.faces.application.ProjectStage; + +import org.apache.myfaces.test.core.AbstractMyFacesCDIRequestTestCase; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Regression test for the @ViewScoped leak on formless views. + * + *

A view without any {@code UIForm} never has its state saved, so it can never be restored and + * is never registered in the session {@code SerializedViewCollection}. Repeatedly requesting such a + * view that references a {@code @ViewScoped} bean must not accumulate view scopes in the session: + * the scope has to be destroyed at the end of each request. Before the fix the beans were never + * destroyed and piled up in the session (a memory leak), so {@code DESTROYED} stayed at 0.

+ */ +public class ViewScopeFormlessLeakTestCase extends AbstractMyFacesCDIRequestTestCase +{ + + protected ExpressionFactory createExpressionFactory() + { + return new org.apache.el.ExpressionFactoryImpl(); + } + + @Override + protected void setUpWebConfigParams() throws Exception + { + super.setUpWebConfigParams(); + servletContext.addInitParameter("org.apache.myfaces.annotation.SCAN_PACKAGES", + "org.apache.myfaces.view.facelets.viewscope"); + servletContext.addInitParameter(ProjectStage.PROJECT_STAGE_PARAM_NAME, "Production"); + } + + @Test + public void testFormlessViewDoesNotLeakViewScopedBeans() throws Exception + { + ViewScopeLeakProbeBean.reset(); + + int requests = 5; + for (int i = 0; i < requests; i++) + { + startViewRequest("/formlessViewScoped.xhtml"); + processLifecycleExecute(); + renderResponse(); + endRequest(); + } + + Assertions.assertEquals(requests, ViewScopeLeakProbeBean.CREATED.get(), + "the @ViewScoped bean must be created once per request"); + Assertions.assertEquals(requests, ViewScopeLeakProbeBean.DESTROYED.get(), + "the @ViewScoped beans of a formless (never-saved) view must be destroyed each " + + "request, not leaked into the session"); + } +} diff --git a/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeLeakProbeBean.java b/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeLeakProbeBean.java new file mode 100644 index 000000000..0250c1079 --- /dev/null +++ b/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeLeakProbeBean.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.myfaces.view.facelets.viewscope; + +import java.io.Serializable; +import java.util.concurrent.atomic.AtomicInteger; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.faces.view.ViewScoped; +import jakarta.inject.Named; + +/** + * A {@code @ViewScoped} CDI bean that counts its creations and destructions, used to assert that + * the view scope of a formless (never-saved) view is destroyed at the end of the request instead + * of lingering in the session. + */ +@Named("viewScopeLeakProbeBean") +@ViewScoped +public class ViewScopeLeakProbeBean implements Serializable +{ + public static final AtomicInteger CREATED = new AtomicInteger(); + public static final AtomicInteger DESTROYED = new AtomicInteger(); + + public static void reset() + { + CREATED.set(0); + DESTROYED.set(0); + } + + @PostConstruct + public void created() + { + CREATED.incrementAndGet(); + } + + @PreDestroy + public void destroyed() + { + DESTROYED.incrementAndGet(); + } + + public String getValue() + { + return "view-scoped"; + } +} diff --git a/impl/src/test/resources/org/apache/myfaces/view/facelets/viewscope/formlessViewScoped.xhtml b/impl/src/test/resources/org/apache/myfaces/view/facelets/viewscope/formlessViewScoped.xhtml new file mode 100644 index 000000000..1aa0bf192 --- /dev/null +++ b/impl/src/test/resources/org/apache/myfaces/view/facelets/viewscope/formlessViewScoped.xhtml @@ -0,0 +1,29 @@ + + + + + + Formless page referencing a @ViewScoped bean + + + + + +