From f975adbc1e520d2e290d5376bce6b8990cb34d43 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 19 Oct 2024 17:27:18 -0400 Subject: [PATCH] Add a shutdown hook which dumps diagnostic data when Recaf closed with a non-standard exit code --- .../java/software/coley/recaf/Bootstrap.java | 4 +- .../java/software/coley/recaf/ExitCodes.java | 16 +- .../coley/recaf/ExitDebugLoggingHook.java | 220 ++++++++++++++++++ .../services/file/RecafDirectoriesConfig.java | 8 +- .../coley/recaf/util/DesktopUtil.java | 12 +- .../main/java/software/coley/recaf/Main.java | 8 +- .../coley/recaf/RecafApplication.java | 2 +- .../coley/recaf/util/JFXValidation.java | 14 +- 8 files changed, 256 insertions(+), 28 deletions(-) create mode 100644 recaf-core/src/main/java/software/coley/recaf/ExitDebugLoggingHook.java diff --git a/recaf-core/src/main/java/software/coley/recaf/Bootstrap.java b/recaf-core/src/main/java/software/coley/recaf/Bootstrap.java index 5c7973c17..af929916a 100644 --- a/recaf-core/src/main/java/software/coley/recaf/Bootstrap.java +++ b/recaf-core/src/main/java/software/coley/recaf/Bootstrap.java @@ -35,7 +35,7 @@ public static Recaf get() { - Build rev: {} - Build date: {} - Build hash: {}"""; - logger.info(fmt, VERSION, GIT_REVISION, BUILD_DATE, GIT_SHA); + logger.info(fmt, VERSION, GIT_REVISION, GIT_DATE, GIT_SHA); long then = System.currentTimeMillis(); // Create the Recaf container @@ -45,7 +45,7 @@ public static Recaf get() { logger.info("Recaf CDI container created in {}ms", System.currentTimeMillis() - then); } catch (Throwable t) { logger.error("Failed to create Recaf CDI container", t); - System.exit(ExitCodes.ERR_CDI_INIT_FAILURE); + ExitDebugLoggingHook.exit(ExitCodes.ERR_CDI_INIT_FAILURE); } } return instance; diff --git a/recaf-core/src/main/java/software/coley/recaf/ExitCodes.java b/recaf-core/src/main/java/software/coley/recaf/ExitCodes.java index bdad19243..24bf40ea9 100644 --- a/recaf-core/src/main/java/software/coley/recaf/ExitCodes.java +++ b/recaf-core/src/main/java/software/coley/recaf/ExitCodes.java @@ -7,14 +7,14 @@ */ public class ExitCodes { public static final int SUCCESS = 0; - public static final int ERR_UNKNOWN = 100; - public static final int ERR_CLASS_NOT_FOUND = 101; - public static final int ERR_NO_SUCH_METHOD = 102; - public static final int ERR_INVOKE_TARGET = 103; - public static final int ERR_ACCESS_TARGET = 104; - public static final int ERR_OLD_JFX_VERSION = 105; - public static final int ERR_UNKNOWN_JFX_VERSION = 106; - public static final int ERR_CDI_INIT_FAILURE = 107; + public static final int ERR_FX_UNKNOWN = 100; + public static final int ERR_FX_CLASS_NOT_FOUND = 101; + public static final int ERR_FX_NO_SUCH_METHOD = 102; + public static final int ERR_FX_INVOKE_TARGET = 103; + public static final int ERR_FX_ACCESS_TARGET = 104; + public static final int ERR_FX_OLD_VERSION = 105; + public static final int ERR_FX_UNKNOWN_VERSION = 106; public static final int INTELLIJ_TERMINATION = 130; + public static final int ERR_CDI_INIT_FAILURE = 150; } diff --git a/recaf-core/src/main/java/software/coley/recaf/ExitDebugLoggingHook.java b/recaf-core/src/main/java/software/coley/recaf/ExitDebugLoggingHook.java new file mode 100644 index 000000000..f643b2a2b --- /dev/null +++ b/recaf-core/src/main/java/software/coley/recaf/ExitDebugLoggingHook.java @@ -0,0 +1,220 @@ +package software.coley.recaf; + +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import jakarta.annotation.Nonnull; +import org.slf4j.Logger; +import software.coley.recaf.analytics.logging.Logging; +import software.coley.recaf.services.file.RecafDirectoriesConfig; +import software.coley.recaf.util.JavaVersion; +import software.coley.recaf.util.LookupUtil; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A hook registered to {@code java.lang.Shutdown} and not {@link Runtime#addShutdownHook(Thread)}. + * This allows us to use the {@link StackWalker} API to detect the exit code from {@link System#exit(int)} + * + * @author Matt Coley + */ +public class ExitDebugLoggingHook { + private static final int UNKNOWN_CODE = -1337420; + private static final Logger logger = Logging.get(ExitDebugLoggingHook.class); + private static int exitCode = UNKNOWN_CODE; + private static boolean printConfigs; + private static Thread mainThread; + private static MethodHandles.Lookup lookup; + + /** + * Register the shutdown hook. + */ + public static void register() { + lookup = LookupUtil.lookup(); + try { + // We use this instead of the Runtime shutdown hook thread because this will run on the same thread + // as the call to System.exit(int) + Class shutdown = lookup.findClass("java.lang.Shutdown"); + MethodHandle add = lookup.findStatic(shutdown, "add", MethodType.methodType(void.class, int.class, boolean.class, Runnable.class)); + add.invoke(9, false, (Runnable) ExitDebugLoggingHook::run); + } catch (Throwable t) { + logger.error("Failed to add exit-hooking debug dumping shutdown hook", t); + + // Use fallback shutdown hook which checks for manual exit codes being set. + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + if (exitCode != UNKNOWN_CODE) + handle(exitCode); + })); + } + } + + private static void run() { + // If we've set the exit code manually, use that. + if (exitCode != UNKNOWN_CODE) { + handle(exitCode); + return; + } + + // We didn't do the exit, so lets try and see if we can figure out who did it. + try { + AtomicBoolean visited = new AtomicBoolean(); + Class shutdown = lookup.findClass("java.lang.Shutdown"); + Class stackFrameClass = Class.forName("java.lang.LiveStackFrame"); + MethodHandle getLocals = lookup.findVirtual(stackFrameClass, "getLocals", MethodType.methodType(Object[].class)) + .asType(MethodType.methodType(Object[].class, Object.class)); + Method getStackWalker = stackFrameClass.getDeclaredMethod("getStackWalker", Set.class); + getStackWalker.setAccessible(true); + StackWalker stackWalker = (StackWalker) getStackWalker.invoke(null, Set.of(StackWalker.Option.RETAIN_CLASS_REFERENCE, StackWalker.Option.SHOW_HIDDEN_FRAMES)); + stackWalker.forEach(frame -> { + try { + if (!visited.get() && frame.getDeclaringClass() == shutdown && frame.getMethodName().equals("exit")) { + Object[] locals = (Object[]) getLocals.invoke(frame); + handle(Integer.parseInt(String.valueOf(locals[0]))); + visited.set(true); + } + } catch (Throwable t) { + throw new IllegalStateException(t); + } + }); + } catch (Throwable t) { + // If this cursed abomination breaks, we want to know about it + logger.error(""" + Failed to detect application exit code. + + Please report that the exit debugger has failed. + + https://github.com/Col-E/Recaf/issues/new?labels=bug&title=Error%20Debugger%20Hook%20fails%20on%20Java%20{V} + """.replace("{V}", String.valueOf(JavaVersion.get())), t); + } + } + + private static void handle(int code) { + // Skip on successful closure + if (code == ExitCodes.SUCCESS || code == ExitCodes.INTELLIJ_TERMINATION) + return; + + if (code == UNKNOWN_CODE) + System.out.println("Exit code: "); + else + System.out.println("Exit code: " + code); + + System.out.println("Java"); + System.out.println(" - Version (Runtime): " + System.getProperty("java.runtime.version", "")); + System.out.println(" - Version (Raw): " + JavaVersion.get()); + System.out.println(" - Vendor: " + System.getProperty("java.vm.vendor", "")); + System.out.println(" - Home: " + System.getProperty("java.home", "")); + System.out.println("JavaFX"); + System.out.println(" - Version (Runtime): " + System.getProperty("javafx.runtime.version", "")); + System.out.println(" - Version (Raw): " + System.getProperty("javafx.version", "")); + System.out.println("Operating System"); + System.out.println(" - Name: " + System.getProperty("os.name")); + System.out.println(" - Version: " + System.getProperty("os.version")); + System.out.println(" - Architecture: " + System.getProperty("os.arch")); + System.out.println(" - Processors: " + Runtime.getRuntime().availableProcessors()); + System.out.println(" - Path Separator: " + File.pathSeparator); + System.out.println("Recaf" + JavaVersion.get()); + System.out.println(" - Version: " + RecafBuildConfig.VERSION); + System.out.println(" - Build hash: " + RecafBuildConfig.GIT_SHA); + System.out.println(" - Build date: " + RecafBuildConfig.GIT_DATE); + + String command = System.getProperty("sun.java.command", ""); + if (command != null) { + System.out.println("Launch"); + System.out.println(" - Args: " + command); + } + + String[] classPath = System.getProperty("java.class.path").split(File.pathSeparator); + System.out.println("Classpath:"); + for (String pathEntry : classPath) { + File file = new File(pathEntry); + if (file.isFile()) { + System.out.println(" - File: " + pathEntry); + try { + System.out.println(" - SHA1: " + createSha1(file)); + } catch (Exception ex) { + System.out.println(" - SHA1: "); + } + } else if (file.isDirectory()) { + System.out.println(" - Directory: " + pathEntry); + } + } + + Path root = RecafDirectoriesConfig.createBaseDirectory().resolve("config"); + if (printConfigs && Files.isDirectory(root)) { + System.out.println("Configs"); + try (Stream stream = Files.walk(root)) { + stream.filter(path -> path.getFileName().toString().endsWith("-config.json")).forEach(path -> { + String configName = path.getFileName().toString(); + + // Skip certain configs + if (configName.contains("service.ui.bind-")) + return; + if (configName.contains("service.ui.snippets-config")) + return; + if (configName.contains("service.io.recent-workspaces-config")) + return; + if (configName.contains("service.decompile.impl.decompiler-")) + return; + + System.out.println(" - " + configName + ":"); + try { + String indent = " "; + String configJson = Files.readString(path); + String indented = indent + Arrays.stream(configJson.split("\n")) + .collect(Collectors.joining("\n" + indent)); + System.out.println(indented); + } catch (Throwable t) { + System.out.println(" - "); + } + }); + } catch (Exception ex) { + System.out.println(" - "); + } + } + + System.out.println("Threads"); + Thread.getAllStackTraces().forEach((thread, trace) -> { + System.out.println(" - " + thread.getName() + " [" + thread.getState().name() + "]"); + for (StackTraceElement element : trace) { + System.out.println(" - " + element); + } + }); + } + + @Nonnull + @SuppressWarnings("all") + private static String createSha1(@Nonnull File file) throws Exception { + long length = file.length(); + if (length > Integer.MAX_VALUE) + throw new IOException("File too large to hash"); + Hasher hasher = Hashing.sha1().newHasher((int) length); + try (InputStream fis = new FileInputStream(file)) { + int n = 0; + byte[] buffer = new byte[8192]; + while (n != -1) { + n = fis.read(buffer); + if (n > 0) + hasher.putBytes(buffer, 0, n); + } + return hasher.hash().toString(); + } + } + + public static void exit(int exitCode) { + ExitDebugLoggingHook.exitCode = exitCode; + System.exit(exitCode); + } +} diff --git a/recaf-core/src/main/java/software/coley/recaf/services/file/RecafDirectoriesConfig.java b/recaf-core/src/main/java/software/coley/recaf/services/file/RecafDirectoriesConfig.java index f0e6f6138..d51d1ed81 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/file/RecafDirectoriesConfig.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/file/RecafDirectoriesConfig.java @@ -149,8 +149,11 @@ private Path resolveDirectory(@Nonnull String dir) { return path; } + /** + * @return Root directory for storing Recaf data. + */ @Nonnull - private static Path createBaseDirectory() { + public static Path createBaseDirectory() { // Try system property first String recafDir = System.getProperty("RECAF_DIR"); if (recafDir == null) // Next try looking for an environment variable @@ -160,9 +163,8 @@ private static Path createBaseDirectory() { // The directories library can break on some version of windows, but it will always // resolve to '%APPDATA%' at the end of the day. So we'll just do that ourselves here, - if (PlatformType.get() == PlatformType.WINDOWS) { + if (PlatformType.get() == PlatformType.WINDOWS) return Paths.get(System.getenv("APPDATA"), "Recaf"); - } // Use generic data/config location try { diff --git a/recaf-core/src/main/java/software/coley/recaf/util/DesktopUtil.java b/recaf-core/src/main/java/software/coley/recaf/util/DesktopUtil.java index 4b4b9763d..e5836a789 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/DesktopUtil.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/DesktopUtil.java @@ -1,8 +1,10 @@ package software.coley.recaf.util; import jakarta.annotation.Nonnull; +import software.coley.recaf.analytics.SystemInformation; -import java.awt.*; +import java.awt.Dimension; +import java.awt.Toolkit; import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -35,11 +37,11 @@ public static Dimension getScreenSize() { * to be launched. */ public static void showDocument(@Nonnull URI uri) throws IOException { + final Runtime rt = Runtime.getRuntime(); switch (PlatformType.get()) { - case MAC -> Runtime.getRuntime().exec("open " + uri); - case WINDOWS -> Runtime.getRuntime().exec("rundll32 url.dll,FileProtocolHandler " + uri); + case MAC -> rt.exec(new String[]{"open", uri.toString()}); + case WINDOWS -> rt.exec(new String[]{"rundll32", "url.dll,FileProtocolHandler", uri.toString()}); case LINUX -> { - Runtime rt = Runtime.getRuntime(); String[] browsers = new String[]{"xdg-open", "google-chrome", "firefox", "opera", "konqueror", "mozilla"}; for (String browser : browsers) { try (InputStream in = rt.exec(new String[]{"which", browser}).getInputStream()) { @@ -51,7 +53,7 @@ public static void showDocument(@Nonnull URI uri) throws IOException { } throw new IOException("No browser found"); } - default -> throw new IllegalStateException("Unsupported OS"); + default -> throw new IllegalStateException("Unsupported OS: " + SystemInformation.OS_NAME); } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/Main.java b/recaf-ui/src/main/java/software/coley/recaf/Main.java index be1d8ab8f..415db1719 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/Main.java +++ b/recaf-ui/src/main/java/software/coley/recaf/Main.java @@ -11,9 +11,9 @@ import software.coley.recaf.launch.LaunchArguments; import software.coley.recaf.launch.LaunchCommand; import software.coley.recaf.launch.LaunchHandler; +import software.coley.recaf.services.file.RecafDirectoriesConfig; import software.coley.recaf.services.plugin.PluginContainer; import software.coley.recaf.services.plugin.PluginException; -import software.coley.recaf.services.file.RecafDirectoriesConfig; import software.coley.recaf.services.plugin.PluginManager; import software.coley.recaf.services.plugin.discovery.DirectoryPluginDiscoverer; import software.coley.recaf.services.script.ScriptEngine; @@ -52,6 +52,10 @@ public class Main { * Application arguments. */ public static void main(String[] args) { + // Add a shutdown hook which dumps system information to console. + // Should provide useful information that users can copy/paste to us for diagnosing problems. + ExitDebugLoggingHook.register(); + // Add a class reference for our UI module. Bootstrap.setWeldConsumer(weld -> weld.addPackage(true, Main.class)); @@ -71,7 +75,7 @@ public static void main(String[] args) { if (!launchArgValues.isHeadless()) { int validationCode = JFXValidation.validateJFX(); if (validationCode != 0) { - System.exit(validationCode); + ExitDebugLoggingHook.exit(validationCode); return; } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java b/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java index c9a4462f8..6db6ee748 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java +++ b/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java @@ -89,7 +89,7 @@ public void start(Stage stage) { stage.setScene(scene); stage.getIcons().add(Icons.getImage(Icons.LOGO)); stage.setTitle("Recaf"); - stage.setOnCloseRequest(e -> System.exit(0)); + stage.setOnCloseRequest(e -> ExitDebugLoggingHook.exit(0)); stage.show(); // Register main window diff --git a/recaf-ui/src/main/java/software/coley/recaf/util/JFXValidation.java b/recaf-ui/src/main/java/software/coley/recaf/util/JFXValidation.java index ce9272638..b5efb412a 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/util/JFXValidation.java +++ b/recaf-ui/src/main/java/software/coley/recaf/util/JFXValidation.java @@ -33,30 +33,30 @@ public static int validateJFX() { int majorVersion = Integer.parseInt(versionMatcher.group()); if (majorVersion < MIN_JFX_VERSION) { logger.error("JavaFX version {} is present, but Recaf requires {}+", majorVersion, MIN_JFX_VERSION); - return ExitCodes.ERR_OLD_JFX_VERSION; + return ExitCodes.ERR_FX_OLD_VERSION; } } else { logger.error("JavaFX version {} does not declare a major release version, cannot validate compatibility", versionProperty); - return ExitCodes.ERR_UNKNOWN_JFX_VERSION; + return ExitCodes.ERR_FX_UNKNOWN_VERSION; } logger.info("JavaFX successfully initialized: {}", versionProperty); return ExitCodes.SUCCESS; } catch (ClassNotFoundException ex) { logger.error("JFX validation failed, could not find 'VersionInfo' class", ex); - return ExitCodes.ERR_CLASS_NOT_FOUND; + return ExitCodes.ERR_FX_CLASS_NOT_FOUND; } catch (NoSuchMethodException ex) { logger.error("JFX validation failed, could not find 'setupSystemProperties' in 'VersionInfo'", ex); - return ExitCodes.ERR_NO_SUCH_METHOD; + return ExitCodes.ERR_FX_NO_SUCH_METHOD; } catch (InvocationTargetException ex) { logger.error("JFX validation failed, failed to invoke 'setupSystemProperties'", ex); - return ExitCodes.ERR_INVOKE_TARGET; + return ExitCodes.ERR_FX_INVOKE_TARGET; } catch (IllegalAccessException | InaccessibleObjectException ex) { logger.error("JFX validation failed, failed to invoke 'setupSystemProperties'", ex); - return ExitCodes.ERR_ACCESS_TARGET; + return ExitCodes.ERR_FX_ACCESS_TARGET; } catch (Exception ex) { logger.error("JFX validation failed due to unhandled exception", ex); - return ExitCodes.ERR_UNKNOWN; + return ExitCodes.ERR_FX_UNKNOWN; } } } \ No newline at end of file