Merge branch '4.0.x'

Closes gh-48701
This commit is contained in:
Andy Wilkinson
2026-01-08 12:14:50 +00:00
212 changed files with 2601 additions and 356 deletions
@@ -0,0 +1,20 @@
/*
* Copyright 2012-present the original author or authors.
*
* 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.
*/
@NullMarked
package com.example;
import org.jspecify.annotations.NullMarked;
@@ -27,11 +27,13 @@ import net.bytebuddy.description.modifier.Visibility;
import net.bytebuddy.dynamic.DynamicType;
import net.bytebuddy.implementation.FixedValue;
import org.awaitility.Awaitility;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.testsupport.BuildOutput;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -50,32 +52,39 @@ abstract class AbstractDevToolsIntegrationTests {
protected final JvmLauncher javaLauncher = new JvmLauncher();
@TempDir
@SuppressWarnings("NullAway.Init")
protected static File temp;
protected LaunchedApplication launchedApplication;
private @Nullable LaunchedApplication launchedApplication;
protected void launchApplication(ApplicationLauncher applicationLauncher, String... args) throws Exception {
protected LaunchedApplication launchApplication(ApplicationLauncher applicationLauncher, String... args)
throws Exception {
this.serverPortFile.delete();
this.launchedApplication = applicationLauncher.launchApplication(this.javaLauncher, this.serverPortFile, args);
return this.launchedApplication;
}
@AfterEach
void stopApplication() throws InterruptedException {
Assert.notNull(this.launchedApplication, "Application has not been launched");
this.launchedApplication.stop();
}
protected int awaitServerPort() throws Exception {
LaunchedApplication launchedApplication = this.launchedApplication;
Assert.notNull(launchedApplication, "Application has not been launched");
int port = Awaitility.waitAtMost(Duration.ofMinutes(3))
.until(() -> new ApplicationState(this.serverPortFile, this.launchedApplication),
.until(() -> new ApplicationState(this.serverPortFile, launchedApplication),
ApplicationState::hasServerPort)
.getServerPort();
this.serverPortFile.delete();
this.launchedApplication.restartRemote(port);
launchedApplication.restartRemote(port);
Thread.sleep(1000);
return port;
}
protected ControllerBuilder controller(String name) {
Assert.notNull(this.launchedApplication, "Application has not been launched");
return new ControllerBuilder(name, this.launchedApplication.getClassesDirectory());
}
@@ -19,7 +19,10 @@ package org.springframework.boot.devtools.tests;
import java.io.File;
import java.time.Instant;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.devtools.tests.JvmLauncher.LaunchedJvm;
import org.springframework.util.Assert;
/**
* State of an application.
@@ -30,7 +33,7 @@ final class ApplicationState {
private final Instant launchTime;
private final Integer serverPort;
private final @Nullable Integer serverPort;
private final FileContents out;
@@ -56,6 +59,7 @@ final class ApplicationState {
}
int getServerPort() {
Assert.notNull(this.serverPort, "No server port is available");
return this.serverPort;
}
@@ -125,10 +125,10 @@ class DevToolsIntegrationTests extends AbstractDevToolsIntegrationTests {
@ParameterizedTest(name = "{0}")
@MethodSource("parameters")
void deleteAController(ApplicationLauncher applicationLauncher) throws Exception {
launchApplication(applicationLauncher);
LaunchedApplication launchedApplication = launchApplication(applicationLauncher);
String urlBase = "http://localhost:" + awaitServerPort();
assertThat(this.template.getForObject(urlBase + "/one", String.class)).isEqualTo("one");
assertThat(new File(this.launchedApplication.getClassesDirectory(), "com/example/ControllerOne.class").delete())
assertThat(new File(launchedApplication.getClassesDirectory(), "com/example/ControllerOne.class").delete())
.isTrue();
urlBase = "http://localhost:" + awaitServerPort();
assertThat(this.template.getForEntity(urlBase + "/one", String.class).getStatusCode())
@@ -139,7 +139,7 @@ class DevToolsIntegrationTests extends AbstractDevToolsIntegrationTests {
@ParameterizedTest(name = "{0}")
@MethodSource("parameters")
void createAControllerAndThenDeleteIt(ApplicationLauncher applicationLauncher) throws Exception {
launchApplication(applicationLauncher);
LaunchedApplication launchedApplication = launchApplication(applicationLauncher);
String urlBase = "http://localhost:" + awaitServerPort();
assertThat(this.template.getForObject(urlBase + "/one", String.class)).isEqualTo("one");
assertThat(this.template.getForEntity(urlBase + "/two", String.class).getStatusCode())
@@ -148,7 +148,7 @@ class DevToolsIntegrationTests extends AbstractDevToolsIntegrationTests {
urlBase = "http://localhost:" + awaitServerPort();
assertThat(this.template.getForObject(urlBase + "/one", String.class)).isEqualTo("one");
assertThat(this.template.getForObject(urlBase + "/two", String.class)).isEqualTo("two");
assertThat(new File(this.launchedApplication.getClassesDirectory(), "com/example/ControllerTwo.class").delete())
assertThat(new File(launchedApplication.getClassesDirectory(), "com/example/ControllerTwo.class").delete())
.isTrue();
urlBase = "http://localhost:" + awaitServerPort();
assertThat(this.template.getForEntity(urlBase + "/two", String.class).getStatusCode())
@@ -21,6 +21,8 @@ import java.io.FileReader;
import java.io.IOException;
import java.util.function.Function;
import org.jspecify.annotations.Nullable;
import org.springframework.util.FileCopyUtils;
/**
@@ -36,11 +38,11 @@ class FileContents {
this.file = file;
}
String get() {
@Nullable String get() {
return get(Function.identity());
}
<T> T get(Function<String, T> transformer) {
<T> @Nullable T get(Function<String, T> transformer) {
if ((!this.file.exists()) || this.file.length() == 0) {
return null;
}
@@ -53,7 +55,7 @@ class FileContents {
}
@Override
public String toString() {
public @Nullable String toString() {
return get();
}
@@ -24,6 +24,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.Extension;
import org.junit.jupiter.api.extension.ExtensionContext;
@@ -43,7 +44,7 @@ class JvmLauncher implements BeforeTestExecutionCallback {
private final BuildOutput buildOutput = new BuildOutput(getClass());
private File outputDirectory;
private @Nullable File outputDirectory;
@Override
public void beforeTestExecution(ExtensionContext context) throws Exception {
@@ -20,6 +20,8 @@ import java.io.File;
import java.time.Instant;
import java.util.function.BiFunction;
import org.jspecify.annotations.Nullable;
/**
* An application launched by {@link ApplicationLauncher}.
*
@@ -35,14 +37,14 @@ class LaunchedApplication {
private final Process localProcess;
private Process remoteProcess;
private @Nullable Process remoteProcess;
private final Instant launchTime = Instant.now();
private final BiFunction<Integer, File, Process> remoteProcessRestarter;
private final @Nullable BiFunction<Integer, File, Process> remoteProcessRestarter;
LaunchedApplication(File classesDirectory, File standardOut, File standardError, Process localProcess,
Process remoteProcess, BiFunction<Integer, File, Process> remoteProcessRestarter) {
@Nullable Process remoteProcess, @Nullable BiFunction<Integer, File, Process> remoteProcessRestarter) {
this.classesDirectory = classesDirectory;
this.standardOut = standardOut;
this.standardError = standardError;
@@ -63,7 +65,7 @@ class LaunchedApplication {
stop(this.remoteProcess);
}
private void stop(Process process) throws InterruptedException {
private void stop(@Nullable Process process) throws InterruptedException {
if (process != null) {
process.destroy();
process.waitFor();
@@ -0,0 +1,20 @@
/*
* Copyright 2012-present the original author or authors.
*
* 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.
*/
@NullMarked
package org.springframework.boot.devtools.tests;
import org.jspecify.annotations.NullMarked;
@@ -24,6 +24,8 @@ import java.util.Deque;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import org.jspecify.annotations.Nullable;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -105,11 +107,11 @@ public class MockClientHttpRequestFactory implements ClientHttpRequestFactory {
private final int delay;
private final byte[] payload;
private final byte @Nullable [] payload;
private final HttpStatus status;
Response(int delay, byte[] payload, HttpStatus status) {
Response(int delay, byte @Nullable [] payload, HttpStatus status) {
this.delay = delay;
this.payload = payload;
this.status = status;
@@ -0,0 +1,20 @@
/*
* Copyright 2012-present the original author or authors.
*
* 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.
*/
@NullMarked
package org.springframework.boot.devtools.test;
import org.jspecify.annotations.NullMarked;
@@ -18,6 +18,8 @@ package org.springframework.boot.loader.launch;
import java.util.function.Consumer;
import org.jspecify.annotations.Nullable;
/**
* Fake launcher in the {@code org.springframework.boot.loader.launch} package used in
* {@code MainMethodTests}.
@@ -26,13 +28,15 @@ import java.util.function.Consumer;
*/
public final class FakeJarLauncher {
public static Consumer<String[]> action;
public static @Nullable Consumer<String[]> action;
private FakeJarLauncher() {
}
public static void main(String... args) {
action.accept(args);
if (action != null) {
action.accept(args);
}
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2012-present the original author or authors.
*
* 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.
*/
@NullMarked
package org.springframework.boot.loader.launch;
import org.jspecify.annotations.NullMarked;