From 5d27b59e7500f397749ed5dd5cbf4f1d7c572ea4 Mon Sep 17 00:00:00 2001 From: Ludovic Champenois Date: Wed, 10 Jun 2026 11:19:48 +0200 Subject: [PATCH] Fix negative maxDepth in Jetty 12.1 fullScan hot-reload scanner - Resolves IllegalArgumentException on startup by setting scan depth to Integer.MAX_VALUE (which represents unlimited depth in Jetty 12.1) instead of the invalid negative -1 value. - Applies the fix to both EE8 and EE11 configurations to ensure both support scanning deeply nested directories. - Added e2e test (DevAppServerFullScanTest) that verifies the server boots without exceptions and successfully reloads when a nested resource file (> 3 levels deep) changes. - Verified that on the original code, the EE8 server crashes on startup, and the EE11 server fails to trigger reloads for changes nested deeper than 3 directory levels. --- .../development/DevAppServerFullScanTest.java | 81 +++++++++++++++++++ .../development/DevAppServerTestBase.java | 34 +++++++- .../jetty/JettyContainerService.java | 2 +- .../jetty/ee11/JettyContainerService.java | 2 +- 4 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 e2etests/devappservertests/src/test/java/com/google/appengine/tools/development/DevAppServerFullScanTest.java diff --git a/e2etests/devappservertests/src/test/java/com/google/appengine/tools/development/DevAppServerFullScanTest.java b/e2etests/devappservertests/src/test/java/com/google/appengine/tools/development/DevAppServerFullScanTest.java new file mode 100644 index 00000000..50fe30b4 --- /dev/null +++ b/e2etests/devappservertests/src/test/java/com/google/appengine/tools/development/DevAppServerFullScanTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed 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 + * + * https://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 com.google.appengine.tools.development; + +import com.google.common.collect.ImmutableList; +import java.io.File; +import java.io.IOException; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +@RunWith(Parameterized.class) +public class DevAppServerFullScanTest extends DevAppServerTestBase { + + public DevAppServerFullScanTest(String runtimeVersion, String jettyVersion, String jakartaVersion) { + super(runtimeVersion, jettyVersion, jakartaVersion); + } + + private File appDir; + + @Before + public void setUpClass() throws IOException, InterruptedException { + appDir = + Boolean.getBoolean("appengine.use.EE10") || Boolean.getBoolean("appengine.use.EE11") + ? createApp("allinone_jakarta") + : createApp("allinone"); + setUpClass(appDir); + } + + @Override + protected List getExtraJvmArgs() { + return ImmutableList.of("-Dappengine.fullscan.seconds=1"); + } + + @Test + public void testFullScanStartAndReload() throws Exception { + // Basic request to ensure server is running and hot reload scanner starts up fine + executeHttpGet("/?memcache_loops=1&memcache_size=1", "Running memcache for 1 loops with value size 1\nCache hits: 1\nCache misses: 0\n", RESPONSE_200); + + // Clear logs to ensure we only search for the subsequent reload event + serverLogs.clear(); + + // Touch web.xml to trigger a reload + File webXml = new File(appDir, "WEB-INF/web.xml"); + com.google.common.truth.Truth.assertThat(webXml.exists()).isTrue(); + long oldLastModified = webXml.lastModified(); + long newTime = oldLastModified + 2000; + boolean modified = webXml.setLastModified(newTime); + com.google.common.truth.Truth.assertThat(modified).isTrue(); + + // Verify that the hot-reload scanner initiates a reload + boolean reloaded = awaitLogContains("A file has changed, reloading the web application.", 10); + com.google.common.truth.Truth.assertThat(reloaded).isTrue(); + + // Clear logs again to test a deeply nested resource file (depth > 3) + serverLogs.clear(); + + File nestedClassFile = new File(appDir, "WEB-INF/classes/allinone/deeper/package/test/Dummy.properties"); + nestedClassFile.getParentFile().mkdirs(); + java.nio.file.Files.write(nestedClassFile.toPath(), new byte[]{0, 1, 2, 3}); + + // Verify that the hot-reload scanner initiates a reload for the deeply nested resource file + boolean nestedReloaded = awaitLogContains("A file has changed, reloading the web application.", 10); + com.google.common.truth.Truth.assertThat(nestedReloaded).isTrue(); + } +} diff --git a/e2etests/devappservertests/src/test/java/com/google/appengine/tools/development/DevAppServerTestBase.java b/e2etests/devappservertests/src/test/java/com/google/appengine/tools/development/DevAppServerTestBase.java index e50670a1..08423e23 100644 --- a/e2etests/devappservertests/src/test/java/com/google/appengine/tools/development/DevAppServerTestBase.java +++ b/e2etests/devappservertests/src/test/java/com/google/appengine/tools/development/DevAppServerTestBase.java @@ -48,6 +48,7 @@ public abstract class DevAppServerTestBase { int jettyPort; private Process runtimeProc; private CountDownLatch serverStarted; + protected final List serverLogs = java.util.Collections.synchronizedList(new ArrayList<>()); static final int NUMBER_OF_RETRIES = 5; @@ -177,6 +178,7 @@ public void setUpClass(File appDir) throws IOException, InterruptedException { runtimeArgs.add("-Dappengine.use.EE10=" + System.getProperty("appengine.use.EE10")); runtimeArgs.add("-Dappengine.use.EE11=" + System.getProperty("appengine.use.EE11")); runtimeArgs.add("-Dappengine.use.jetty121=" + System.getProperty("appengine.use.jetty121")); + runtimeArgs.addAll(getExtraJvmArgs()); runtimeArgs.add("-cp"); runtimeArgs.add(TOOLS_JAR); runtimeArgs.add("com.google.appengine.tools.development.DevAppServerMain"); @@ -185,10 +187,19 @@ public void setUpClass(File appDir) throws IOException, InterruptedException { runtimeArgs.add("--allow_remote_shutdown"); // Keep as used in Maven plugin runtimeArgs.add("--disable_update_check"); // Keep, as used in Maven plugin + runtimeArgs.addAll(getExtraAppArgs()); runtimeArgs.add(appDir.toString()); createRuntime(ImmutableList.copyOf(runtimeArgs), ImmutableMap.of(), jettyPort); } + protected List getExtraJvmArgs() { + return ImmutableList.of(); + } + + protected List getExtraAppArgs() { + return ImmutableList.of(); + } + void createRuntime( ImmutableList runtimeArgs, ImmutableMap extraEnvironmentEntries, @@ -221,8 +232,8 @@ private Process launchRuntime( pb.environment().putAll(extraEnvironmentEntries); Process process = pb.start(); - OutputPump outPump = new OutputPump(process.getInputStream(), serverStarted); - OutputPump errPump = new OutputPump(process.getErrorStream(), serverStarted); + OutputPump outPump = new OutputPump(process.getInputStream(), serverStarted, serverLogs); + OutputPump errPump = new OutputPump(process.getErrorStream(), serverStarted, serverLogs); new Thread(outPump).start(); new Thread(errPump).start(); if (!serverStarted.await(120, TimeUnit.SECONDS)) { @@ -284,12 +295,28 @@ void executeHttpGetWithRetriesContains( assertThat(retCode).isEqualTo(expectedReturnCode); } + protected boolean awaitLogContains(String expected, int timeoutSeconds) throws InterruptedException { + for (int i = 0; i < timeoutSeconds * 10; i++) { + synchronized (serverLogs) { + for (String logLine : serverLogs) { + if (logLine.contains(expected)) { + return true; + } + } + } + Thread.sleep(100); + } + return false; + } + private static class OutputPump implements Runnable { private final BufferedReader stream; private final CountDownLatch serverStarted; + private final List serverLogs; - public OutputPump(InputStream instream, CountDownLatch serverStarted) { + public OutputPump(InputStream instream, CountDownLatch serverStarted, List serverLogs) { this.serverStarted = serverStarted; + this.serverLogs = serverLogs; this.stream = new BufferedReader(new InputStreamReader(instream, UTF_8)); } @@ -299,6 +326,7 @@ public void run() { try { while ((line = stream.readLine()) != null) { System.out.println(line); + serverLogs.add(line); if (line.contains("INFO: Dev App Server is now running")) { serverStarted.countDown(); } diff --git a/runtime/local_jetty121/src/main/java/com/google/appengine/tools/development/jetty/JettyContainerService.java b/runtime/local_jetty121/src/main/java/com/google/appengine/tools/development/jetty/JettyContainerService.java index 0281e049..ff4be0db 100644 --- a/runtime/local_jetty121/src/main/java/com/google/appengine/tools/development/jetty/JettyContainerService.java +++ b/runtime/local_jetty121/src/main/java/com/google/appengine/tools/development/jetty/JettyContainerService.java @@ -513,7 +513,7 @@ private void fullWebAppScanner(int interval) throws IOException { scanner.setScanInterval(interval); scanner.setScanDirs(scanList); scanner.setReportExistingFilesOnStartup(false); - scanner.setScanDepth(-1); // -1 means unlimited depth. + scanner.setScanDepth(Integer.MAX_VALUE); // Integer.MAX_VALUE means unlimited depth. scanner.addListener( new Scanner.BulkListener() { diff --git a/runtime/local_jetty121_ee11/src/main/java/com/google/appengine/tools/development/jetty/ee11/JettyContainerService.java b/runtime/local_jetty121_ee11/src/main/java/com/google/appengine/tools/development/jetty/ee11/JettyContainerService.java index 0600c6b3..5f040c6e 100644 --- a/runtime/local_jetty121_ee11/src/main/java/com/google/appengine/tools/development/jetty/ee11/JettyContainerService.java +++ b/runtime/local_jetty121_ee11/src/main/java/com/google/appengine/tools/development/jetty/ee11/JettyContainerService.java @@ -507,7 +507,7 @@ private void fullWebAppScanner(int interval) throws IOException { scanner.setScanInterval(interval); scanner.setScanDirs(scanList); scanner.setReportExistingFilesOnStartup(false); - scanner.setScanDepth(3); + scanner.setScanDepth(Integer.MAX_VALUE); // Integer.MAX_VALUE means unlimited depth. scanner.addListener( new Scanner.BulkListener() {