Skip to content
25 changes: 20 additions & 5 deletions SpecsUtils/src/pt/up/fe/specs/util/SpecsGraphviz.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ public class SpecsGraphviz {
private static final Lazy<Boolean> IS_DOT_AVAILABLE = Lazy.newInstance(SpecsGraphviz::checkDot);

private static boolean checkDot() {
var result = SpecsSystem.runProcess(Arrays.asList("dot", "-?"), false, false);
return result.getReturnValue() == 0;
try {
var result = SpecsSystem.runProcess(Arrays.asList("dot", "-?"), false, false);
return result.getReturnValue() == 0;
} catch (RuntimeException e) {
// The launch itself failed, e.g. Graphviz is not installed
return false;
Comment on lines +35 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict the availability fallback to launch failures

If the first isDotAvailable() call is interrupted while waiting for an installed dot process, SpecsSystem.runProcess() throws a RuntimeException, but this catch converts it to false and the lazy value permanently caches Graphviz as unavailable. Other post-launch runtime failures are likewise hidden. As in renderDot(), only exceptions satisfying SpecsSystem.isLaunchFailure(e) should be treated as an absent installation; unrelated failures should propagate.

Useful? React with 👍 / 👎.

}
}

public static boolean isDotAvailable() {
Expand All @@ -47,9 +52,19 @@ public static void renderDot(File dotFile, DotRenderFormat format, File outputFi
var command = Arrays.asList("dot", format.getFlag(), dotFile.getAbsolutePath(), "-o",
outputFile.getAbsolutePath());

var result = SpecsSystem.runProcess(command, false, false);
if (result.getReturnValue() == 0) {
SpecsLogs.debug(() -> "Rendered dot file '" + dotFile.getAbsolutePath() + "' as " + format);
try {
var result = SpecsSystem.runProcess(command, false, false);
if (result.getReturnValue() == 0) {
SpecsLogs.debug(() -> "Rendered dot file '" + dotFile.getAbsolutePath() + "' as " + format);
}
} catch (RuntimeException e) {
// Only a missing Graphviz installation is skipped; any other
// failure is rethrown
if (!SpecsSystem.isLaunchFailure(e)) {
throw e;
}

SpecsLogs.msgInfo("Graphviz not available, could not render dot file '" + dotFile.getAbsolutePath() + "'");
}
}

Expand Down
62 changes: 62 additions & 0 deletions SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.UncheckedIOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
Expand All @@ -40,8 +41,10 @@
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
Expand Down Expand Up @@ -1910,6 +1913,65 @@ public static File getTempFolder(String folderName) {
return mkdir(systemTemp, folderName);
}

/** Creates a unique OS temporary directory. The caller deletes it when done. */
public static File createTempDirectory(String prefix) {
try {
return Files.createTempDirectory(prefix).toFile();
} catch (IOException e) {
throw new UncheckedIOException("Could not create temporary directory with prefix '" + prefix + "'", e);
}
}

/** Deletes a temporary directory and its contents without traversing links. */
public static void deleteTempDirectory(File directory) {
try {
deleteTempDirectory(directory.toPath());
} catch (IOException e) {
throw new UncheckedIOException("Could not delete temporary directory '" + directory + "'", e);
}
}

private static void deleteTempDirectory(Path path) throws IOException {
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
return;
}

BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
// Windows junctions are directories and "other"; delete the link without opening it.
if (attributes.isDirectory() && !attributes.isOther()) {
try (DirectoryStream<Path> children = Files.newDirectoryStream(path)) {
for (Path child : children) {
deleteTempDirectory(child);
}
}
}
Files.delete(path);
}

/** Returns the user cache directory, creating it if needed. */
public static File getOsCacheFolder() {
String cacheDir;
if (SpecsSystem.isWindows()) {
String localAppData = System.getenv("LOCALAPPDATA");
cacheDir = localAppData != null && !localAppData.isBlank() ? localAppData
: new File(System.getProperty("user.home"), "AppData/Local").getAbsolutePath();
} else if (SpecsSystem.isMac()) {
cacheDir = new File(System.getProperty("user.home"), "Library/Caches").getAbsolutePath();
} else {
String xdgCacheHome = System.getenv("XDG_CACHE_HOME");
String home = System.getenv("HOME");
String fallbackHome = home != null && !home.isBlank() && new File(home).isAbsolute()
? home
: System.getProperty("user.home");
cacheDir = xdgCacheHome != null && !xdgCacheHome.isBlank() && new File(xdgCacheHome).isAbsolute()
? xdgCacheHome
: new File(fallbackHome, ".cache").getAbsolutePath();
}

return SpecsIo.mkdir(new File(cacheDir));
}

/**
* List directory contents for a resource folder. Not recursive. This is
* basically a brute-force implementation.
Expand Down
55 changes: 31 additions & 24 deletions SpecsUtils/src/pt/up/fe/specs/util/SpecsSystem.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public class SpecsSystem {

private static final boolean IS_LINUX = System.getProperty("os.name").toLowerCase().startsWith("linux");
private static final boolean IS_WINDOWS = System.getProperty("os.name").startsWith("Windows");
private static final boolean IS_MAC = System.getProperty("os.name").toLowerCase().contains("mac");

private static final Map<String, Method> CACHED_METHODS = new HashMap<>();
private static final Map<String, Optional<Field>> CACHED_FIELDS = new HashMap<>();
Expand Down Expand Up @@ -190,15 +191,7 @@ public static <O, E> ProcessOutput<O, E> runProcess(ProcessBuilder builder,

Process process;
try {
// Experiment: Calling Garbage Collector before starting process in order to
// reduce memory required to fork VM
// http://www.bryanmarty.com/2012/01/14/forking-jvm/
long totalMemBefore = Runtime.getRuntime().totalMemory();
System.gc();
long totalMemAfter = Runtime.getRuntime().totalMemory();
SpecsLogs.msgLib("Preparing to run process, memory before -> after GC: "
+ SpecsStrings.parseSize(totalMemBefore) + " -> " + SpecsStrings.parseSize(totalMemAfter));
process = builder.start();
process = builder.start();

} catch (IOException e) {
throw new RuntimeException("Could not start process", e);
Expand Down Expand Up @@ -254,19 +247,6 @@ private static void processCommand(ProcessBuilder builder) {
newCommand.add("/c");
newCommand.addAll(builder.command());

builder.command(newCommand);
} else if (isLinux()) {
// Update command
List<String> newCommand = new ArrayList<>(4);
newCommand.add("bash");
// Same user
newCommand.add("-l");
// Command
newCommand.add("-c");
newCommand.add(builder.command().stream()
.map(arg -> arg.replace(" ", "\\ "))
.collect(Collectors.joining(" ")));

builder.command(newCommand);
}

Expand Down Expand Up @@ -525,15 +505,14 @@ interface Returnable<T> {
}

/**
* @param callGc whether to request garbage collection before measuring
* @return the current amount of memory, in bytes
*/
public static long getUsedMemory(boolean callGc) {
if (callGc) {
System.gc();
}

System.gc();

return Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
}

Expand Down Expand Up @@ -599,6 +578,26 @@ public static boolean isCommandAvailable(List<String> command, File workingdir)

}

/**
* Checks if the throwable was caused by a failure to launch the process
* itself (e.g., command not found or not executable), as opposed to a
* failure after a successful launch.
*
* @param throwable
* @return
*/
public static boolean isLaunchFailure(Throwable throwable) {
for (Throwable currentThrowable = throwable; currentThrowable != null; currentThrowable = currentThrowable
.getCause()) {
if (currentThrowable instanceof IOException) {
return true;
}
}

return false;
}


/**
* Adds a path to the java.library.path property, and flushes the path cache so
* that subsequent System.load calls
Expand Down Expand Up @@ -875,6 +874,14 @@ public static boolean isWindows() {
return IS_WINDOWS;
}

/**
* @return true if the JVM is currently executing in a macOS system, false
* otherwise
*/
public static boolean isMac() {
return IS_MAC;
}

/**
* Equivalent to class.isInstance.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.io.File;
import java.util.List;

import pt.up.fe.specs.util.SpecsLogs;
import pt.up.fe.specs.util.SpecsSystem;

/**
Expand Down Expand Up @@ -45,7 +46,20 @@ public ProcessExecution(List<String> commandArgs, String workingFoldername) {
*/
@Override
public int run() {
return SpecsSystem.run(this.commandArgs, new File(this.workingFoldername));
try {
return SpecsSystem.run(this.commandArgs, new File(this.workingFoldername));
} catch (RuntimeException e) {
// Only a launch failure due to a missing command maps to a return
// code (the shell convention of 127, 'command not found'). Any
// other failure is not representable as an exit code and is
// rethrown, as the process may even have been running.
if (!SpecsSystem.isLaunchFailure(e)) {
throw e;
}

SpecsLogs.msgInfo("Could not run command '" + getCommandString() + "': " + e.getMessage());
return 127;
}
}

@Override
Expand Down
65 changes: 65 additions & 0 deletions SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystemException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
Expand All @@ -25,6 +26,7 @@
import java.util.Optional;
import java.util.Set;

import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -1041,6 +1043,69 @@ void testTempFileOperations() {
assertThat(randomFolder).isNotNull();
}

@Test
@DisplayName("Test OS-created temp directory")
void testCreateTempDirectory() {
File first = SpecsIo.createTempDirectory("specs-test-");
File second = SpecsIo.createTempDirectory("specs-test-");
try {
assertThat(first).isDirectory();
assertThat(first.getName()).startsWith("specs-test-");
assertThat(first.getParentFile())
.isEqualTo(new File(System.getProperty("java.io.tmpdir")).getAbsoluteFile());
assertThat(second).isNotEqualTo(first);
} finally {
SpecsIo.deleteTempDirectory(first);
SpecsIo.deleteTempDirectory(second);
}
}

@Test
@DisplayName("Temp cleanup deletes nested contents")
void testDeleteTempDirectory() throws IOException {
File directory = SpecsIo.createTempDirectory("specs-test-");
try {
Path nested = Files.createDirectory(directory.toPath().resolve("nested"));
Files.writeString(nested.resolve("file.txt"), "content");

SpecsIo.deleteTempDirectory(directory);
assertThat(directory).doesNotExist();
} finally {
SpecsIo.deleteTempDirectory(directory);
}
}

@Test
@DisplayName("Temp cleanup does not follow directory symlinks")
void testDeleteTempDirectorySymlink(@TempDir Path fixture) throws IOException {
Path outsideFile = Files.writeString(fixture.resolve("keep.txt"), "keep");
File directory = SpecsIo.createTempDirectory("specs-test-");
try {
try {
Files.createSymbolicLink(directory.toPath().resolve("outside"), fixture);
} catch (FileSystemException | UnsupportedOperationException | SecurityException e) {
Assumptions.assumeTrue(false, "Symbolic links unavailable: " + e.getMessage());
}

SpecsIo.deleteTempDirectory(directory);
assertThat(directory).doesNotExist();
assertThat(outsideFile).exists();
} finally {
SpecsIo.deleteTempDirectory(directory);
}
}

@Test
@DisplayName("Test OS cache folder")
void testGetOsCacheFolder() {
File cacheFolder = SpecsIo.getOsCacheFolder();
assertThat(cacheFolder).isNotNull();
assertThat(cacheFolder.isDirectory()).isTrue();

// Same call returns the same location
assertThat(SpecsIo.getOsCacheFolder()).isEqualTo(cacheFolder);
}

@Test
@DisplayName("Test ZIP operations")
void testZipOperations(@TempDir Path tempDir) throws IOException {
Expand Down
26 changes: 26 additions & 0 deletions SpecsUtils/test/pt/up/fe/specs/util/SpecsSystemTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@
import static org.assertj.core.api.Assertions.*;

import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ThreadFactory;

import jdk.jfr.Recording;
import jdk.jfr.consumer.RecordingFile;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -443,6 +448,27 @@ void testIsAvailable() {
@DisplayName("Memory and Performance")
class MemoryPerformance {

@Test
@DisplayName("getUsedMemory should only request GC when asked")
void testGetUsedMemoryGcRequests(@TempDir Path tempDir) throws IOException {
assertThat(countGcRequests(false, tempDir.resolve("without-gc.jfr"))).isZero();
assertThat(countGcRequests(true, tempDir.resolve("with-gc.jfr"))).isEqualTo(1);
}

private long countGcRequests(boolean callGc, Path recordingPath) throws IOException {
try (var recording = new Recording()) {
recording.enable("jdk.SystemGC");
recording.start();
SpecsSystem.getUsedMemory(callGc);
recording.stop();
recording.dump(recordingPath);
}

return RecordingFile.readAllEvents(recordingPath).stream()
.filter(event -> event.getEventType().getName().equals("jdk.SystemGC"))
.count();
}

@Test
@DisplayName("getUsedMemory should return positive value")
void testGetUsedMemory() {
Expand Down
1 change: 0 additions & 1 deletion jOptions/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ repositories {
dependencies {
implementation ':SpecsUtils'
implementation ':XStreamPlus'
implementation ':GuiHelper'

implementation 'com.google.guava:guava:33.4.0-jre'
implementation 'com.google.code.gson:gson:2.12.1'
Expand Down
Loading
Loading