From 52db9bda8dc4942398a75b7f7865bef12160c17f Mon Sep 17 00:00:00 2001 From: Reed von Redwitz Date: Tue, 7 Apr 2026 13:28:45 +0200 Subject: [PATCH] fix: address five latent issues and remove stale BUG test comments --- .../AbstractTemplatedLauncher.java | 9 +- .../AbstractTemplatedPlatform.java | 2 +- .../command/JsonNodeInputStreamProcessor.java | 13 +- .../docker/okhttp/command/KillContainer.java | 12 +- .../okhttp/CompletableFuturesNamingTests.java | 7 - .../command/KillContainerRequestTests.java | 172 ++++++++++++++++++ .../model/ContainerInformationTests.java | 4 - .../build/spawn/docker/option/KillSignal.java | 67 +++++++ .../build/spawn/docker/ImagesBuildTests.java | 5 - .../spawn/docker/option/PublishPortTests.java | 7 - .../agent/SpawnAgentArgumentParsingTests.java | 14 -- .../build/spawn/jdk/option/HeadlessTests.java | 4 - .../platform/local/jdk/LocalJDKLauncher.java | 18 +- .../JDKHomeBasedPatternDetectorLogTests.java | 6 - .../local/LocalMachineCloseTests.java | 39 ++++ 15 files changed, 322 insertions(+), 57 deletions(-) create mode 100644 spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/command/KillContainerRequestTests.java create mode 100644 spawn-docker/src/main/java/build/spawn/docker/option/KillSignal.java create mode 100644 spawn-local-platform/src/test/java/build/spawn/application/local/LocalMachineCloseTests.java diff --git a/spawn-application/src/main/java/build/spawn/application/AbstractTemplatedLauncher.java b/spawn-application/src/main/java/build/spawn/application/AbstractTemplatedLauncher.java index 312a91e..dbd2e61 100644 --- a/spawn-application/src/main/java/build/spawn/application/AbstractTemplatedLauncher.java +++ b/spawn-application/src/main/java/build/spawn/application/AbstractTemplatedLauncher.java @@ -24,6 +24,7 @@ import build.base.configuration.Configuration; import build.base.configuration.ConfigurationBuilder; import build.base.foundation.Introspection; +import build.base.logging.Logger; import build.base.naming.UniqueNameGenerator; import build.base.option.TemporaryDirectory; import build.base.option.WorkingDirectory; @@ -69,6 +70,11 @@ public abstract class AbstractTemplatedLauncher implements TemplatedLauncher { + /** + * The {@link Logger}. + */ + private static final Logger LOGGER = Logger.get(AbstractTemplatedLauncher.class); + /** * A {@link UniqueNameGenerator} to generate unique {@link Application} names. */ @@ -295,7 +301,8 @@ public Dependency dependency() { }); } catch (final ClassNotFoundException e) { - // TODO: log the fact that the Class can't be found + LOGGER.debug("Could not load class [{0}] for Iterable injection resolution", + namedTypeUsage.typeName().canonicalName(), e); } } } diff --git a/spawn-application/src/main/java/build/spawn/application/AbstractTemplatedPlatform.java b/spawn-application/src/main/java/build/spawn/application/AbstractTemplatedPlatform.java index c85b24f..feab074 100644 --- a/spawn-application/src/main/java/build/spawn/application/AbstractTemplatedPlatform.java +++ b/spawn-application/src/main/java/build/spawn/application/AbstractTemplatedPlatform.java @@ -267,7 +267,7 @@ public InjectionFramework injectionFramework() { @Override public void close() { - // TODO: ensure any asynchronous tasks are shutdown? + this.server.close(); } @Override diff --git a/spawn-docker-okhttp/src/main/java/build/spawn/docker/okhttp/command/JsonNodeInputStreamProcessor.java b/spawn-docker-okhttp/src/main/java/build/spawn/docker/okhttp/command/JsonNodeInputStreamProcessor.java index 5e38a25..61dc0ec 100644 --- a/spawn-docker-okhttp/src/main/java/build/spawn/docker/okhttp/command/JsonNodeInputStreamProcessor.java +++ b/spawn-docker-okhttp/src/main/java/build/spawn/docker/okhttp/command/JsonNodeInputStreamProcessor.java @@ -32,6 +32,8 @@ import java.io.InputStream; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; /** * An {@link InputStreamProcessor} for {@link JsonNode}s. @@ -42,6 +44,11 @@ public class JsonNodeInputStreamProcessor implements InputStreamProcessor { + /** + * The {@link Logger}. + */ + private static final Logger LOG = Logger.getLogger(JsonNodeInputStreamProcessor.class.getName()); + /** * The {@link JsonFactory} to produce {@link JsonParser}s. */ @@ -101,13 +108,13 @@ public void cancel() { final JsonNode jsonNode = this.objectMapper.readTree(parser); subscriber.onNext(jsonNode); } catch (final Throwable throwable) { - // TODO: log the throwable + LOG.log(Level.FINE, "Failed to read or deliver a JSON node from the stream", throwable); failed = true; subscriber.onError(throwable); } } } catch (final Throwable throwable) { - // TODO: log the throwable + LOG.log(Level.FINE, "Failed while processing the JSON input stream", throwable); if (!failed) { subscriber.onError(throwable); } @@ -119,7 +126,7 @@ public void cancel() { inputStream.close(); } catch (final IOException e) { - // TODO: log the throwable + LOG.log(Level.FINE, "Failed to close the JSON input stream", e); } } } diff --git a/spawn-docker-okhttp/src/main/java/build/spawn/docker/okhttp/command/KillContainer.java b/spawn-docker-okhttp/src/main/java/build/spawn/docker/okhttp/command/KillContainer.java index 96b7cdb..1ca9740 100644 --- a/spawn-docker-okhttp/src/main/java/build/spawn/docker/okhttp/command/KillContainer.java +++ b/spawn-docker-okhttp/src/main/java/build/spawn/docker/okhttp/command/KillContainer.java @@ -22,6 +22,7 @@ import build.base.configuration.Configuration; import build.spawn.docker.Container; +import build.spawn.docker.option.KillSignal; import jakarta.inject.Inject; import okhttp3.HttpUrl; import okhttp3.Request; @@ -45,24 +46,33 @@ public class KillContainer @Inject private Container container; + /** + * The {@link Configuration} for the {@link Command}. + */ + private final Configuration configuration; + /** * Constructs a {@link KillContainer} {@link Command}. * * @param configuration the {@link Configuration} to kill the {@link Container} */ public KillContainer(final Configuration configuration) { + this.configuration = configuration == null + ? Configuration.empty() + : configuration; } @Override protected Request createRequest(final HttpUrl.Builder httpUrlBuilder) { - // TODO: include the SigTerm for stopping + final KillSignal signal = this.configuration.get(KillSignal.class); return new Request.Builder() .url(httpUrlBuilder .addPathSegment("containers") .addPathSegment(this.container.id()) .addPathSegment("kill") + .addQueryParameter("signal", signal.signalName()) .build()) .post(EMPTY_BODY) .build(); diff --git a/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/CompletableFuturesNamingTests.java b/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/CompletableFuturesNamingTests.java index 2d0bea4..cf10247 100644 --- a/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/CompletableFuturesNamingTests.java +++ b/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/CompletableFuturesNamingTests.java @@ -14,16 +14,9 @@ class CompletableFuturesNamingTests { /** * Ensure the {@code CompletableFutures} utility class is spelled correctly. - *

- * BUG: The class is currently named {@code CompleteableFutures} (extra {@code e} after - * {@code Complet}) instead of the standard Java spelling {@code CompletableFutures}, - * which matches {@link java.util.concurrent.CompletableFuture}. Any caller that attempts - * to import or reference the class by its correct name will receive a compile or runtime - * error. */ @Test void classNameShouldBeSpelledCorrectly() { - // Class.forName will throw ClassNotFoundException because only "CompleteableFutures" exists assertThatCode(() -> Class.forName("build.spawn.docker.okhttp.CompletableFutures")) .doesNotThrowAnyException(); } diff --git a/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/command/KillContainerRequestTests.java b/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/command/KillContainerRequestTests.java new file mode 100644 index 0000000..9e6a7d6 --- /dev/null +++ b/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/command/KillContainerRequestTests.java @@ -0,0 +1,172 @@ +package build.spawn.docker.okhttp.command; + +import build.base.configuration.Configuration; +import build.base.io.Terminal; +import build.spawn.docker.Container; +import build.spawn.docker.Executable; +import build.spawn.docker.Image; +import build.spawn.docker.option.Command; +import build.spawn.docker.option.KillSignal; +import okhttp3.HttpUrl; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the HTTP request built by {@link KillContainer}. + * + * @author reed.vonredwitz + * @since Apr-2026 + */ +class KillContainerRequestTests { + + /** + * A minimal {@link Container} stub that returns a fixed id and throws + * {@link UnsupportedOperationException} for all other methods. + */ + private static final class StubContainer + implements Container { + + private final String id; + + StubContainer(final String id) { + this.id = id; + } + + @Override + public String id() { + return this.id; + } + + @Override + public Image image() { + throw new UnsupportedOperationException(); + } + + @Override + public Configuration configuration() { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture onStart() { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture onExit() { + throw new UnsupportedOperationException(); + } + + @Override + public OptionalInt exitValue() { + throw new UnsupportedOperationException(); + } + + @Override + public Terminal attach(final Configuration configuration) { + throw new UnsupportedOperationException(); + } + + @Override + public Executable createExecutable(final Command command) { + throw new UnsupportedOperationException(); + } + + @Override + public void remove(final Configuration configuration) { + throw new UnsupportedOperationException(); + } + + @Override + public void copyFiles(final Path archivePath, + final String destinationDirectory, + final Path... filesToCopy) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional> fileInformation(final Path filePath) { + throw new UnsupportedOperationException(); + } + + @Override + public void stop(final Configuration configuration) { + throw new UnsupportedOperationException(); + } + + @Override + public void kill(final Configuration configuration) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture pause() { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture unpause() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional inspect() { + throw new UnsupportedOperationException(); + } + } + + /** + * Injects a {@link StubContainer} into the {@link KillContainer}'s {@code container} field. + * + * @param killCommand the command to inject into + * @param containerId the container id the stub should return + * @throws Exception if reflection fails + */ + private static void injectContainer(final KillContainer killCommand, final String containerId) + throws Exception { + + final Field field = KillContainer.class.getDeclaredField("container"); + field.setAccessible(true); + field.set(killCommand, new StubContainer(containerId)); + } + + /** + * Ensure the kill request includes a {@code signal} query parameter matching the + * {@link KillSignal} in the configuration. + */ + @Test + void requestShouldIncludeConfiguredSignal() throws Exception { + final var command = new KillContainer(Configuration.of(KillSignal.SIGTERM)); + + injectContainer(command, "abc123"); + + final var url = HttpUrl.parse("http://localhost/"); + final var request = command.createRequest(url.newBuilder()); + + assertThat(request.url().queryParameter("signal")).isEqualTo("SIGTERM"); + } + + /** + * Ensure the default signal is {@code SIGKILL} when no {@link KillSignal} is configured, + * preserving Docker's default kill behaviour. + */ + @Test + void requestShouldDefaultToSigkill() throws Exception { + final var command = new KillContainer(Configuration.empty()); + + injectContainer(command, "abc123"); + + final var url = HttpUrl.parse("http://localhost/"); + final var request = command.createRequest(url.newBuilder()); + + assertThat(request.url().queryParameter("signal")).isEqualTo("SIGKILL"); + } +} diff --git a/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/model/ContainerInformationTests.java b/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/model/ContainerInformationTests.java index 1e3abc7..4896853 100644 --- a/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/model/ContainerInformationTests.java +++ b/spawn-docker-okhttp/src/test/java/build/spawn/docker/okhttp/model/ContainerInformationTests.java @@ -18,10 +18,6 @@ class ContainerInformationTests { /** * Ensure that {@link ContainerInformation#name()} returns the plain container name * without JSON quote characters. - *

- * BUG: {@link ContainerInformation#name()} calls {@code jsonNode().get("Name").toString()}, - * which returns the JSON representation of the text node (e.g. {@code "\"/my-container\""}) - * rather than the raw text value. It should call {@code asText()} instead. * * @throws Exception if reflection-based field injection fails */ diff --git a/spawn-docker/src/main/java/build/spawn/docker/option/KillSignal.java b/spawn-docker/src/main/java/build/spawn/docker/option/KillSignal.java new file mode 100644 index 0000000..c5d9645 --- /dev/null +++ b/spawn-docker/src/main/java/build/spawn/docker/option/KillSignal.java @@ -0,0 +1,67 @@ +package build.spawn.docker.option; + +/*- + * #%L + * Spawn Docker (Client) + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * 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 + * + * 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. + * #L% + */ + +import build.base.configuration.Default; +import build.base.configuration.Option; + +/** + * The POSIX signal sent to a {@link build.spawn.docker.Container} when killing it. + *

+ * Corresponds to the {@code signal} query parameter of the + * Kill Container API. + * + * @author reed.vonredwitz + * @since Apr-2026 + */ +public enum KillSignal + implements Option { + + /** + * {@code SIGKILL} — immediately terminates the container (Docker's default). + */ + @Default + SIGKILL, + + /** + * {@code SIGTERM} — requests graceful termination, allowing the container to clean up. + */ + SIGTERM, + + /** + * {@code SIGQUIT} — requests the container to produce a core dump and terminate. + */ + SIGQUIT, + + /** + * {@code SIGHUP} — signals the container to reload its configuration. + */ + SIGHUP; + + /** + * Returns the signal name as required by the Docker Engine API. + * + * @return the signal name, e.g. {@code "SIGKILL"} + */ + public String signalName() { + return name(); + } +} diff --git a/spawn-docker/src/test/java/build/spawn/docker/ImagesBuildTests.java b/spawn-docker/src/test/java/build/spawn/docker/ImagesBuildTests.java index cf05d3d..d04fb3f 100644 --- a/spawn-docker/src/test/java/build/spawn/docker/ImagesBuildTests.java +++ b/spawn-docker/src/test/java/build/spawn/docker/ImagesBuildTests.java @@ -20,10 +20,6 @@ class ImagesBuildTests { /** * Ensure that an {@link IOException} thrown by a {@link DockerContextBuilder} propagates * to the caller rather than being silently swallowed. - *

- * BUG: The default {@link Images#build(DockerContextBuilder, build.base.configuration.Option...)} - * method catches {@link IOException} from {@link DockerContextBuilder#build()} and returns - * {@code Optional.empty()} without logging or rethrowing, making failures invisible to callers. */ @Test void buildShouldPropagateIOExceptionFromContextBuilder() { @@ -63,7 +59,6 @@ public Optional build(final Path contextPath, final Configuration configu } }; - // the IOException should propagate; currently it is caught and Optional.empty() returned silently assertThatThrownBy(() -> images.build(failingBuilder)) .isInstanceOf(IOException.class) .hasMessage("simulated Docker context build failure"); diff --git a/spawn-docker/src/test/java/build/spawn/docker/option/PublishPortTests.java b/spawn-docker/src/test/java/build/spawn/docker/option/PublishPortTests.java index edbbedf..6edd72b 100644 --- a/spawn-docker/src/test/java/build/spawn/docker/option/PublishPortTests.java +++ b/spawn-docker/src/test/java/build/spawn/docker/option/PublishPortTests.java @@ -18,12 +18,6 @@ class PublishPortTests { /** * Ensure that configuring multiple external bindings for the same internal port * accumulates all bindings rather than silently discarding prior ones. - *

- * BUG: {@link PublishPort#configure} checks {@code objectNode.get(key)} instead of - * {@code portBindings.get(key)} when deciding whether an existing {@link ArrayNode} is - * reusable. Because the root node never has a raw port key (e.g. {@code "8080/tcp"}), - * the condition always evaluates to {@code true} and a fresh empty array is created, - * discarding any previously added bindings for that port. */ @Test void shouldAccumulateMultipleBindingsForSamePort() { @@ -34,7 +28,6 @@ void shouldAccumulateMultipleBindingsForSamePort() { PublishPort.of(8080, PublishPort.Type.TCP, 8080).configure(container, objectMapper); PublishPort.of(8080, PublishPort.Type.TCP, 9090).configure(container, objectMapper); - // both bindings should be present; the bug causes only the last one to survive final var portBindings = (ObjectNode) container.path("HostConfig").path("PortBindings"); final var array = (ArrayNode) portBindings.get("8080/tcp"); diff --git a/spawn-jdk/src/test/java/build/spawn/jdk/agent/SpawnAgentArgumentParsingTests.java b/spawn-jdk/src/test/java/build/spawn/jdk/agent/SpawnAgentArgumentParsingTests.java index 55e6e17..f9add84 100644 --- a/spawn-jdk/src/test/java/build/spawn/jdk/agent/SpawnAgentArgumentParsingTests.java +++ b/spawn-jdk/src/test/java/build/spawn/jdk/agent/SpawnAgentArgumentParsingTests.java @@ -34,12 +34,6 @@ private static Properties parseArguments(final String arguments) /** * Ensure that a value containing an {@code =} character is preserved in full rather than * being silently discarded. - *

- * BUG: {@code parseArguments} splits each token on {@code "="} with no limit, producing - * three or more parts when the value itself contains {@code =}. The entry-length check - * only handles lengths {@code 1} and {@code 2}, so any token with {@code =} in its value - * (e.g. a Base64 string or a {@code host:port=label} value) is silently dropped. - * The fix is to split with a limit of {@code 2}: {@code s.split("=", 2)}. * * @throws Exception if reflection-based invocation fails */ @@ -50,17 +44,12 @@ void shouldPreserveValueContainingEqualsSign() final var properties = parseArguments("machine=host:1234,token=abc=def"); assertThat(properties.getProperty("machine")).isEqualTo("host:1234"); - - // "token" is silently dropped because "token=abc=def".split("=") produces 3 parts assertThat(properties.getProperty("token")).isEqualTo("abc=def"); } /** * Ensure that a value consisting entirely of {@code =} characters (e.g. Base64 padding) * is not discarded. - *

- * BUG: same root cause as above — {@code "padding==="} splits into four parts and is - * silently ignored. * * @throws Exception if reflection-based invocation fails */ @@ -68,12 +57,9 @@ void shouldPreserveValueContainingEqualsSign() void shouldPreserveValueOfOnlyEqualsSignPadding() throws Exception { - // simulates a Base64-encoded token that ends with padding characters final var properties = parseArguments("host=localhost:1234,padding==="); assertThat(properties.getProperty("host")).isEqualTo("localhost:1234"); - - // "padding" entry is silently dropped; it should hold the value "==" assertThat(properties.getProperty("padding")).isEqualTo("=="); } } diff --git a/spawn-jdk/src/test/java/build/spawn/jdk/option/HeadlessTests.java b/spawn-jdk/src/test/java/build/spawn/jdk/option/HeadlessTests.java index 6aa5a34..4f40943 100644 --- a/spawn-jdk/src/test/java/build/spawn/jdk/option/HeadlessTests.java +++ b/spawn-jdk/src/test/java/build/spawn/jdk/option/HeadlessTests.java @@ -24,10 +24,6 @@ void enabledShouldResolveToHeadlessSystemProperty() { /** * Ensure {@link Headless#DISABLED} resolves to an empty stream, adding no tokens to the * JVM command line. - *

- * BUG: {@link Headless#DISABLED} returns {@code Stream.of("")} — a stream containing a - * single empty-string token — instead of {@code Stream.empty()}. This causes a blank - * argument to be injected into the JVM command line, which can confuse argument parsers. */ @Test void disabledShouldResolveToNoTokens() { diff --git a/spawn-local-jdk/src/main/java/build/spawn/platform/local/jdk/LocalJDKLauncher.java b/spawn-local-jdk/src/main/java/build/spawn/platform/local/jdk/LocalJDKLauncher.java index ca5cb6d..0343c59 100644 --- a/spawn-local-jdk/src/main/java/build/spawn/platform/local/jdk/LocalJDKLauncher.java +++ b/spawn-local-jdk/src/main/java/build/spawn/platform/local/jdk/LocalJDKLauncher.java @@ -49,7 +49,9 @@ import java.net.InetAddress; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.Path; import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; /** * A {@link Launcher} for {@link JDKApplication}s on a {@link LocalMachine}. @@ -65,6 +67,11 @@ public class LocalJDKLauncher */ private static final Logger LOGGER = Logger.get(LocalJDKLauncher.class); + /** + * The cached {@link Path} to the {@link SpawnAgent} archive, created at most once per JVM. + */ + private static final AtomicReference SPAWN_AGENT_ARCHIVE = new AtomicReference<>(); + @Override public Optional getExecutable(final ConfigurationBuilder options) { return Optional.of(Executable.of(options.get(JDKHome.class).get() + "/bin/java")); @@ -74,10 +81,13 @@ public Optional getExecutable(final ConfigurationBuilder options) { public LocalProcess createProcess(final LocalMachine machine, final ConfigurationBuilder options) { - // TODO: this should be once and only once per (this) Virtual Machine - // obtain the Path to the SpawnAgent archive - final var spawnAgentPath = SpawnAgentArchiveBuilder.getArchive() - .orElseGet(SpawnAgentArchiveBuilder::createArchive); + // obtain the Path to the SpawnAgent archive, creating it at most once per JVM + if (SPAWN_AGENT_ARCHIVE.get() == null) { + final Path created = SpawnAgentArchiveBuilder.getArchive() + .orElseGet(SpawnAgentArchiveBuilder::createArchive); + SPAWN_AGENT_ARCHIVE.compareAndSet(null, created); + } + final var spawnAgentPath = SPAWN_AGENT_ARCHIVE.get(); // establish a ProcessBuilder to create and launch the underlying process for the application final ProcessBuilder processBuilder = getExecutable(options) diff --git a/spawn-local-jdk/src/test/java/build/spawn/platform/local/jdk/JDKHomeBasedPatternDetectorLogTests.java b/spawn-local-jdk/src/test/java/build/spawn/platform/local/jdk/JDKHomeBasedPatternDetectorLogTests.java index af05d1f..e9656b5 100644 --- a/spawn-local-jdk/src/test/java/build/spawn/platform/local/jdk/JDKHomeBasedPatternDetectorLogTests.java +++ b/spawn-local-jdk/src/test/java/build/spawn/platform/local/jdk/JDKHomeBasedPatternDetectorLogTests.java @@ -23,12 +23,6 @@ class JDKHomeBasedPatternDetectorLogTests { * Ensure that when a configured JDK search path does not exist, the debug log message * shows both the base directory and the full pattern that was skipped. *

- * BUG: The format string in {@link JDKHomeBasedPatternDetector} reads - * {@code "Skipping path [{0}] for pattern [{0}] as the path does not exist"} — the - * second placeholder is {@code {0}} (base) when it should be {@code {1}} (pattern). - * This causes the pattern value to never appear in the log, making the message - * misleading when diagnosing JDK detection failures. - *

* NOTE: This test captures log output via {@code java.util.logging} (JUL) on the assumption * that {@code build.base.logging.Logger} delegates to JUL. If a different backend is used, * this test will pass vacuously (no records captured) and must be revisited. diff --git a/spawn-local-platform/src/test/java/build/spawn/application/local/LocalMachineCloseTests.java b/spawn-local-platform/src/test/java/build/spawn/application/local/LocalMachineCloseTests.java new file mode 100644 index 0000000..9045c15 --- /dev/null +++ b/spawn-local-platform/src/test/java/build/spawn/application/local/LocalMachineCloseTests.java @@ -0,0 +1,39 @@ +package build.spawn.application.local; + +import build.spawn.platform.local.LocalMachine; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Tests for the {@link LocalMachine#close()} behaviour. + * + * @author reed.vonredwitz + * @since Apr-2026 + */ +class LocalMachineCloseTests { + + /** + * Ensure that closing a {@link LocalMachine} does not throw. + *

+ * The platform holds an internal {@link build.base.network.Server} that must be closed; a + * no-op {@code close()} would leave it open, leaking the bound port. + */ + @Test + void closeShouldNotThrow() { + assertThatCode(() -> new LocalMachine().close()).doesNotThrowAnyException(); + } + + /** + * Ensure that closing a {@link LocalMachine} multiple times does not throw. + */ + @Test + void closeIsIdempotent() { + final var machine = new LocalMachine(); + + assertThatCode(() -> { + machine.close(); + machine.close(); + }).doesNotThrowAnyException(); + } +}