Fix run preview process tree cleanup
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<Integer> findWindowsListeningPids(int port)
|
||||
{
|
||||
Set<Integer> pids = new LinkedHashSet<Integer>();
|
||||
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<Integer>(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<String> 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");
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<String, String> 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<String, String> environment) throws IOException
|
||||
{
|
||||
return start("npm install && npm run dev", vueDirectory, prepareFrontendEnvironment(vueDirectory, environment));
|
||||
}
|
||||
|
||||
private Process start(String shellCommand, File workingDirectory, Map<String, String> 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<String, String> environment) throws IOException
|
||||
{
|
||||
return start(shellCommand, workingDirectory, environment);
|
||||
}
|
||||
|
||||
private Process startWindowsManaged(String shellCommand, File workingDirectory,
|
||||
Map<String, String> 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<String, String> prepareFrontendEnvironment(File vueDirectory, Map<String, String> environment) throws IOException
|
||||
{
|
||||
Map<String, String> result = new HashMap<String, String>();
|
||||
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<String, String> 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.<String, String>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.<String, String>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<String, String> 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<String, String> 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<String, String> environment)
|
||||
{
|
||||
String key = environmentKey(environment);
|
||||
return key == null ? pathEnvironmentKey() : key;
|
||||
}
|
||||
|
||||
private String environmentKey(Map<String, String> environment)
|
||||
{
|
||||
for (String key : environment.keySet())
|
||||
{
|
||||
if ("PATH".equalsIgnoreCase(key))
|
||||
{
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String environmentValue(Map<String, String> environment, String targetKey)
|
||||
{
|
||||
String value = environment.get(targetKey);
|
||||
if (value != null)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
for (Map.Entry<String, String> 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<String> shellCommand(String command)
|
||||
{
|
||||
List<String> result = new ArrayList<String>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> environment = runner.prepareFrontendEnvironment(projectDirectory, new HashMap<String, String>());
|
||||
|
||||
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<String, String>());
|
||||
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<String, String>());
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user