org.apache.maven.plugins
maven-surefire-plugin
diff --git a/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java b/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java
index b1e2e62..3fb6e65 100644
--- a/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java
+++ b/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java
@@ -14,7 +14,7 @@ import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import javax.imageio.ImageIO;
-import java.awt.Taskbar;
+import java.awt.*;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
@@ -39,7 +39,9 @@ public class PholioFxApplication extends Application {
private @Nullable ConfigurableApplicationContext context;
- /** Feeds {@code SplashPreloader} while the context builds — see {@link StartupProgressReporter}. */
+ /**
+ * Feeds {@code SplashPreloader} while the context builds — see {@link StartupProgressReporter}.
+ */
private final StartupProgressReporter startupProgress = new StartupProgressReporter(this::notifyPreloader);
@Override
@@ -112,7 +114,8 @@ public class PholioFxApplication extends Application {
try (InputStream in = PholioFxApplication.class.getResourceAsStream("/images/spo-macos-1024x1024.png")) {
taskbar.setIconImage(ImageIO.read(Objects.requireNonNull(in, "spo-macos-1024x1024.png missing")));
log.debug("Dock icon set");
- } catch (IOException | RuntimeException e) {
+ }
+ catch (IOException | RuntimeException e) {
log.warn("Could not set dock icon", e);
}
}
diff --git a/src/main/java/org/icroco/pholio/ui/common/FxThread.java b/src/main/java/org/icroco/pholio/ui/common/FxThread.java
index 8841c10..9c3d0a2 100644
--- a/src/main/java/org/icroco/pholio/ui/common/FxThread.java
+++ b/src/main/java/org/icroco/pholio/ui/common/FxThread.java
@@ -10,9 +10,10 @@ import java.lang.annotation.Target;
* Marks a method — or, on a class, every method it declares — that must always run on the JavaFX
* Application Thread, replacing a hand-written {@link FxUtils#onFxThread} wrapper around the body.
*
- * Enforced by {@link FxThreadAgent}, a Byte Buddy agent that self-attaches at JVM startup and weaves
- * every matching method as it loads — not a runtime check, not a Spring proxy, and not a build-time Maven
- * plugin. None of those were arbitrary rejections:
+ *
Enforced by Byte Buddy bytecode weaving — at build time by {@link FxThreadPlugin}
+ * ({@code byte-buddy-maven-plugin}), and at load time by {@link FxThreadAgent} only for classes an IDE may
+ * have recompiled since — not a runtime check and not a Spring proxy. How it got there was no arbitrary
+ * choice:
*
*
* - AspectJ compile-time weaving was tried first and ruled out: its compiler cannot yet target this
@@ -22,17 +23,18 @@ import java.lang.annotation.Target;
* intercepts calls arriving from outside the bean. This codebase's actual candidates overwhelmingly
* self-invoke the method that needs wrapping from an {@code @EventListener} or a constructor, which a
* proxy silently cannot see — it would report success while doing nothing.
- *
- Build-time weaving via {@code byte-buddy-maven-plugin} was tried third, worked, and was reverted
- * after it shipped: an IDE's own incremental compiler recompiles into {@code target/classes} on every
- * run by default, silently overwriting the woven classes with plain ones the Maven plugin never
- * touched. Nothing failed at build time — a scenegraph mutation just ran on the wrong thread at
- * runtime, the first time the application was actually launched from an IDE.
+ *
- Build-time weaving alone ({@code byte-buddy-maven-plugin}) was tried third, worked, and was
+ * reverted after it shipped: an IDE's own incremental compiler recompiles into {@code target/classes} on
+ * every run by default, silently overwriting the woven classes with plain ones. Nothing failed at build
+ * time — a scenegraph mutation just ran on the wrong thread at runtime.
+ *
- A load-time agent alone came next: correct whichever compiler produced a class, but ~420 ms spent
+ * on every launch before the splash screen could show.
*
*
- * A JVM agent has none of these limitations: it rewrites the method itself as the class loads, so
- * self-invocation, constructor-time calls, and classes that are not Spring beans are all covered the same
- * way — and unlike a build-time weave, it applies identically no matter which compiler produced the
- * {@code .class} file being loaded.
+ *
Both weaves rewrite the method itself, so self-invocation, constructor-time calls, and classes that are
+ * not Spring beans are covered the same way. Combined — build-time weaving marking each class
+ * {@link FxThreadWoven}, the agent installed only for classes run from a directory and weaving only the
+ * unmarked ones — a Maven-built jar starts with no agent, and an IDE run is still always correct.
*
*
{@code void}-only. Off the FX thread, the wrapped call is dispatched via
* {@code Platform.runLater} — fire-and-forget, with no return value a synchronous caller could receive.
diff --git a/src/main/java/org/icroco/pholio/ui/common/FxThreadAgent.java b/src/main/java/org/icroco/pholio/ui/common/FxThreadAgent.java
index 9574d7e..bf612bb 100644
--- a/src/main/java/org/icroco/pholio/ui/common/FxThreadAgent.java
+++ b/src/main/java/org/icroco/pholio/ui/common/FxThreadAgent.java
@@ -9,6 +9,10 @@ import net.bytebuddy.matcher.ElementMatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
import static net.bytebuddy.matcher.ElementMatchers.isAbstract;
import static net.bytebuddy.matcher.ElementMatchers.isAnnotatedWith;
import static net.bytebuddy.matcher.ElementMatchers.isConstructor;
@@ -23,16 +27,21 @@ import static net.bytebuddy.matcher.ElementMatchers.returns;
* any class of this application's own is touched — since a class can only be woven as it loads, not
* afterwards.
*
- *
Why a runtime agent, not a build-time Maven plugin. An earlier version of this
- * weaved at build time via {@code byte-buddy-maven-plugin}, bound to Maven's {@code process-classes}
- * phase. That worked for every build and test run driven through Maven — and broke silently the moment
- * the application was actually launched from an IDE, because an IDE's own incremental compiler recompiles
- * straight into {@code target/classes} on every run by default, overwriting the woven {@code .class}
- * files with plain ones from a compiler that has never heard of this Maven plugin. The failure mode is not
- * a build error anywhere — it is a scenegraph mutation running on the wrong thread at runtime, minutes
- * later, with nothing in the stack trace to suggest {@code @FxThread} was ever involved. A JVM agent
- * instruments classes as the JVM loads them, which happens identically no matter which compiler produced
- * the {@code .class} file — IDE, {@code mvn spring-boot:run}, or the packaged jar.
+ *
Build-time weaving first, this agent only as the safety net. {@link FxThreadPlugin}
+ * weaves at build time ({@code byte-buddy-maven-plugin}, {@code process-classes}) and marks every class it
+ * weaves {@link FxThreadWoven}. Build-time weaving alone was tried first and dropped: an IDE's incremental
+ * compiler recompiles straight into {@code target/classes}, overwriting woven {@code .class} files with
+ * plain ones — no build error anywhere, just a scenegraph mutation on the wrong thread minutes later. And an
+ * agent alone costs ~420 ms before the splash screen can show (measured: Byte Buddy's self-attach and its
+ * ~1,700 classes), on every launch, packaged ones included. So {@link #install()} now decides:
+ *
+ * - classes loaded from a jar — the Maven-built jar, the installers — are all woven
+ * already: no agent;
+ *
- classes loaded from a directory (an IDE run, {@code target/classes}) may have been
+ * recompiled by the IDE: the agent is installed, and weaves only the classes not marked
+ * {@link FxThreadWoven} — exactly the recompiled ones, never a class twice;
+ *
- {@code -Dpholio.fxthread.agent=always|never} overrides that choice.
+ *
*/
public final class FxThreadAgent {
@@ -49,6 +58,13 @@ public final class FxThreadAgent {
}
installed = true;
+ String mode = System.getProperty("pholio.fxthread.agent", "auto");
+ boolean fromDirectory = loadedFromDirectory();
+ if ("never".equalsIgnoreCase(mode) || (!"always".equalsIgnoreCase(mode) && !fromDirectory)) {
+ log.debug("@FxThread woven at build time; no agent");
+ return;
+ }
+
ByteBuddyAgent.install();
new AgentBuilder.Default()
// AgentBuilder's default InitializationStrategy (SelfInjection) registers every visited
@@ -67,11 +83,31 @@ public final class FxThreadAgent {
// Scoped to this application's own classes: matching every loaded type (JDK, Spring,
// JavaFX, every library) would run matcherFor's reflection over each one for no benefit,
// since nothing outside this codebase ever carries @FxThread.
- .type(nameStartsWith("org.icroco.pholio."))
+ // Classes FxThreadPlugin already wove at build time are skipped: only those an IDE has
+ // recompiled since (so without the marker) are woven here.
+ .type(nameStartsWith("org.icroco.pholio.").and(not(isAnnotatedWith(FxThreadWoven.class))))
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
builder.visit(Advice.to(FxThreadInterceptor.class).on(matcherFor(typeDescription))))
.installOnByteBuddyAgent();
- log.debug("@FxThread instrumentation installed");
+ log.info("@FxThread load-time weaving installed ({}): classes run from a directory may have been "
+ + "recompiled without build-time weaving", fromDirectory ? "class directory" : "forced");
+ }
+
+ /**
+ * Whether this application's classes come from a directory ({@code target/classes}, an IDE run) rather
+ * than a jar — a nested {@code jar:} URL in the repackaged jar, a plain {@code .jar} file once extracted.
+ */
+ private static boolean loadedFromDirectory() {
+ try {
+ var source = FxThreadAgent.class.getProtectionDomain().getCodeSource();
+ if (source == null || source.getLocation() == null || !"file".equals(source.getLocation().getProtocol())) {
+ return false;
+ }
+ return Files.isDirectory(Path.of(source.getLocation().toURI()));
+ }
+ catch (URISyntaxException | RuntimeException e) {
+ return true; // cannot tell: weave, the safe choice
+ }
}
/**
@@ -79,7 +115,7 @@ public final class FxThreadAgent {
* declared by a class that carries {@link FxThread} itself — in which case every non-abstract,
* non-static, non-constructor method of that class is woven, not just some of them.
*/
- private static ElementMatcher.Junction matcherFor(TypeDescription typeDescription) {
+ static ElementMatcher.Junction matcherFor(TypeDescription typeDescription) {
boolean classAnnotated = typeDescription.getDeclaredAnnotations().isAnnotationPresent(FxThread.class);
return classAnnotated
? not(isConstructor()).and(not(isStatic())).and(not(isAbstract())).and(returns(void.class))
diff --git a/src/main/java/org/icroco/pholio/ui/common/FxThreadPlugin.java b/src/main/java/org/icroco/pholio/ui/common/FxThreadPlugin.java
new file mode 100644
index 0000000..3e1f0d8
--- /dev/null
+++ b/src/main/java/org/icroco/pholio/ui/common/FxThreadPlugin.java
@@ -0,0 +1,49 @@
+package org.icroco.pholio.ui.common;
+
+import net.bytebuddy.asm.Advice;
+import net.bytebuddy.build.Plugin;
+import net.bytebuddy.description.annotation.AnnotationDescription;
+import net.bytebuddy.description.method.MethodDescription;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.dynamic.ClassFileLocator;
+import net.bytebuddy.dynamic.DynamicType;
+
+/**
+ * Build-time weaving of {@link FxThread}: run by {@code byte-buddy-maven-plugin} over {@code target/classes}
+ * at {@code process-classes} (see {@code pom.xml}), after javac, Lombok and ErrorProne are done.
+ *
+ * Applies exactly what {@link FxThreadAgent} applies at load time — the same inlined
+ * {@link FxThreadInterceptor} {@link Advice}, on the same methods ({@link FxThreadAgent#matcherFor}) — and
+ * marks each woven class {@link FxThreadWoven}. A Maven-built jar is therefore fully woven and starts with
+ * no agent at all; the agent only steps in for classes a compiler other than Maven's has produced (an IDE's
+ * incremental build), which is what made build-time weaving alone unsafe before — see {@link FxThreadAgent}.
+ *
+ *
Only classes that actually use {@link FxThread}, and are not already woven, are touched: every other
+ * class file is left byte-for-byte as javac wrote it.
+ */
+public class FxThreadPlugin implements Plugin {
+
+ @Override
+ public boolean matches(TypeDescription target) {
+ return target.getName().startsWith("org.icroco.pholio.")
+ && !target.getDeclaredAnnotations().isAnnotationPresent(FxThreadWoven.class)
+ && usesFxThread(target);
+ }
+
+ @Override
+ public DynamicType.Builder> apply(DynamicType.Builder> builder, TypeDescription typeDescription,
+ ClassFileLocator classFileLocator) {
+ return builder.visit(Advice.to(FxThreadInterceptor.class).on(FxThreadAgent.matcherFor(typeDescription)))
+ .annotateType(AnnotationDescription.Builder.ofType(FxThreadWoven.class).build());
+ }
+
+ private static boolean usesFxThread(TypeDescription type) {
+ return type.getDeclaredAnnotations().isAnnotationPresent(FxThread.class)
+ || type.getDeclaredMethods().stream()
+ .anyMatch(method -> method.getDeclaredAnnotations().isAnnotationPresent(FxThread.class));
+ }
+
+ @Override
+ public void close() {
+ }
+}
diff --git a/src/main/java/org/icroco/pholio/ui/common/FxThreadWoven.java b/src/main/java/org/icroco/pholio/ui/common/FxThreadWoven.java
new file mode 100644
index 0000000..37d4eb5
--- /dev/null
+++ b/src/main/java/org/icroco/pholio/ui/common/FxThreadWoven.java
@@ -0,0 +1,20 @@
+package org.icroco.pholio.ui.common;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Added by {@link FxThreadPlugin} to every class it weaves at build time — never written by hand.
+ *
+ *
Tells {@link FxThreadAgent} a class already carries its {@link FxThread} advice, so the agent, when it
+ * runs at all (a run from class directories, typically the IDE), only weaves the classes an incremental
+ * compiler has recompiled since — which lack the marker — and never weaves a class twice. The Maven plugin
+ * skips marked classes the same way, so re-running {@code process-classes} over already-woven output is a
+ * no-op.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface FxThreadWoven {
+}