From d34de73ebac6c394c07bccd379ab9eb80fe5c5bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Thu, 18 Jun 2026 12:38:50 +0800 Subject: [PATCH] Fix run preview process tree cleanup --- .../service/front/ManagedPreviewProcess.java | 134 +++++ .../front/RunPreviewProcessKiller.java | 225 ++++++++ .../front/RunPreviewProcessRunner.java | 515 ++++++++++++++++++ .../front/RunPreviewProcessKillerTest.java | 120 ++++ .../front/RunPreviewProcessRunnerTest.java | 107 ++++ 5 files changed, 1101 insertions(+) create mode 100644 RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ManagedPreviewProcess.java create mode 100644 RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessKiller.java create mode 100644 RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessRunner.java create mode 100644 RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessKillerTest.java create mode 100644 RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessRunnerTest.java diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ManagedPreviewProcess.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ManagedPreviewProcess.java new file mode 100644 index 0000000..04beafb --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ManagedPreviewProcess.java @@ -0,0 +1,134 @@ +package com.ruoyi.generator.service.front; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.TimeUnit; + +final class ManagedPreviewProcess extends Process +{ + private final Process delegate; + private final File pidFile; + + ManagedPreviewProcess(Process delegate, File pidFile) + { + this.delegate = delegate; + this.pidFile = pidFile; + } + + long waitForRootPid(long timeoutMillis) + { + long deadline = System.currentTimeMillis() + timeoutMillis; + do + { + Long pid = readRootPid(); + if (pid != null) + { + return pid.longValue(); + } + sleepQuietly(25L); + } + while (System.currentTimeMillis() < deadline && delegate.isAlive()); + return -1L; + } + + void deletePidFile() + { + if (pidFile.isFile()) + { + pidFile.delete(); + } + } + + private Long readRootPid() + { + if (!pidFile.isFile()) + { + return null; + } + try + { + String value = new String(Files.readAllBytes(pidFile.toPath()), StandardCharsets.UTF_8).trim(); + long pid = Long.parseLong(value); + return pid > 0L ? Long.valueOf(pid) : null; + } + catch (IOException ignored) + { + return null; + } + catch (NumberFormatException ignored) + { + return null; + } + } + + private void sleepQuietly(long millis) + { + try + { + Thread.sleep(millis); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + } + + @Override + public OutputStream getOutputStream() + { + return delegate.getOutputStream(); + } + + @Override + public InputStream getInputStream() + { + return delegate.getInputStream(); + } + + @Override + public InputStream getErrorStream() + { + return delegate.getErrorStream(); + } + + @Override + public int waitFor() throws InterruptedException + { + return delegate.waitFor(); + } + + @Override + public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException + { + return delegate.waitFor(timeout, unit); + } + + @Override + public int exitValue() + { + return delegate.exitValue(); + } + + @Override + public void destroy() + { + delegate.destroy(); + } + + @Override + public Process destroyForcibly() + { + delegate.destroyForcibly(); + return this; + } + + @Override + public boolean isAlive() + { + return delegate.isAlive(); + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessKiller.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessKiller.java new file mode 100644 index 0000000..4e82d2a --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessKiller.java @@ -0,0 +1,225 @@ +package com.ruoyi.generator.service.front; + +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@Component +public class RunPreviewProcessKiller +{ + private static final long GRACEFUL_WAIT_MILLIS = 1500L; + + public void stop(Process process, Integer port) + { + stopProcess(process); + if (port != null) + { + killListeningProcesses(port.intValue()); + } + } + + private void stopProcess(Process process) + { + if (process == null) + { + return; + } + ManagedPreviewProcess managed = process instanceof ManagedPreviewProcess + ? (ManagedPreviewProcess) process + : null; + try + { + if (!process.isAlive()) + { + return; + } + if (managed != null) + { + long pid = managed.waitForRootPid(GRACEFUL_WAIT_MILLIS); + if (pid > 0L) + { + killWindowsProcessTree(pid); + waitForExit(process, GRACEFUL_WAIT_MILLIS); + } + } + if (process.isAlive()) + { + process.destroy(); + waitForExit(process, GRACEFUL_WAIT_MILLIS); + } + if (process.isAlive()) + { + process.destroyForcibly(); + waitForExit(process, GRACEFUL_WAIT_MILLIS); + } + } + finally + { + if (managed != null) + { + managed.deletePidFile(); + } + } + } + + protected boolean killWindowsProcessTree(long pid) + { + return executeQuietly(Arrays.asList("taskkill", "/PID", String.valueOf(pid), "/T", "/F")); + } + + private void killListeningProcesses(int port) + { + if (isWindows()) + { + killWindowsListeningProcesses(port); + } + else + { + executeQuietly(Arrays.asList("fuser", "-k", port + "/tcp")); + } + } + + private void killWindowsListeningProcesses(int port) + { + for (Integer pid : findWindowsListeningPids(port)) + { + killWindowsProcessTree(pid.longValue()); + } + } + + private List findWindowsListeningPids(int port) + { + Set pids = new LinkedHashSet(); + Process netstat = null; + BufferedReader reader = null; + try + { + netstat = new ProcessBuilder("netstat", "-ano", "-p", "tcp").redirectErrorStream(true).start(); + reader = new BufferedReader(new InputStreamReader(netstat.getInputStream(), Charset.defaultCharset())); + String line; + while ((line = reader.readLine()) != null) + { + Integer pid = parseWindowsListeningPid(line, port); + if (pid != null) + { + pids.add(pid); + } + } + waitForExit(netstat, GRACEFUL_WAIT_MILLIS); + } + catch (IOException ignored) + { + // Best-effort cleanup: process.destroy() above is still applied. + } + finally + { + closeQuietly(reader); + if (netstat != null && netstat.isAlive()) + { + netstat.destroyForcibly(); + } + } + return new ArrayList(pids); + } + + private Integer parseWindowsListeningPid(String line, int port) + { + String trimmed = line == null ? "" : line.trim(); + if (!trimmed.startsWith("TCP")) + { + return null; + } + String[] parts = trimmed.split("\\s+"); + if (parts.length < 5 || !"LISTENING".equalsIgnoreCase(parts[3]) || !matchesLocalPort(parts[1], port)) + { + return null; + } + try + { + return Integer.valueOf(parts[4]); + } + catch (NumberFormatException ignored) + { + return null; + } + } + + private boolean matchesLocalPort(String localAddress, int port) + { + int separator = localAddress == null ? -1 : localAddress.lastIndexOf(':'); + return separator >= 0 && String.valueOf(port).equals(localAddress.substring(separator + 1)); + } + + private boolean executeQuietly(List command) + { + Process process = null; + try + { + process = new ProcessBuilder(command).redirectErrorStream(true).start(); + if (!process.waitFor(GRACEFUL_WAIT_MILLIS, TimeUnit.MILLISECONDS)) + { + return false; + } + return process.exitValue() == 0; + } + catch (IOException ignored) + { + // Missing OS tools should not break the stop API. + return false; + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + return false; + } + finally + { + if (process != null && process.isAlive()) + { + process.destroyForcibly(); + } + } + } + + private void waitForExit(Process process, long timeoutMillis) + { + try + { + process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + } + + private void closeQuietly(BufferedReader reader) + { + if (reader == null) + { + return; + } + try + { + reader.close(); + } + catch (IOException ignored) + { + } + } + + private boolean isWindows() + { + String osName = System.getProperty("os.name"); + return osName != null && osName.toLowerCase().contains("win"); + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessRunner.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessRunner.java new file mode 100644 index 0000000..06b37e6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessRunner.java @@ -0,0 +1,515 @@ +package com.ruoyi.generator.service.front; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class RunPreviewProcessRunner +{ + private static final String SPRING_BOOT_RUN_GOAL = "org.springframework.boot:spring-boot-maven-plugin:2.5.15:run"; + private static final int VITE_MIN_NODE_MAJOR = 18; + private static final long NODE_VERSION_CHECK_TIMEOUT_SECONDS = 5L; + private static final Pattern NODE_VERSION_PATTERN = Pattern.compile("v?(\\d+)(?:\\.\\d+){0,2}"); + private static final String WINDOWS_PROCESS_WRAPPER = + "[System.IO.File]::WriteAllText(" + + "$env:EASYCODE_PREVIEW_PID_FILE," + + "[string]$PID," + + "[System.Text.Encoding]::ASCII" + + ");" + + "& $env:ComSpec /d /s /c $env:EASYCODE_PREVIEW_COMMAND;" + + "exit $LASTEXITCODE"; + + @Value("${easycode.preview.node-home:${EASYCODE_PREVIEW_NODE_HOME:}}") + private String nodeHome; + + public Process startBackend(File workspace, Map environment) throws IOException + { + return start("mvn spring-boot:run", workspace, environment); + } + + public Process startBackend(File previewBuildDirectory, File rootPom, File reactorPom, String backendModulePath, + File localRepository, Map environment) throws IOException + { + File localRepositoryParent = localRepository.getParentFile(); + if (localRepositoryParent != null && !localRepositoryParent.mkdirs() && !localRepositoryParent.isDirectory()) + { + throw new IOException("Unable to create preview Maven repository directory"); + } + String repositoryOption = shellQuote("-Dmaven.repo.local=" + localRepository.getAbsolutePath()); + String rootInstallCommand = "mvn " + repositoryOption + " -N -f " + shellQuote(rootPom.getAbsolutePath()) + " install"; + String backendRunCommand = "mvn " + repositoryOption + " -f " + shellQuote(reactorPom.getName()) + + " -pl " + shellQuote(backendModulePath) + " -am -DskipTests " + SPRING_BOOT_RUN_GOAL; + return start(rootInstallCommand + " && " + backendRunCommand, + previewBuildDirectory, environment); + } + + public Process startFrontend(File vueDirectory, Map environment) throws IOException + { + return start("npm install && npm run dev", vueDirectory, prepareFrontendEnvironment(vueDirectory, environment)); + } + + private Process start(String shellCommand, File workingDirectory, Map environment) throws IOException + { + if (isWindows()) + { + return startWindowsManaged(shellCommand, workingDirectory, environment); + } + ProcessBuilder builder = new ProcessBuilder(shellCommand(shellCommand)); + builder.directory(workingDirectory); + builder.redirectErrorStream(true); + if (environment != null) + { + builder.environment().putAll(environment); + } + return builder.start(); + } + + Process startCommandForTesting(String shellCommand, File workingDirectory, + Map environment) throws IOException + { + return start(shellCommand, workingDirectory, environment); + } + + private Process startWindowsManaged(String shellCommand, File workingDirectory, + Map environment) throws IOException + { + File pidFile = new File(workingDirectory, ".easycode-process-" + UUID.randomUUID() + ".pid"); + ProcessBuilder builder = new ProcessBuilder( + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + WINDOWS_PROCESS_WRAPPER); + builder.directory(workingDirectory); + builder.redirectErrorStream(true); + if (environment != null) + { + builder.environment().putAll(environment); + } + builder.environment().put("EASYCODE_PREVIEW_COMMAND", shellCommand); + builder.environment().put("EASYCODE_PREVIEW_PID_FILE", pidFile.getAbsolutePath()); + return new ManagedPreviewProcess(builder.start(), pidFile); + } + + Map prepareFrontendEnvironment(File vueDirectory, Map environment) throws IOException + { + Map result = new HashMap(); + if (environment != null) + { + result.putAll(environment); + } + + if (requiresModernNode(vueDirectory)) + { + prepareModernNodeEnvironment(result); + } + else if (hasText(nodeHome)) + { + prependNodeHome(result, normalizeNodeHome(nodeHome)); + } + return result; + } + + String pathEnvironmentKey() + { + String key = environmentKey(System.getenv()); + if (key != null) + { + return key; + } + return isWindows() ? "Path" : "PATH"; + } + + void setNodeHomeForTesting(String nodeHome) + { + this.nodeHome = nodeHome; + } + + private void prepareModernNodeEnvironment(Map environment) throws IOException + { + File configuredNodeHome = normalizeNodeHome(nodeHome); + if (configuredNodeHome != null) + { + assertCompatibleNodeHome(configuredNodeHome); + prependNodeHome(environment, configuredNodeHome); + return; + } + + NodeVersion currentVersion = readNodeVersion("node", environment); + if (currentVersion != null && currentVersion.isAtLeast(VITE_MIN_NODE_MAJOR)) + { + return; + } + + File detectedNodeHome = findCompatibleFnmNodeHome(); + if (detectedNodeHome != null) + { + prependNodeHome(environment, detectedNodeHome); + return; + } + + String actualVersion = currentVersion == null ? "not found" : currentVersion.display; + throw new IOException("Generated Vite frontend preview requires Node.js >= " + VITE_MIN_NODE_MAJOR + + ", but current node is " + actualVersion + + ". Install Node.js 18+ or set easycode.preview.node-home/EASYCODE_PREVIEW_NODE_HOME to the Node installation directory."); + } + + private boolean requiresModernNode(File vueDirectory) throws IOException + { + File packageJsonFile = new File(vueDirectory, "package.json"); + if (!packageJsonFile.isFile()) + { + return false; + } + + String content = new String(Files.readAllBytes(packageJsonFile.toPath()), StandardCharsets.UTF_8); + JSONObject packageJson = JSON.parseObject(content); + return hasDependency(packageJson.getJSONObject("dependencies"), "vite") + || hasDependency(packageJson.getJSONObject("devDependencies"), "vite") + || hasViteScript(packageJson.getJSONObject("scripts")); + } + + private boolean hasDependency(JSONObject dependencies, String dependencyName) + { + return dependencies != null && dependencies.containsKey(dependencyName); + } + + private boolean hasViteScript(JSONObject scripts) + { + if (scripts == null) + { + return false; + } + for (String key : scripts.keySet()) + { + String command = scripts.getString(key); + if (command != null && command.contains("vite")) + { + return true; + } + } + return false; + } + + private void assertCompatibleNodeHome(File candidate) throws IOException + { + File nodeExecutable = nodeExecutable(candidate); + if (nodeExecutable == null) + { + throw new IOException("Configured preview Node home does not contain a node executable: " + candidate.getAbsolutePath()); + } + + NodeVersion version = readNodeVersion(nodeExecutable.getAbsolutePath(), Collections.emptyMap()); + if (version == null || !version.isAtLeast(VITE_MIN_NODE_MAJOR)) + { + String actualVersion = version == null ? "not found" : version.display; + throw new IOException("Generated Vite frontend preview requires Node.js >= " + VITE_MIN_NODE_MAJOR + + ", but configured node is " + actualVersion + " at " + candidate.getAbsolutePath()); + } + } + + private File findCompatibleFnmNodeHome() throws IOException + { + String appData = System.getenv("APPDATA"); + if (!hasText(appData)) + { + return null; + } + + File nodeVersionsDirectory = new File(new File(appData, "fnm"), "node-versions"); + File[] versionDirectories = nodeVersionsDirectory.listFiles(); + if (versionDirectories == null) + { + return null; + } + + File bestHome = null; + NodeVersion bestVersion = null; + for (File versionDirectory : versionDirectories) + { + File candidateHome = fnmInstallationDirectory(versionDirectory); + if (candidateHome == null) + { + continue; + } + File executable = nodeExecutable(candidateHome); + if (executable == null) + { + continue; + } + NodeVersion version = readNodeVersion(executable.getAbsolutePath(), Collections.emptyMap()); + if (version != null && version.isAtLeast(VITE_MIN_NODE_MAJOR) + && (bestVersion == null || version.compareTo(bestVersion) > 0)) + { + bestHome = candidateHome; + bestVersion = version; + } + } + return bestHome; + } + + private File fnmInstallationDirectory(File versionDirectory) + { + if (versionDirectory == null || !versionDirectory.isDirectory() || !versionDirectory.getName().startsWith("v")) + { + return null; + } + File installationDirectory = new File(versionDirectory, "installation"); + return installationDirectory.isDirectory() ? installationDirectory : versionDirectory; + } + + private NodeVersion readNodeVersion(String nodeCommand, Map environment) throws IOException + { + ProcessBuilder builder = new ProcessBuilder(nodeCommand, "-v"); + builder.redirectErrorStream(true); + if (environment != null) + { + builder.environment().putAll(environment); + } + + Process process; + try + { + process = builder.start(); + } + catch (IOException e) + { + return null; + } + + try + { + if (!process.waitFor(NODE_VERSION_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + { + process.destroy(); + return null; + } + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + return null; + } + + StringBuilder output = new StringBuilder(); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + try + { + String line; + while ((line = reader.readLine()) != null) + { + output.append(line).append('\n'); + } + } + finally + { + reader.close(); + } + + if (process.exitValue() != 0) + { + return null; + } + return NodeVersion.parse(output.toString().trim()); + } + + private void prependNodeHome(Map environment, File candidate) + { + if (candidate == null) + { + return; + } + String key = pathEnvironmentKey(environment); + String current = environment.get(key); + if (current == null) + { + current = environmentValue(System.getenv(), key); + } + String value = candidate.getAbsolutePath(); + if (hasText(current)) + { + value += File.pathSeparator + current; + } + environment.put(key, value); + } + + private String pathEnvironmentKey(Map environment) + { + String key = environmentKey(environment); + return key == null ? pathEnvironmentKey() : key; + } + + private String environmentKey(Map environment) + { + for (String key : environment.keySet()) + { + if ("PATH".equalsIgnoreCase(key)) + { + return key; + } + } + return null; + } + + private String environmentValue(Map environment, String targetKey) + { + String value = environment.get(targetKey); + if (value != null) + { + return value; + } + for (Map.Entry entry : environment.entrySet()) + { + if (entry.getKey().equalsIgnoreCase(targetKey)) + { + return entry.getValue(); + } + } + return null; + } + + private File normalizeNodeHome(String value) + { + if (!hasText(value)) + { + return null; + } + File candidate = new File(value.trim()); + if (candidate.isFile()) + { + return candidate.getParentFile(); + } + return candidate; + } + + private File nodeExecutable(File nodeHome) + { + String[] names = isWindows() + ? new String[] { "node.exe", "node.cmd", "node.bat", "node" } + : new String[] { "node" }; + for (String name : names) + { + File candidate = new File(nodeHome, name); + if (candidate.isFile()) + { + return candidate; + } + } + return null; + } + + private boolean hasText(String value) + { + return value != null && value.trim().length() > 0; + } + + private List shellCommand(String command) + { + List result = new ArrayList(); + if (isWindows()) + { + result.add("cmd"); + result.add("/c"); + } + else + { + result.add("sh"); + result.add("-c"); + } + result.add(command); + return result; + } + + private String shellQuote(String value) + { + return "\"" + value.replace("\"", "\\\"") + "\""; + } + + private boolean isWindows() + { + String osName = System.getProperty("os.name"); + return osName != null && osName.toLowerCase().contains("win"); + } + + private static class NodeVersion + { + private final String display; + private final int major; + private final int minor; + private final int patch; + + private NodeVersion(String display, int major, int minor, int patch) + { + this.display = display; + this.major = major; + this.minor = minor; + this.patch = patch; + } + + private static NodeVersion parse(String output) + { + Matcher matcher = NODE_VERSION_PATTERN.matcher(output); + if (!matcher.find()) + { + return null; + } + String version = matcher.group(); + String[] parts = version.replace("v", "").split("\\."); + return new NodeVersion(version, parsePart(parts, 0), parsePart(parts, 1), parsePart(parts, 2)); + } + + private static int parsePart(String[] parts, int index) + { + if (parts.length <= index) + { + return 0; + } + try + { + return Integer.parseInt(parts[index]); + } + catch (NumberFormatException e) + { + return 0; + } + } + + private boolean isAtLeast(int expectedMajor) + { + return major >= expectedMajor; + } + + private int compareTo(NodeVersion other) + { + if (major != other.major) + { + return major - other.major; + } + if (minor != other.minor) + { + return minor - other.minor; + } + return patch - other.patch; + } + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessKillerTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessKillerTest.java new file mode 100644 index 0000000..19cf58d --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessKillerTest.java @@ -0,0 +1,120 @@ +package com.ruoyi.generator.service.front; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class RunPreviewProcessKillerTest +{ + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void stopRequestsWholeTreeTerminationForManagedProcess() throws Exception + { + File pidFile = temporaryFolder.newFile("preview.pid"); + Files.write(pidFile.toPath(), "4321".getBytes(StandardCharsets.UTF_8)); + FakeProcess delegate = new FakeProcess(); + ManagedPreviewProcess process = new ManagedPreviewProcess(delegate, pidFile); + RecordingProcessKiller killer = new RecordingProcessKiller(); + + killer.stop(process, null); + + assertEquals(Long.valueOf(4321L), killer.killedPid); + assertTrue(delegate.destroyed); + assertFalse(pidFile.exists()); + } + + @Test + public void stopFallsBackWhenManagedProcessHasNoPid() + { + File missingPidFile = new File(temporaryFolder.getRoot(), "missing.pid"); + FakeProcess delegate = new FakeProcess(); + ManagedPreviewProcess process = new ManagedPreviewProcess(delegate, missingPidFile); + RecordingProcessKiller killer = new RecordingProcessKiller(); + + killer.stop(process, null); + + assertNull(killer.killedPid); + assertTrue(delegate.destroyed); + } + + private static class RecordingProcessKiller extends RunPreviewProcessKiller + { + private Long killedPid; + + @Override + protected boolean killWindowsProcessTree(long pid) + { + killedPid = Long.valueOf(pid); + return true; + } + } + + private static class FakeProcess extends Process + { + private boolean alive = true; + private boolean destroyed; + + @Override + public OutputStream getOutputStream() + { + return new ByteArrayOutputStream(); + } + + @Override + public InputStream getInputStream() + { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public InputStream getErrorStream() + { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public int waitFor() + { + alive = false; + return 0; + } + + @Override + public int exitValue() + { + if (alive) + { + throw new IllegalThreadStateException(); + } + return 0; + } + + @Override + public void destroy() + { + alive = false; + destroyed = true; + } + + @Override + public boolean isAlive() + { + return alive; + } + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessRunnerTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessRunnerTest.java new file mode 100644 index 0000000..236f530 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessRunnerTest.java @@ -0,0 +1,107 @@ +package com.ruoyi.generator.service.front; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class RunPreviewProcessRunnerTest +{ + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void prepareFrontendEnvironmentPrependsConfiguredNodeHomeForViteProject() throws Exception + { + File projectDirectory = viteProject(); + File nodeHome = fakeNodeHome("v20.20.2"); + RunPreviewProcessRunner runner = new RunPreviewProcessRunner(); + runner.setNodeHomeForTesting(nodeHome.getAbsolutePath()); + + Map environment = runner.prepareFrontendEnvironment(projectDirectory, new HashMap()); + + assertTrue(environment.get(runner.pathEnvironmentKey()).startsWith(nodeHome.getAbsolutePath())); + } + + @Test + public void prepareFrontendEnvironmentRejectsOldConfiguredNodeForViteProject() throws Exception + { + File projectDirectory = viteProject(); + File nodeHome = fakeNodeHome("v14.21.3"); + RunPreviewProcessRunner runner = new RunPreviewProcessRunner(); + runner.setNodeHomeForTesting(nodeHome.getAbsolutePath()); + + try + { + runner.prepareFrontendEnvironment(projectDirectory, new HashMap()); + fail("Expected Node version validation to reject old Node"); + } + catch (IOException e) + { + assertTrue(e.getMessage().contains("requires Node.js >= 18")); + assertTrue(e.getMessage().contains("v14.21.3")); + } + } + + @Test + public void startCommandRecordsManagedWindowsProcessPid() throws Exception + { + if (!isWindows()) + { + return; + } + File projectDirectory = temporaryFolder.newFolder("managed-process"); + RunPreviewProcessRunner runner = new RunPreviewProcessRunner(); + + Process process = runner.startCommandForTesting( + "ping 127.0.0.1 -n 3 > nul", projectDirectory, new HashMap()); + + try + { + assertTrue(process instanceof ManagedPreviewProcess); + assertTrue(((ManagedPreviewProcess) process).waitForRootPid(2000L) > 0L); + } + finally + { + new RunPreviewProcessKiller().stop(process, null); + } + } + + private File viteProject() throws IOException + { + File projectDirectory = temporaryFolder.newFolder("vite-project"); + String packageJson = "{\n" + + " \"scripts\": { \"dev\": \"vite --host 0.0.0.0\" },\n" + + " \"dependencies\": { \"vite\": \"5.3.1\" }\n" + + "}"; + Files.write(new File(projectDirectory, "package.json").toPath(), packageJson.getBytes(StandardCharsets.UTF_8)); + return projectDirectory; + } + + private File fakeNodeHome(String version) throws IOException + { + File nodeHome = temporaryFolder.newFolder("node-" + version.substring(1)); + File nodeCommand = new File(nodeHome, isWindows() ? "node.cmd" : "node"); + String script = isWindows() + ? "@echo off\r\necho " + version + "\r\n" + : "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n"; + Files.write(nodeCommand.toPath(), script.getBytes(StandardCharsets.UTF_8)); + nodeCommand.setExecutable(true); + return nodeHome; + } + + private boolean isWindows() + { + String osName = System.getProperty("os.name"); + return osName != null && osName.toLowerCase().contains("win"); + } +}