diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java index 58cd6c9ba1..051c72ec9f 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java @@ -22,13 +22,16 @@ import java.util.ArrayList; import java.util.Deque; import java.util.List; +import java.util.Locale; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.IntSupplier; import javax.annotation.Nonnull; import javax.annotation.Nullable; /** Internal ProcessBuilder-based Temporal dev-server launcher. */ public final class TemporalDevServerLauncher { + private static final int AUTOMATIC_PORT_ATTEMPTS = 3; private static final int LOG_TAIL_LINES = 200; private static final long GRACEFUL_SHUTDOWN_SECONDS = 10; @@ -36,8 +39,33 @@ private TemporalDevServerLauncher() {} public static RunningServer start( @Nonnull String namespace, @Nonnull TemporalDevServerOptions options) { + return start(namespace, options, () -> reservePort(options.getIp())); + } + + static RunningServer start( + @Nonnull String namespace, + @Nonnull TemporalDevServerOptions options, + @Nonnull IntSupplier automaticPortSupplier) { Path executable = TemporalDevServerDownloader.prepare(options); - int port = options.getPort() == null ? reservePort(options.getIp()) : options.getPort(); + if (options.getPort() != null) { + return start(executable, namespace, options, options.getPort()); + } + for (int attempt = 1; ; attempt++) { + try { + return start(executable, namespace, options, automaticPortSupplier.getAsInt()); + } catch (IllegalStateException failure) { + if (attempt == AUTOMATIC_PORT_ATTEMPTS || !isAddressAlreadyInUse(failure)) { + throw failure; + } + } + } + } + + private static RunningServer start( + @Nonnull Path executable, + @Nonnull String namespace, + @Nonnull TemporalDevServerOptions options, + int port) { String target = targetHost(options.getIp()) + ":" + port; List command = buildCommand(executable, namespace, options, port); @@ -247,6 +275,16 @@ private static String renderCommand(@Nonnull List command) { return rendered.toString(); } + private static boolean isAddressAlreadyInUse(@Nonnull IllegalStateException failure) { + String message = failure.getMessage(); + if (message == null) { + return false; + } + String normalizedMessage = message.toLowerCase(Locale.ROOT); + return normalizedMessage.contains("address already in use") + || normalizedMessage.contains("only one usage of each socket address"); + } + private static int reservePort(@Nonnull String ip) { try (ServerSocket socket = new ServerSocket(0, 0, InetAddress.getByName(ip))) { socket.setReuseAddress(true); diff --git a/temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncherIntegrationTest.java b/temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncherIntegrationTest.java new file mode 100644 index 0000000000..e1d521aaaf --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncherIntegrationTest.java @@ -0,0 +1,61 @@ +package io.temporal.testing.internal.devserver; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.temporal.testing.TemporalDevServerOptions; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntSupplier; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; + +/** + * Integration coverage for automatic port collision recovery using the pinned real Temporal CLI. + */ +@EnabledIfSystemProperty(named = SdkJavaTestServerProfile.ACTIVE_PROPERTY, matches = "true") +class TemporalDevServerLauncherIntegrationTest { + private static Path temporalCli; + + @TempDir Path tempDirectory; + + @BeforeAll + static void prepareTemporalCli() { + temporalCli = SdkJavaTestServerProfile.prepare(); + } + + @Test + void automaticPortCollisionIsRetried() throws IOException { + try (ServerSocket occupiedPort = new ServerSocket(0, 0, InetAddress.getByName("127.0.0.1"))) { + AtomicInteger selections = new AtomicInteger(); + IntSupplier ports = + () -> { + if (selections.getAndIncrement() == 0) { + return occupiedPort.getLocalPort(); + } + try (ServerSocket availablePort = + new ServerSocket(0, 0, InetAddress.getByName("127.0.0.1"))) { + return availablePort.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }; + TemporalDevServerOptions options = + TemporalDevServerOptions.newBuilder() + .setExistingPath(temporalCli.toString()) + .setStartupTimeout(Duration.ofSeconds(60)) + .setLogFile(tempDirectory.resolve("server.log").toString()) + .build(); + + try (TemporalDevServerLauncher.RunningServer ignored = + TemporalDevServerLauncher.start("default", options, ports)) { + assertEquals(2, selections.get()); + } + } + } +}