diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy
index 5b9dd7527ba..5e3323f5365 100644
--- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy
+++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy
@@ -1,6 +1,7 @@
package datadog.trace.civisibility.utils
import datadog.communication.util.IOUtils
+import datadog.trace.test.util.PortableCommand
import spock.lang.Specification
import spock.lang.TempDir
@@ -18,7 +19,7 @@ class ShellCommandExecutorTest extends Specification {
def shellCommandExecutor = new ShellCommandExecutor(temporaryFolder, SHELL_COMMAND_TIMEOUT)
when:
- def output = shellCommandExecutor.executeCommand(IOUtils::readFully, "echo", "this is a test")
+ def output = shellCommandExecutor.executeCommand(IOUtils::readFully, *PortableCommand.echo("this is a test"))
then:
output.trim() == "this is a test"
@@ -29,7 +30,7 @@ class ShellCommandExecutorTest extends Specification {
def shellCommandExecutor = new ShellCommandExecutor(temporaryFolder, SHELL_COMMAND_TIMEOUT)
when:
- def output = shellCommandExecutor.executeCommand(IOUtils::readFully, "this is a test".bytes, "cat")
+ def output = shellCommandExecutor.executeCommand(IOUtils::readFully, "this is a test".bytes, *PortableCommand.cat())
then:
output.trim() == "this is a test"
@@ -40,7 +41,7 @@ class ShellCommandExecutorTest extends Specification {
def shellCommandExecutor = new ShellCommandExecutor(temporaryFolder, 1_000)
when:
- shellCommandExecutor.executeCommand(IOUtils::readFully, "sleep", "2")
+ shellCommandExecutor.executeCommand(IOUtils::readFully, *PortableCommand.sleep(2))
then:
thrown TimeoutException
diff --git a/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy b/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy
index eb8af334502..dce93754c55 100644
--- a/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy
+++ b/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy
@@ -1,14 +1,15 @@
package datadog.trace.util
import datadog.trace.test.util.DDSpecification
+import datadog.trace.test.util.PortableCommand
import spock.util.concurrent.PollingConditions
// This test looks at the private "currentProcess" variable because the alternative
// would be calling "ps -e" repeatedly
class ProcessSupervisorTest extends DDSpecification {
ProcessBuilder createProcessBuilder() {
- // Creates a process that never returns
- return new ProcessBuilder("tail", "-f", "/dev/null")
+ // Creates a process that never returns on its own
+ return new ProcessBuilder(PortableCommand.runForever())
}
def "Process killed when supervisor closed"() {
diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java
new file mode 100644
index 00000000000..cc023a4d4d3
--- /dev/null
+++ b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java
@@ -0,0 +1,133 @@
+package datadog.trace.test.util;
+
+import datadog.environment.OperatingSystem;
+import datadog.trace.api.internal.VisibleForTesting;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.CodeSource;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Builds command lines for a small set of command-line utilities so tests can spawn them without
+ * depending on the host operating system.
+ *
+ *
On POSIX platforms the native utilities are used directly. Windows has no usable equivalent
+ * for any of them — {@code echo} is a {@code cmd.exe} builtin rather than an executable, {@code
+ * type} cannot read standard input, and {@code timeout} refuses to run when standard input is
+ * redirected — so there the commands are emulated by {@link PortableCommandRunner} in a child JVM.
+ *
+ *
POSIX deliberately keeps the native utilities instead of emulating everywhere: forking a JVM
+ * is far more expensive than spawning a small native binary, in both startup time and memory, and
+ * effectively all CI runs on Linux — so the cheap path is the one that matters. It is also exactly
+ * what these tests spawned before this class existed, which leaves CI behavior unchanged.
+ *
+ *
Both paths are observably identical: {@code cat} copies bytes exactly, {@code sleep} takes a
+ * duration in seconds, and {@code echo} terminates its output with the platform line separator.
+ * Callers therefore never need to branch on the operating system.
+ *
+ *
Supported commands:
+ *
+ *
+ * - {@link #echo(String)} writes a value followed by the platform line separator.
+ *
- {@link #cat()} copies standard input to standard output.
+ *
- {@link #sleep(long)} waits for the requested number of seconds, then exits with 0.
+ *
- {@link #runForever()} never exits on its own and must be destroyed by the caller.
+ *
+ */
+public final class PortableCommand {
+ private static final String MIN_HEAP = "-Xms8m";
+ private static final String MAX_HEAP = "-Xmx16m";
+
+ /** Windows has no usable native equivalent for any of these commands. */
+ private static final boolean EMULATED = OperatingSystem.isWindows();
+
+ private PortableCommand() {}
+
+ public static String[] echo(String value) {
+ return echo(value, EMULATED);
+ }
+
+ public static String[] cat() {
+ return cat(EMULATED);
+ }
+
+ public static String[] sleep(long durationSec) {
+ return sleep(durationSec, EMULATED);
+ }
+
+ public static String[] runForever() {
+ return runForever(EMULATED);
+ }
+
+ @VisibleForTesting
+ static String[] echo(String value, boolean emulated) {
+ return emulated ? emulate("echo", value) : new String[] {"echo", value};
+ }
+
+ @VisibleForTesting
+ static String[] cat(boolean emulated) {
+ return emulated ? emulate("cat") : new String[] {"cat"};
+ }
+
+ @VisibleForTesting
+ static String[] sleep(long durationSec, boolean emulated) {
+ if (durationSec < 0) {
+ throw new IllegalArgumentException("Sleep duration must not be negative: " + durationSec);
+ }
+ // The native sleep takes seconds; the emulated runner takes milliseconds.
+ return emulated
+ ? emulate("sleep", Long.toString(durationSec * 1000))
+ : new String[] {"sleep", Long.toString(durationSec)};
+ }
+
+ @VisibleForTesting
+ static String[] runForever(boolean emulated) {
+ return emulated
+ ? emulate("sleep", Long.toString(Long.MAX_VALUE))
+ : new String[] {"tail", "-f", "/dev/null"};
+ }
+
+ private static String[] emulate(String... arguments) {
+ Path executable = javaExecutable();
+ Path classpath = classpathEntry();
+
+ List command = new ArrayList<>();
+ command.add(executable.toString());
+ command.add(MIN_HEAP);
+ command.add(MAX_HEAP);
+ command.add("-cp");
+ command.add(classpath.toString());
+ command.add(PortableCommandRunner.class.getName());
+ command.addAll(Arrays.asList(arguments));
+ return command.toArray(new String[0]);
+ }
+
+ private static Path javaExecutable() {
+ return javaExecutable(Paths.get(System.getProperty("java.home")));
+ }
+
+ @VisibleForTesting
+ static Path javaExecutable(Path javaHome) {
+ Path bin = javaHome.resolve("bin");
+ for (String name : new String[] {"java", "java.exe"}) {
+ Path candidate = bin.resolve(name);
+ if (Files.isRegularFile(candidate)) {
+ return candidate;
+ }
+ }
+ throw new IllegalStateException("Could not find a Java executable under " + bin);
+ }
+
+ private static Path classpathEntry() {
+ CodeSource source = PortableCommand.class.getProtectionDomain().getCodeSource();
+ try {
+ return Paths.get(source.getLocation().toURI());
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ "Cannot determine the classpath of " + PortableCommand.class.getName(), e);
+ }
+ }
+}
diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java
new file mode 100644
index 00000000000..f67b4b6963f
--- /dev/null
+++ b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java
@@ -0,0 +1,55 @@
+package datadog.trace.test.util;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import datadog.trace.api.internal.VisibleForTesting;
+import de.thetaphi.forbiddenapis.SuppressForbidden;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Emulates the utilities described by {@link PortableCommand} inside a JVM, for platforms that have
+ * no usable native equivalent. Spawned as a child process; not meant to be called directly.
+ */
+public final class PortableCommandRunner {
+ private PortableCommandRunner() {}
+
+ @SuppressForbidden
+ public static void main(String[] arguments) throws IOException, InterruptedException {
+ execute(arguments, System.in, System.out);
+ System.out.flush();
+ }
+
+ @VisibleForTesting
+ static void execute(String[] arguments, InputStream input, OutputStream output)
+ throws IOException, InterruptedException {
+ if (arguments.length == 0) {
+ throw new IllegalArgumentException("Missing command");
+ }
+ switch (arguments[0]) {
+ case "echo":
+ output.write((argument(arguments) + System.lineSeparator()).getBytes(UTF_8));
+ break;
+ case "cat":
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = input.read(buffer)) != -1) {
+ output.write(buffer, 0, read);
+ }
+ break;
+ case "sleep":
+ Thread.sleep(Long.parseLong(argument(arguments)));
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown command: " + arguments[0]);
+ }
+ }
+
+ private static String argument(String[] arguments) {
+ if (arguments.length < 2) {
+ throw new IllegalArgumentException("Command '" + arguments[0] + "' requires an argument");
+ }
+ return arguments[1];
+ }
+}
diff --git a/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java
new file mode 100644
index 00000000000..610e5dad584
--- /dev/null
+++ b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java
@@ -0,0 +1,79 @@
+package datadog.trace.test.util;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for the command dispatch. In production the runner only ever executes as a child
+ * process, where its behavior is covered end to end by the emulated cases of {@link
+ * PortableCommandTest} — but a child JVM is opaque to both the coverage report and to assertions
+ * about why a command misbehaved, so the dispatch is driven directly here.
+ */
+class PortableCommandRunnerTest {
+ @Test
+ void echoes() throws Exception {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+
+ PortableCommandRunner.execute(new String[] {"echo", "value"}, emptyInput(), output);
+
+ assertEquals("value" + System.lineSeparator(), new String(output.toByteArray(), UTF_8));
+ }
+
+ @Test
+ void copiesInput() throws Exception {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ InputStream input = new ByteArrayInputStream("payload".getBytes(UTF_8));
+
+ PortableCommandRunner.execute(new String[] {"cat"}, input, output);
+
+ assertEquals("payload", new String(output.toByteArray(), UTF_8));
+ }
+
+ @Test
+ void sleeps() throws Exception {
+ long start = System.nanoTime();
+
+ PortableCommandRunner.execute(
+ new String[] {"sleep", "50"}, emptyInput(), new ByteArrayOutputStream());
+
+ assertTrue((System.nanoTime() - start) / 1_000_000 >= 40, "sleep returned immediately");
+ }
+
+ @Test
+ void rejectsMissingCommand() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ PortableCommandRunner.execute(
+ new String[0], emptyInput(), new ByteArrayOutputStream()));
+ }
+
+ @Test
+ void rejectsUnknownCommand() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ PortableCommandRunner.execute(
+ new String[] {"rm"}, emptyInput(), new ByteArrayOutputStream()));
+ }
+
+ @Test
+ void rejectsMissingArgument() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ PortableCommandRunner.execute(
+ new String[] {"echo"}, emptyInput(), new ByteArrayOutputStream()));
+ }
+
+ private static InputStream emptyInput() {
+ return new ByteArrayInputStream(new byte[0]);
+ }
+}
diff --git a/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java
new file mode 100644
index 00000000000..784145e8c8b
--- /dev/null
+++ b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java
@@ -0,0 +1,212 @@
+package datadog.trace.test.util;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import datadog.environment.OperatingSystem;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.tabletest.junit.TableTest;
+
+class PortableCommandTest {
+ private static final long TIMEOUT_SECONDS = 20;
+
+ // Both strategies are exercised on every platform so that the emulated path, which only ships
+ // on Windows, is still verified by a POSIX CI run.
+
+ @TableTest({
+ "scenario | emulated",
+ "native command | false ",
+ "emulated child JVM | true "
+ })
+ void testEcho(boolean emulated) throws Exception {
+ assumeStrategySupported(emulated);
+
+ Result result = run(PortableCommand.echo("hello", emulated), null);
+
+ assertEquals(0, result.exitCode, result.error);
+ assertEquals("hello" + System.lineSeparator(), result.output);
+ }
+
+ @TableTest({
+ "scenario | emulated",
+ "native command | false ",
+ "emulated child JVM | true "
+ })
+ void testCat(boolean emulated) throws Exception {
+ assumeStrategySupported(emulated);
+
+ Result result = run(PortableCommand.cat(emulated), "copied".getBytes(UTF_8));
+
+ assertEquals(0, result.exitCode, result.error);
+ assertEquals("copied", result.output);
+ }
+
+ @TableTest({
+ "scenario | emulated",
+ "native command | false ",
+ "emulated child JVM | true "
+ })
+ void testSleep(boolean emulated) throws Exception {
+ assumeStrategySupported(emulated);
+
+ long start = System.nanoTime();
+ Result result = run(PortableCommand.sleep(1, emulated), null);
+ long elapsedMillis = (System.nanoTime() - start) / 1_000_000;
+
+ assertEquals(0, result.exitCode, result.error);
+ // A floor slightly below the requested duration keeps this robust against clock granularity
+ // while still failing if sleep is a no-op.
+ assertTrue(elapsedMillis >= 900, "slept for only " + elapsedMillis + "ms");
+ }
+
+ @TableTest({
+ "scenario | emulated",
+ "native command | false ",
+ "emulated child JVM | true "
+ })
+ void testRunForever(boolean emulated) throws Exception {
+ assumeStrategySupported(emulated);
+
+ Process process = new ProcessBuilder(PortableCommand.runForever(emulated)).start();
+ try {
+ assertFalse(process.waitFor(1, SECONDS), "runForever() must not exit on its own");
+
+ process.destroyForcibly();
+
+ assertTrue(process.waitFor(TIMEOUT_SECONDS, SECONDS), "process outlived destroyForcibly()");
+ } finally {
+ process.destroyForcibly();
+ }
+ }
+
+ @Test
+ void publicApiSelectsStrategyForCurrentPlatform() {
+ boolean emulated = OperatingSystem.isWindows();
+
+ assertArrayEquals(PortableCommand.echo("v", emulated), PortableCommand.echo("v"));
+ assertArrayEquals(PortableCommand.cat(emulated), PortableCommand.cat());
+ assertArrayEquals(PortableCommand.sleep(10, emulated), PortableCommand.sleep(10));
+ assertArrayEquals(PortableCommand.runForever(emulated), PortableCommand.runForever());
+ }
+
+ @Test
+ void rejectsNegativeSleep() {
+ assertThrows(IllegalArgumentException.class, () -> PortableCommand.sleep(-1, false));
+ assertThrows(IllegalArgumentException.class, () -> PortableCommand.sleep(-1, true));
+ }
+
+ @Test
+ void locatesJavaExecutableOfRunningJvm() {
+ Path executable = PortableCommand.javaExecutable(Paths.get(System.getProperty("java.home")));
+
+ assertTrue(Files.isRegularFile(executable), executable + " is not a file");
+ }
+
+ @Test
+ void locatesWindowsJavaExecutable(@TempDir Path javaHome) throws Exception {
+ Path bin = Files.createDirectory(javaHome.resolve("bin"));
+ Path executable = Files.createFile(bin.resolve("java.exe"));
+
+ assertEquals(executable, PortableCommand.javaExecutable(javaHome));
+ }
+
+ @Test
+ void failsWhenJavaExecutableIsMissing(@TempDir Path javaHome) throws Exception {
+ Files.createDirectory(javaHome.resolve("bin"));
+
+ assertThrows(IllegalStateException.class, () -> PortableCommand.javaExecutable(javaHome));
+ }
+
+ private static void assumeStrategySupported(boolean emulated) {
+ assumeTrue(
+ emulated || !OperatingSystem.isWindows(),
+ "native commands are only available on POSIX platforms");
+ }
+
+ private static Result run(String[] command, byte[] input) throws Exception {
+ Process process = new ProcessBuilder(command).start();
+ try {
+ // Both streams are drained concurrently: waiting for the process to exit before reading
+ // deadlocks as soon as either pipe buffer fills.
+ Drain output = Drain.of(process.getInputStream());
+ Drain error = Drain.of(process.getErrorStream());
+
+ try (OutputStream stdin = process.getOutputStream()) {
+ if (input != null) {
+ stdin.write(input);
+ }
+ }
+
+ assertTrue(
+ process.waitFor(TIMEOUT_SECONDS, SECONDS),
+ () -> "command timed out: " + String.join(" ", command));
+ return new Result(process.exitValue(), output.text(), error.text());
+ } finally {
+ process.destroyForcibly();
+ }
+ }
+
+ private static final class Result {
+ final int exitCode;
+ final String output;
+ final String error;
+
+ Result(int exitCode, String output, String error) {
+ this.exitCode = exitCode;
+ this.output = output;
+ this.error = error;
+ }
+ }
+
+ private static final class Drain extends Thread {
+ private final InputStream input;
+ private final ByteArrayOutputStream output = new ByteArrayOutputStream();
+ private volatile IOException failure;
+
+ private Drain(InputStream input) {
+ this.input = input;
+ setDaemon(true);
+ }
+
+ static Drain of(InputStream input) {
+ Drain drain = new Drain(input);
+ drain.start();
+ return drain;
+ }
+
+ @Override
+ public void run() {
+ byte[] buffer = new byte[8192];
+ try {
+ int read;
+ while ((read = input.read(buffer)) != -1) {
+ output.write(buffer, 0, read);
+ }
+ } catch (IOException e) {
+ failure = e;
+ }
+ }
+
+ String text() throws Exception {
+ join(SECONDS.toMillis(TIMEOUT_SECONDS));
+ if (failure != null) {
+ throw failure;
+ }
+ return new String(output.toByteArray(), UTF_8);
+ }
+ }
+}