diff --git a/common/src/main/java/com/highgo/cloud/util/ShellUtil.java b/common/src/main/java/com/highgo/cloud/util/ShellUtil.java index 57a5e0b..26a947a 100644 --- a/common/src/main/java/com/highgo/cloud/util/ShellUtil.java +++ b/common/src/main/java/com/highgo/cloud/util/ShellUtil.java @@ -50,7 +50,7 @@ public static int execShellSpaceParam(String[] shellCmd) { log.debug("Begin to execute command: [{}]", Arrays.toString(shellCmd)); try { - Process ps = Runtime.getRuntime().exec(shellCmd); + Process ps = new ProcessBuilder(shellCmd).redirectErrorStream(true).start(); // -- catch the deploy log start BufferedInputStream in = new BufferedInputStream(ps.getInputStream()); BufferedReader br = new BufferedReader(new InputStreamReader(in)); @@ -171,7 +171,7 @@ public static int execShell(String shellCmd) { } public static boolean execShellWithTimeout(String shellCmd, long time, TimeUnit unit) { - boolean result = true; + boolean result = false; log.info("Begin to execute command: [{}], timeout: [{}], unit: [{}]", shellCmd, time, unit.name()); try { Process ps = Runtime.getRuntime().exec(shellCmd); diff --git a/tests/regression/ShellArrayStderrRegression.java b/tests/regression/ShellArrayStderrRegression.java new file mode 100644 index 0000000..3105728 --- /dev/null +++ b/tests/regression/ShellArrayStderrRegression.java @@ -0,0 +1,80 @@ +import com.highgo.cloud.util.ShellUtil; +import java.io.File; +import java.util.Arrays; + +/** Exercises the complete executor with real, self-bounded JDK child processes. */ +public final class ShellArrayStderrRegression { + private static int failures; + + private static void check(String name, String mode, int exitCode, String argument) { + String executable = new File( + new File(System.getProperty("java.home"), "bin"), + System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java") + .getAbsolutePath(); + String[] command = { + executable, "-Xmx32m", "-XX:ActiveProcessorCount=1", + "-cp", System.getProperty("java.class.path"), + Emitter.class.getName(), mode, Integer.toString(exitCode), argument + }; + int actual = ShellUtil.execShellSpaceParam(command); + if (actual != exitCode) { + failures++; + System.err.println("FAIL " + name + ": expected exit " + exitCode + ", got " + actual); + } else { + System.out.println("PASS " + name); + } + } + + public static void main(String[] args) { + check("large-stderr-zero-exit", "stderr", 0, "ordinary"); + check("large-stderr-nonzero-exit", "stderr", 7, "ordinary"); + check("stdout-only-control", "stdout", 0, "ordinary"); + check("quiet-nonzero-control", "quiet", 7, "ordinary"); + check("array-argument-with-spaces-control", "argument", 0, "literal argument with spaces"); + if (failures != 0) { + throw new AssertionError(failures + " process completion assertions failed"); + } + } + + public static final class Emitter { + public static void main(String[] args) throws Exception { + // Bound THIS private child, including a blocked pipe write, without + // killing unrelated processes or depending on the production helper's timeout policy. + Thread watchdog = new Thread(() -> { + try { + Thread.sleep(8000); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + Runtime.getRuntime().halt(99); + }, "private-emitter-watchdog"); + watchdog.setDaemon(true); + watchdog.start(); + + String mode = args[0]; + int exitCode = Integer.parseInt(args[1]); + if ("stderr".equals(mode)) { + byte[] chunk = new byte[8192]; + Arrays.fill(chunk, (byte) 'e'); + chunk[chunk.length - 1] = (byte) '\n'; + for (int i = 0; i < 64; i++) { + System.err.write(chunk); + System.err.flush(); + } + System.out.println("stderr fully written"); + } else if ("stdout".equals(mode)) { + System.out.println("ordinary stdout"); + } else if ("argument".equals(mode)) { + if (args.length != 3 || !"literal argument with spaces".equals(args[2])) { + Runtime.getRuntime().halt(83); + } + System.out.println(args[2]); + } else if (!"quiet".equals(mode)) { + Runtime.getRuntime().halt(84); + } + System.exit(exitCode); + } + } +} + diff --git a/tests/regression/ShellTimeoutRegression.java b/tests/regression/ShellTimeoutRegression.java new file mode 100644 index 0000000..15d41dd --- /dev/null +++ b/tests/regression/ShellTimeoutRegression.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import com.highgo.cloud.util.ShellUtil; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** Full production ShellUtil tests with actual local processes, no process mock. */ +public final class ShellTimeoutRegression { + private static int passed; + private static int failed; + + private interface Check { + void run() throws Exception; + } + + private static void check(String name, Check check) { + try { + check.run(); + passed++; + System.out.println("PASS " + name); + } catch (Throwable error) { + failed++; + System.err.println("FAIL " + name); + error.printStackTrace(System.err); + } + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static String exitCommand(int status) { + // Runtime.exec(String) tokenizes on whitespace. These fixed shell + // commands intentionally need no quoting or user-supplied interpolation. + return "cmd.exe /d /c exit " + status; + } + + public static void main(String[] args) throws Exception { + if (args.length != 1) { + throw new IllegalArgumentException("Supply Main's existing private work directory"); + } + Path root = Paths.get(args[0]).toRealPath(); + require(Files.isDirectory(root), "Private work directory must exist"); + require(root.getParent() != null, "Private work directory must not be a filesystem root"); + Path missing = root.resolve("nonexistent-ivory-command-" + UUID.randomUUID() + ".exe"); + require(Files.notExists(missing), "Missing-command fixture must not exist"); + + check("launch IOException returns false", () -> { + boolean completed = ShellUtil.execShellWithTimeout( + missing.toString(), 5, TimeUnit.SECONDS); + require(!completed, "A process that could not start cannot have completed"); + }); + check("successful local process still completes", () -> { + String command = System.getProperty("os.name").startsWith("Windows") + ? exitCommand(0) : "/bin/true"; + require(ShellUtil.execShellWithTimeout(command, 5, TimeUnit.SECONDS), + "An actual process exiting promptly must complete"); + }); + check("nonzero termination retains completion semantics", () -> { + String command = System.getProperty("os.name").startsWith("Windows") + ? exitCommand(7) : "/bin/false"; + require(ShellUtil.execShellWithTimeout(command, 5, TimeUnit.SECONDS), + "Existing waitFor semantics report termination, including a nonzero exit"); + }); + System.out.println("RESULT passed=" + passed + " failed=" + failed); + if (failed != 0) { + throw new AssertionError("Regression failures: " + failed); + } + } +}