diff --git a/packages/@jsii/java-runtime-test/project/.mvn/maven.config b/packages/@jsii/java-runtime-test/project/.mvn/maven.config new file mode 100644 index 0000000000..3fcbe53a79 --- /dev/null +++ b/packages/@jsii/java-runtime-test/project/.mvn/maven.config @@ -0,0 +1 @@ +--settings=user.xml \ No newline at end of file diff --git a/packages/@jsii/java-runtime/project/.mvn/maven.config b/packages/@jsii/java-runtime/project/.mvn/maven.config new file mode 100644 index 0000000000..3fcbe53a79 --- /dev/null +++ b/packages/@jsii/java-runtime/project/.mvn/maven.config @@ -0,0 +1 @@ +--settings=user.xml \ No newline at end of file diff --git a/packages/@jsii/java-runtime/project/src/main/java/software/amazon/jsii/JsiiRuntime.java b/packages/@jsii/java-runtime/project/src/main/java/software/amazon/jsii/JsiiRuntime.java index 57f97fcc35..6c722a4aee 100644 --- a/packages/@jsii/java-runtime/project/src/main/java/software/amazon/jsii/JsiiRuntime.java +++ b/packages/@jsii/java-runtime/project/src/main/java/software/amazon/jsii/JsiiRuntime.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; import software.amazon.jsii.api.Callback; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; @@ -14,6 +15,7 @@ import java.lang.reflect.InvocationTargetException; import java.nio.channels.Channels; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -71,6 +73,24 @@ public final class JsiiRuntime { */ private Thread shutdownHook; + /** The value of the JSII_RUNTIME environment variable */ + @Nullable + private final String customRuntime; + + /** The value of the JSII_NODE environment variable */ + @Nullable + private final String customNode; + + public JsiiRuntime() { + this(System.getenv("JSII_RUNTIME"), System.getenv("JSII_NODE")); + } + + @VisibleForTesting + JsiiRuntime(@Nullable final String customRuntime, @Nullable final String customNode) { + this.customRuntime = customRuntime; + this.customNode = customNode; + } + /** * The main API of this class. Sends a JSON request to jsii-runtime and returns the JSON response. * @@ -273,13 +293,7 @@ private synchronized void startRuntimeIfNeeded() { && !jsiiDebug.equalsIgnoreCase("false") && !jsiiDebug.equalsIgnoreCase("0"); - // If JSII_RUNTIME is set, use it to find the jsii-server executable - // otherwise, we default to "jsii-runtime" from PATH. - final String jsiiRuntimeEnv = System.getenv("JSII_RUNTIME"); - final List jsiiRuntimeCommand = jsiiRuntimeEnv == null - ? Arrays.asList("node", BundledRuntime.extract(getClass())) - : Collections.singletonList(jsiiRuntimeEnv); - + final List jsiiRuntimeCommand = jsiiRuntimeCommand(); if (traceEnabled) { System.err.println("jsii-runtime: " + String.join(" ", jsiiRuntimeCommand)); } @@ -314,6 +328,56 @@ private synchronized void startRuntimeIfNeeded() { this.client = new JsiiClient(this); } + /** + * Determines the correct command to execute in order to start the jsii runtime program. If custom runtimes are + * configured (either via `JSII_RUNTIME` or `JSII_NODE`), defer to `sh -c` in order to ensure platform-appropriate + * command parsing is performed, since {@link ProcessBuilder#command(String...)} won't do any of this by itself. + * + * @return The command to execute to start the jsii runtime program. + */ + private List jsiiRuntimeCommand() { + if (this.customRuntime != null) { + if (this.customRuntime.matches(".*\\s.*")) { + // Shell out only if the custom runtime includes white space. + return shellOut(this.customRuntime); + } + return Collections.singletonList(this.customRuntime); + } + + // We don't use a custom runtime, so extract the bundled one... + final String bundledRuntime = BundledRuntime.extract(JsiiRuntime.class); + + if (this.customNode != null && this.customNode.matches(".*\\s.*")) { + // Shell out only if the custom node includes white space. + return shellOut(this.customNode, bundledRuntime); + } + return Arrays.asList(this.customNode != null ? this.customNode : "node", bundledRuntime); + } + + /** + * Creates a command to sub-shell to the specified end-user command, that uses `/bin/sh` on *NIXes, and %COMSPEC%, or + * cmd.exe on Windows. + * + *

+ * This is heavily inspired from how Node.js does the same thing. + *

+ * + * @param command the end-user command to be run. + * + * @return a full sub-shell command that is platform-appropriate. + */ + private static List shellOut(final String... command) { + final boolean isWindows = System.getProperty("os.name").startsWith("Windows"); + if (isWindows) { + String cmd = System.getenv("COMSPEC"); + if (cmd == null) { + cmd = "cmd.exe"; + } + return Arrays.asList(cmd, "/d", "/s", "/c", String.join(" ", command)); + } + return Arrays.asList("/bin/sh", "-c", String.join(" ", command)); + } + /** * Verifies the "hello" message and runtime version compatibility. * In the meantime, we require full version compatibility, but we should use semver eventually. diff --git a/packages/@jsii/java-runtime/project/src/test/java/software/amazon/jsii/JsiiRuntimeTest.java b/packages/@jsii/java-runtime/project/src/test/java/software/amazon/jsii/JsiiRuntimeTest.java new file mode 100644 index 0000000000..4f01bf57a6 --- /dev/null +++ b/packages/@jsii/java-runtime/project/src/test/java/software/amazon/jsii/JsiiRuntimeTest.java @@ -0,0 +1,73 @@ +package software.amazon.jsii; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +public final class JsiiRuntimeTest { + @Test + public void withNoCustomization() { + final JsiiRuntime runtime = new JsiiRuntime(null, null); + runtime.getClient().createObject("Object", Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + runtime.terminate(); + } + + @Test + public void withCustomNode_Simple() { + final String customNode = resolveNodeFromPath(); + + final JsiiRuntime runtime = new JsiiRuntime(null, customNode); + runtime.getClient().createObject("Object", Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + runtime.terminate(); + } + + @Test + public void withCustomNode_WithSpace() { + final JsiiRuntime runtime = new JsiiRuntime(null, "node --max_old_space_size=1024"); + runtime.getClient().createObject("Object", Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + runtime.terminate(); + } + + @Test + public void withCustomRuntime_Simple() throws Exception { + final Path launcher = Files.createTempFile("jsii-runtime", ".launcher.bat"); + try (final Writer writer = new FileWriter(launcher.toFile())) { + writer.write("node ./src/main/resources/software/amazon/jsii/bin/jsii-runtime.js\n"); + } + Assertions.assertTrue(launcher.toFile().setExecutable(true)); + + final JsiiRuntime runtime = new JsiiRuntime(launcher.toString(), null); + runtime.getClient().createObject("Object", Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + runtime.terminate(); + } + + @Test + public void withCustomRuntime_WithSpace() { + final JsiiRuntime runtime = new JsiiRuntime("node ./src/main/resources/software/amazon/jsii/bin/jsii-runtime.js", null); + runtime.getClient().createObject("Object", Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + runtime.terminate(); + } + + private static String resolveNodeFromPath() { + try { + final String[] command = System.getProperty("os.name").startsWith("Windows") + ? new String[]{"cmd.exe", "/d", "/s", "/c", "where node"} + : new String[]{"sh", "-c", "command -v node"}; + final Process process = Runtime.getRuntime().exec(command); + try { + final BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream())); + return br.readLine(); + } finally { + process.waitFor(); + } + } catch (final IOException ioe) { + throw new UncheckedIOException(ioe); + } catch (final InterruptedException ie) { + throw new RuntimeException(ie); + } + } +}