diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/DefaultBuildCommandProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/DefaultBuildCommandProvider.java index e5aef72882..f8b9b05d62 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/DefaultBuildCommandProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/DefaultBuildCommandProvider.java @@ -11,6 +11,8 @@ package org.springframework.ide.vscode.boot.java; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -48,30 +50,26 @@ public DefaultBuildCommandProvider(SimpleLanguageServer server, JavaProjectFinde server.onCommand(CMD_EXEC_MAVEN_GOAL, params -> { String pomPath = extractString(params.getArguments().get(0)); String goal = extractString(params.getArguments().get(1)); - return CompletableFuture.runAsync(() -> { - try { - Path buildFile = validateOpenProjectBuildFile(pomPath, ProjectBuild.MAVEN_PROJECT_TYPE); - String[] goals = goal.trim().split("\\s+"); - executeMaven(buildFile, goals).get(); - } catch (Exception e) { - throw new CompletionException(e); - } - }); + Path buildFile = validateOpenProjectBuildFile(pomPath, ProjectBuild.MAVEN_PROJECT_TYPE); + String[] goals = goal.trim().split("\\s+"); + try { + return executeMaven(buildFile, goals); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } }); // Execute Gradle Build server.onCommand(CMD_EXEC_GRADLE_BUILD, params -> { String gradleBuildPath = extractString(params.getArguments().get(0)); String command = extractString(params.getArguments().get(1)); - return CompletableFuture.runAsync(() -> { - try { - Path buildFile = validateOpenProjectBuildFile(gradleBuildPath, ProjectBuild.GRADLE_PROJECT_TYPE); - String[] tasks = command.trim().split("\\s+"); - executeGradle(buildFile, tasks).get(); - } catch (Exception e) { - throw new CompletionException(e); - } - }); + try { + Path buildFile = validateOpenProjectBuildFile(gradleBuildPath, ProjectBuild.GRADLE_PROJECT_TYPE); + String[] tasks = command.trim().split("\\s+"); + return executeGradle(buildFile, tasks); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } }); } @@ -126,39 +124,51 @@ private static String extractString(Object o) { return o instanceof JsonPrimitive ? ((JsonPrimitive) o).getAsString() : o.toString(); } - private CompletableFuture executeMaven(Path pom, String[] goal) { + private CompletableFuture executeMaven(Path pom, String[] goal) throws IOException { synchronized(MAVEN_LOCK) { String[] cmd = new String[1 + goal.length]; Path projectPath = pom.getParent(); Path mvnw = projectPath.resolve(OS.isWindows() ? "mvnw.cmd" : "mvnw"); cmd[0] = Files.isRegularFile(mvnw) ? mvnw.toFile().toString() : "mvn"; System.arraycopy(goal, 0, cmd, 1, goal.length); - try { - return Runtime.getRuntime().exec(cmd, null, projectPath.toFile()).onExit().thenAccept(process -> { - if (process.exitValue() != 0) { - throw new CompletionException("Failed to execute Maven goal", new IllegalStateException("Errors running maven command: %s".formatted(String.join(" ", cmd)))); - } - }); - } catch (IOException e) { - throw new CompletionException(e); - } + return runProcess(cmd, projectPath, "Failed to execute Maven goal"); } } - private CompletableFuture executeGradle(Path gradleBuildPath, String[] command) { + private CompletableFuture executeGradle(Path gradleBuildPath, String[] command) throws IOException { String[] cmd = new String[1 + command.length]; Path projectPath = gradleBuildPath.getParent(); - Path mvnw = projectPath.resolve(OS.isWindows() ? "gradlew.cmd" : "gradlew"); - cmd[0] = Files.isRegularFile(mvnw) ? mvnw.toFile().toString() : "gradle"; + Path gradlew = projectPath.resolve(OS.isWindows() ? "gradlew.bat" : "gradlew"); + cmd[0] = Files.isRegularFile(gradlew) ? gradlew.toFile().toString() : "gradle"; System.arraycopy(command, 0, cmd, 1, command.length); - try { - return Runtime.getRuntime().exec(cmd, null, projectPath.toFile()).onExit().thenAccept(process -> { - if (process.exitValue() != 0) { - throw new CompletionException("Failed to execute Gradle build", new IllegalStateException("Errors running gradle command: %s".formatted(String.join(" ", cmd)))); - } - }); - } catch (IOException e) { - throw new CompletionException(e); - } + return runProcess(cmd, projectPath, "Failed to execute Gradle build"); + } + + /** + * Starts the given command and continuously drains its combined stdout/stderr on a + * separate thread so the child never blocks on a full pipe buffer. The captured + * output is attached to the resulting exception if the process exits with a + * non-zero status. + */ + private CompletableFuture runProcess(String[] cmd, Path workingDir, String failureMessage) throws IOException { + Process process = new ProcessBuilder() + .command(cmd) + .directory(workingDir.toFile()) + .redirectErrorStream(true) + .start(); + CompletableFuture output = CompletableFuture.supplyAsync(() -> { + try (InputStream in = process.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + return ""; + } + }); + return process.onExit().thenCombine(output, (exited, capturedOutput) -> { + if (exited.exitValue() != 0) { + throw new CompletionException(failureMessage, new IllegalStateException( + "Errors running command: %s%n%s".formatted(String.join(" ", cmd), capturedOutput))); + } + return null; + }); } } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/DefaultBuildCommandProviderTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/DefaultBuildCommandProviderTest.java index ec4d3d5f9a..dc411b6fa2 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/DefaultBuildCommandProviderTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/DefaultBuildCommandProviderTest.java @@ -85,9 +85,8 @@ public void rejectsMavenGoalForPathOutsideOfOpenProjects() throws Exception { // attacker points at a build file that is NOT part of any open project Path evilPom = createBuildFile("evil", "pom.xml"); - ExecutionException ex = assertThrows(ExecutionException.class, + assertThrows(SecurityException.class, () -> mavenHandler.handle(params(evilPom, "compile")).get()); - assertInstanceOf(SecurityException.class, ex.getCause()); } @Test @@ -108,9 +107,8 @@ public void rejectsMavenGoalWhenNoProjectOfMatchingTypeIsOpen() throws Exception Path gradleBuild = createBuildFile("open-project", "build.gradle"); openProject(ProjectBuild.GRADLE_PROJECT_TYPE, gradleBuild); - ExecutionException ex = assertThrows(ExecutionException.class, + assertThrows(SecurityException.class, () -> mavenHandler.handle(params(gradleBuild, "compile")).get()); - assertInstanceOf(SecurityException.class, ex.getCause()); } @Test @@ -166,7 +164,10 @@ private Path createBuildFile(String projectDirName, String buildFileName) throws */ private void createBuildWrapper(Path projectDir, String baseName) throws IOException { if (OS.isWindows()) { - Path wrapper = projectDir.resolve(baseName + ".cmd"); + // matches the extension each wrapper generator actually produces: + // Maven Wrapper -> mvnw.cmd, Gradle Wrapper -> gradlew.bat + String extension = "gradlew".equals(baseName) ? ".bat" : ".cmd"; + Path wrapper = projectDir.resolve(baseName + extension); Files.writeString(wrapper, "@echo off\r\nexit /b 0\r\n"); } else { Path wrapper = projectDir.resolve(baseName);