Implement isolated Build and Refresh with immutable leased snapshots

This commit is contained in:
2026-11-12 09:28:25 +01:00
parent 85a75383a0
commit e97af99412
27 changed files with 2066 additions and 15 deletions
@@ -55,6 +55,15 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl
});
}
public CompletionStage<List<DocumentSnapshot>> unsaved() {
return submit(
() ->
documents.values().stream()
.map(FileDocuments::snapshot)
.filter(DocumentSnapshot::dirty)
.toList());
}
private static DocumentSnapshot snapshot(State s) {
return new DocumentSnapshot(
s.file.toUri(), s.revision, s.text, Files.isWritable(s.file), !s.text.equals(s.saved));
@@ -126,9 +135,11 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl
Path temp = Files.createTempFile(s.file.getParent(), "designer-save-", ".tmp");
try {
Files.writeString(temp, s.text);
if (Files.getFileAttributeView(s.file, java.nio.file.attribute.PosixFileAttributeView.class) != null) {
Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(s.file));
}
if (Files.getFileAttributeView(
s.file, java.nio.file.attribute.PosixFileAttributeView.class)
!= null) {
Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(s.file));
}
Files.move(
temp,
s.file,
@@ -0,0 +1,205 @@
package de.neitzel.fxdesigner.build;
import de.neitzel.fxdesigner.host.*;
import de.neitzel.fxdesigner.standalone.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
/** Executed by check-bootstrap after the helper plugin and shared artifacts are installed. */
public final class BuildRefreshSmoke {
private static int checks;
private static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
checks++;
}
private static void write(Path root, String name, String text) throws Exception {
Path p = root.resolve(name);
Files.createDirectories(p.getParent());
Files.writeString(p, text);
}
private static String child(String name, String extra) {
return "<project><modelVersion>4.0.0</modelVersion><parent><groupId>fixture</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>"
+ name
+ "</artifactId>"
+ extra
+ "</project>";
}
private static final String DEP =
"<dependencies><dependency><groupId>fixture</groupId><artifactId>dep</artifactId><version>1</version></dependency></dependencies>";
public static void main(String[] args) throws Exception {
Path repository = Path.of(args[0]).toRealPath(), javaHome = Path.of(args[1]).toRealPath();
Path root = Files.createTempDirectory("designer-maven-smoke-").toRealPath();
var logs = new java.util.concurrent.CopyOnWriteArrayList<String>();
try {
write(
root,
"pom.xml",
"""
<project><modelVersion>4.0.0</modelVersion><groupId>fixture</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>dep</module><module>app</module><module>unrelated</module></modules><properties><maven.compiler.release>21</maven.compiler.release></properties><profiles><profile><id>designer-preview</id><modules><module>preview</module></modules></profile></profiles><build><pluginManagement><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-resources-plugin</artifactId><version>3.3.1</version></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-jar-plugin</artifactId><version>3.4.2</version></plugin></plugins></pluginManagement></build></project>
""");
write(root, "dep/pom.xml", child("dep", ""));
write(
root,
"dep/src/main/java/fixture/Model.java",
"package fixture; public class Model { public static String value(){return \"first\";}"
+ " }");
write(root, "app/pom.xml", child("app", DEP));
write(
root,
"app/src/main/java/fixture/Main.java",
"package fixture; public class Main {public static void main(String[]"
+ " a){System.out.print(Model.value());}}");
write(root, "app/src/main/resources/data.txt", "resource-v1");
write(
root,
"app/src/test/java/fixture/TestPreview.java",
"package fixture; public class TestPreview {public static void main(String[]"
+ " a){System.out.print(\"test-\"+Model.value());}}");
write(
root,
"preview/pom.xml",
child(
"preview",
"<dependencies><dependency><groupId>fixture</groupId><artifactId>app</artifactId><version>1</version></dependency></dependencies>"));
write(
root,
"preview/src/main/java/fixture/Preview.java",
"package fixture; public class Preview {public static void main(String[]"
+ " a){System.out.print(\"preview-\"+Model.value());}}");
write(root, "unrelated/pom.xml", child("unrelated", ""));
write(root, "unrelated/src/main/java/Broken.java", "THIS MUST NEVER COMPILE");
Files.copy(
repository.resolve("mvnw"), root.resolve("mvnw"), StandardCopyOption.COPY_ATTRIBUTES);
Files.createDirectories(root.resolve(".mvn/wrapper"));
Files.copy(
repository.resolve(".mvn/wrapper/maven-wrapper.properties"),
root.resolve(".mvn/wrapper/maven-wrapper.properties"));
Path config = root.resolve("neitzel-fx-designer.json");
write(root, "neitzel-fx-designer.json", configuration(true));
var runtime = new HostServices.RuntimeInstallation(javaHome, 21);
var mapping =
new ConfiguredRuntimes(
Map.of(
new ConfiguredRuntimes.Key("java", HostServices.RuntimeRole.BUILD),
runtime,
new ConfiguredRuntimes.Key("java", HostServices.RuntimeRole.TARGET),
runtime));
try (var host =
new StandaloneHost(
mapping,
l -> CompletableFuture.completedFuture(null),
d -> logs.add(d.message()));
var refresh =
new BuildRefresh(
host.services(),
new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots")))) {
var request = new BuildRefresh.Request(config, "desktop", null);
var first =
refresh
.start(request, Map.of(), o -> logs.add(o.line()))
.completion()
.toCompletableFuture()
.get(180, TimeUnit.SECONDS);
check(
first.status() == BuildRefresh.Status.READY,
"Module preview build failed: " + first.message());
check(
first.reactor().modules().stream().noneMatch(m -> m.directory().endsWith("unrelated")),
"Unrelated module entered scope");
check(!Files.exists(root.resolve("app/target")), "Build touched original target");
try (var old = refresh.previewCurrent(request)) {
check(
run(javaHome, old, "fixture.Preview").equals("preview-first"),
"Preview module runtime incorrect");
check(
old.runtime().stream().anyMatch(p -> Files.exists(p.resolve("data.txt"))),
"Resources missing from snapshot");
write(
root,
"dep/src/main/java/fixture/Model.java",
"package fixture; public class Model { public static String value(){return"
+ " \"second\";} }");
write(root, "neitzel-fx-designer.json", configuration(false));
var second =
refresh
.start(request, Map.of(), o -> logs.add(o.line()))
.completion()
.toCompletableFuture()
.get(180, TimeUnit.SECONDS);
check(
second.status() == BuildRefresh.Status.READY,
"Test preview build failed: " + second.message());
try (var current = refresh.previewCurrent(request)) {
check(
run(javaHome, current, "fixture.TestPreview").equals("test-second"),
"Test source arrangement missing");
}
check(
run(javaHome, old, "fixture.Preview").equals("preview-first"),
"Rebuild mutated active snapshot");
write(root, "app/src/main/java/fixture/Main.java", "INVALID JAVA");
var failed =
refresh
.start(request, Map.of(), o -> logs.add(o.line()))
.completion()
.toCompletableFuture()
.get(180, TimeUnit.SECONDS);
check(
failed.status() == BuildRefresh.Status.FAILED,
"Compiler failure incorrectly activated");
try (var retained = refresh.previewLastSuccessful("desktop")) {
check(
run(javaHome, retained, "fixture.TestPreview").equals("test-second"),
"Last successful build lost");
}
}
}
System.out.println(
"PASS real Maven Build and Refresh: "
+ checks
+ " checks (Java 21, profile/module preview, test preview, scope, resources,"
+ " isolation, failed build retention)");
} finally {
Path log = repository.resolve("designer-standalone/target/build-refresh-smoke.log");
Files.createDirectories(log.getParent());
Files.write(log, logs);
try (var files = Files.walk(root)) {
for (Path file : files.sorted(Comparator.reverseOrder()).toList())
Files.deleteIfExists(file);
}
}
}
private static String configuration(boolean module) {
return "{\"schemaVersion\":\"1.0\",\"contexts\":{\"desktop\":{\"applicationModule\":\"app\",\"mavenProfiles\":[\"designer-preview\"],\"launchMode\":\"classpath\",\"buildRuntime\":\"java\",\"targetRuntime\":\"java\",\"preview\":{\"sourceSet\":\""
+ (module ? "module" : "test")
+ "\","
+ (module ? "\"module\":\"preview\"," : "")
+ "\"provider\":\"fixture.Preview\"}}}}";
}
private static String run(Path home, SnapshotStore.Lease lease, String main) throws Exception {
String cp =
String.join(
java.io.File.pathSeparator, lease.runtime().stream().map(Path::toString).toList());
var process =
new ProcessBuilder(home.resolve("bin/java").toString(), "-cp", cp, main)
.redirectErrorStream(true)
.start();
if (!process.waitFor(15, TimeUnit.SECONDS)) {
process.destroyForcibly();
throw new AssertionError("Target did not exit");
}
String output =
new String(
process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
check(process.exitValue() == 0, output);
return output;
}
}
@@ -0,0 +1,199 @@
package de.neitzel.fxdesigner.build;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.contracts.*;
import de.neitzel.fxdesigner.host.*;
import de.neitzel.fxdesigner.standalone.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class BuildRefreshTest {
@TempDir Path root;
private Path config;
private final AtomicInteger starts = new AtomicInteger();
private final AtomicBoolean failure = new AtomicBoolean();
private Runnable duringBuild = () -> {};
private HostServices host(FileDocuments documents) {
var runtime =
new HostServices.RuntimeInstallation(Path.of(System.getProperty("java.home")), 25);
return new HostServices(
documents,
(request, output) -> {
starts.incrementAndGet();
duringBuild.run();
try {
Path classes = Files.createDirectories(request.directory().resolve("target/classes"));
Files.writeString(
classes.resolve("A.class"),
Files.readString(request.directory().resolve("A.java")));
Path meta =
Path.of(
request.command().stream()
.filter(s -> s.startsWith("-Ddesigner.captureDir="))
.findFirst()
.orElseThrow()
.substring("-Ddesigner.captureDir=".length()));
Files.createDirectories(meta);
var p = new Properties();
p.setProperty("format", "2");
p.setProperty("artifact.count", "0");
p.setProperty("module", request.directory().toString());
p.setProperty("coordinates", "test:app:1");
p.setProperty("packaging", "jar");
for (String list : List.of("runtime", "test")) {
p.setProperty(list + ".count", "1");
p.setProperty(list + ".0", classes.toString());
}
for (String list : List.of("sources", "dependencies", "profiles"))
p.setProperty(list + ".count", "0");
try (var out = Files.newOutputStream(meta.resolve("app.properties"))) {
p.store(out, "");
}
} catch (Exception e) {
throw new java.io.IOException(e);
}
return new HostServices.BuildRun() {
public CompletionStage<HostServices.BuildResult> completion() {
return CompletableFuture.completedFuture(
new HostServices.BuildResult(failure.get() ? 1 : 0, false));
}
public void cancel() {}
};
},
(n, r) -> CompletableFuture.completedFuture(runtime),
l -> CompletableFuture.completedFuture(null),
d -> {},
new SessionScope());
}
@BeforeEach
void setup() throws Exception {
Files.writeString(root.resolve("pom.xml"), "<project/>");
Files.writeString(root.resolve("A.java"), "first");
Files.writeString(root.resolve("View.fxml"), "view");
Path wrapper = Files.writeString(root.resolve("mvnw"), "#!/bin/sh\nexit 0\n");
wrapper.toFile().setExecutable(true);
config = root.resolve("neitzel-fx-designer.json");
Files.writeString(
config,
"""
{"schemaVersion":"1.0","contexts":{"desktop":{"applicationModule":".","launchMode":"classpath","buildRuntime":"build","targetRuntime":"target","preview":{"sourceSet":"test","provider":"Example"}}}}
""");
root = root.toRealPath();
config = config.toRealPath();
}
private BuildRefresh.Request request() {
return new BuildRefresh.Request(config, "desktop", null);
}
private BuildRefresh.Outcome build(BuildRefresh refresh, Map<java.net.URI, Long> approvals)
throws Exception {
return refresh
.start(request(), approvals, o -> {})
.completion()
.toCompletableFuture()
.get(15, TimeUnit.SECONDS);
}
@Test
void unsavedInputRequiresExactApprovalAndFxmlDraftDoesNotForceJavaBuild() throws Exception {
try (var docs = new FileDocuments()) {
var host = host(docs);
var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots"));
try (var refresh = new BuildRefresh(host, store);
var java = docs.open(root.resolve("A.java").toUri()).toCompletableFuture().get();
var fxml = docs.open(root.resolve("View.fxml").toUri()).toCompletableFuture().get()) {
java.apply(new EditBatch(0, "edit", List.of(new EditBatch.Replacement(0, 5, "draft"))))
.toCompletableFuture()
.get();
var prompt = build(refresh, Map.of());
assertEquals(BuildRefresh.Status.NEEDS_SAVE, prompt.status());
assertEquals(0, starts.get());
assertEquals("first", Files.readString(root.resolve("A.java")));
var approved = prompt.unsaved().getFirst();
assertEquals(
BuildRefresh.Status.READY,
build(refresh, Map.of(approved.document(), approved.revision())).status());
assertEquals("draft", Files.readString(root.resolve("A.java")));
fxml.apply(
new EditBatch(
0, "FXML edit", List.of(new EditBatch.Replacement(0, 4, "draft view"))))
.toCompletableFuture()
.get();
try (var lease = refresh.previewCurrent(request())) {
assertEquals("draft", Files.readString(lease.runtime().getFirst().resolve("A.class")));
}
assertEquals(1, starts.get());
}
}
}
@Test
void failedBuildRetainsExplicitLastSuccessAndNeverFallsBackAutomatically() throws Exception {
try (var docs = new FileDocuments();
var refresh =
new BuildRefresh(
host(docs), new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots")))) {
assertEquals(BuildRefresh.Status.READY, build(refresh, Map.of()).status());
failure.set(true);
assertEquals(BuildRefresh.Status.FAILED, build(refresh, Map.of()).status());
assertThrows(IllegalStateException.class, () -> refresh.previewCurrent(request()));
try (var lease = refresh.previewLastSuccessful("desktop")) {
assertEquals("first", Files.readString(lease.runtime().getFirst().resolve("A.class")));
}
}
}
@Test
void inputEditDuringBuildRejectsActivationEvenThoughBuildUsedIsolatedSources() throws Exception {
duringBuild =
() -> {
try {
Files.writeString(root.resolve("A.java"), "second");
} catch (Exception e) {
throw new RuntimeException(e);
}
};
var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots"));
try (var docs = new FileDocuments();
var refresh = new BuildRefresh(host(docs), store)) {
assertEquals(BuildRefresh.Status.STALE, build(refresh, Map.of()).status());
assertNull(store.state("desktop").lastSuccessful());
}
}
@Test
void cancellationPreventsPublication() throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
duringBuild =
() -> {
entered.countDown();
try {
release.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots"));
try (var docs = new FileDocuments();
var refresh = new BuildRefresh(host(docs), store)) {
var run = refresh.start(request(), Map.of(), o -> {});
assertTrue(entered.await(5, TimeUnit.SECONDS));
run.cancel();
release.countDown();
assertEquals(
BuildRefresh.Status.CANCELLED,
run.completion().toCompletableFuture().get(10, TimeUnit.SECONDS).status());
assertNull(store.state("desktop").lastSuccessful());
}
}
}