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
@@ -0,0 +1,417 @@
package de.neitzel.fxdesigner.build;
import de.neitzel.fxdesigner.config.*;
import de.neitzel.fxdesigner.contracts.DocumentSnapshot;
import de.neitzel.fxdesigner.host.*;
import java.io.*;
import java.net.URI;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.function.Consumer;
/**
* One context's build/refresh owner. UI callers await stages; they never block the host UI thread.
*/
public final class BuildRefresh implements AutoCloseable {
public enum Status {
READY,
NEEDS_SAVE,
CANCELLED,
FAILED,
STALE
}
public record Request(Path configuration, String context, Path fallbackMaven) {
public Request {
Objects.requireNonNull(configuration);
Objects.requireNonNull(context);
}
}
public record Outcome(
Status status,
String snapshot,
List<DocumentSnapshot> unsaved,
String message,
ContextCatalog.ReactorView reactor) {
public Outcome {
unsaved = List.copyOf(unsaved);
}
}
public final class Run implements AutoCloseable {
private final AtomicBoolean cancelled = new AtomicBoolean();
private final AtomicReference<HostServices.BuildRun> process = new AtomicReference<>();
private final CompletableFuture<Outcome> result = new CompletableFuture<>();
public CompletionStage<Outcome> completion() {
return result;
}
public void cancel() {
if (result.isDone()) return;
cancelled.set(true);
var p = process.get();
if (p != null) p.cancel();
}
public void close() {
cancel();
}
}
private final HostServices host;
private final SnapshotStore store;
private Run active;
private boolean closed;
public BuildRefresh(HostServices host, SnapshotStore store) {
this.host = host;
this.store = store;
host.lifecycle().own(this);
}
public synchronized Run start(
Request request, Map<URI, Long> approvedSaves, Consumer<HostServices.BuildOutput> output) {
if (closed) throw new IllegalStateException("Build owner closed");
if (active != null && !active.result.isDone())
throw new IllegalStateException("A build is already running");
var run = new Run();
active = run;
var approved = Map.copyOf(approvedSaves);
Thread.ofVirtual().start(() -> execute(run, request, approved, output));
return run;
}
private List<DocumentSnapshot> dirty(Path root) throws Exception {
return host.documents().unsaved().toCompletableFuture().get().stream()
.filter(
d -> {
Path p = Path.of(d.document()).toAbsolutePath().normalize();
return settingsPaths().containsValue(p)
|| (p.startsWith(root)
&& InputTree.included(root, root.relativize(p))
&& !p.toString().toLowerCase(Locale.ROOT).endsWith(".fxml"));
})
.toList();
}
private void execute(
Run run,
Request request,
Map<URI, Long> approvals,
Consumer<HostServices.BuildOutput> output) {
Path work = null;
String context = request.context();
try {
Path config = request.configuration().toRealPath(), root = config.getParent();
var unsaved = dirty(root);
if (!unsaved.isEmpty()) {
var actual = new HashMap<URI, Long>();
unsaved.forEach(d -> actual.put(d.document(), d.revision()));
if (!actual.equals(approvals)) {
store.invalidate(context, "Unsaved build inputs");
run.result.complete(
new Outcome(
Status.NEEDS_SAVE,
null,
unsaved,
"Save listed revisions and build, or cancel",
null));
return;
}
for (var draft : unsaved) {
checkCancelled(run);
try (var doc = host.documents().open(draft.document()).toCompletableFuture().get()) {
doc.save(draft.revision()).toCompletableFuture().get();
}
}
}
if (!dirty(root).isEmpty()) throw new IOException("Unsaved inputs changed during save");
checkCancelled(run);
var settings = ConfigurationFiles.load(config);
var selected = settings.contexts().get(context);
if (selected == null) throw new IOException("Unknown context");
var build =
host.runtimes()
.resolve(selected.buildRuntime(), HostServices.RuntimeRole.BUILD)
.toCompletableFuture()
.get();
var target =
host.runtimes()
.resolve(selected.targetRuntime(), HostServices.RuntimeRole.TARGET)
.toCompletableFuture()
.get();
if (build.javaFeature() < selected.buildJava()
|| target.javaFeature() < selected.targetJava())
throw new IOException("Runtime requirements not met");
Path cache = root.resolve(".neitzel-fx-designer/work");
Files.createDirectories(cache);
work = Files.createTempDirectory(cache, "build-");
Path workspace = work.resolve("reactor");
Files.createDirectory(workspace);
Path metadata = work.resolve("metadata");
store.invalidate(context, "Build in progress");
try (var watch = new SourceWatch(root)) {
var inputs = fingerprint(root);
InputTree.copy(root, workspace, InputTree.scan(root));
isolateSettings(work);
if (watch.changed() || !inputs.equals(fingerprint(root)))
throw new Stale("Inputs changed during isolation");
var command = command(workspace, metadata, selected, request.fallbackMaven());
output.accept(new HostServices.BuildOutput(false, "Build plan: " + command));
checkCancelled(run);
var process =
host.builds()
.start(
new HostServices.BuildRequest(
workspace,
command,
Map.of(
"JAVA_HOME",
build.javaHome().toString(),
"PATH",
build.javaHome().resolve("bin")
+ File.pathSeparator
+ System.getenv().getOrDefault("PATH", ""))),
output);
run.process.set(process);
if (run.cancelled.get()) process.cancel();
var result = process.completion().toCompletableFuture().get();
checkCancelled(run);
if (result.cancelled()) throw new CancellationException();
if (result.exitCode() != 0)
throw new IOException("Maven failed with exit code " + result.exitCode());
var captured = MavenCapture.read(metadata, workspace, root, selected);
var details = new TreeMap<String, String>();
details.put("configuration", config.toUri().toString());
details.put(
"contextSpec",
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(selected));
details.put("buildJavaHome", build.javaHome().toString());
details.put("buildJava", Integer.toString(build.javaFeature()));
details.put("targetJavaHome", target.javaHome().toString());
details.put("targetJava", Integer.toString(target.javaFeature()));
details.put("wireVersion", "1.0");
details.put("manifestVersion", "1");
details.put("coordinates", captured.coordinates().toString());
details.put("command", command.toString());
java.util.function.BooleanSupplier valid =
() -> {
try {
for (var e : captured.dependencyProof().entrySet())
if (!InputTree.signature(e.getKey()).equals(e.getValue())) return false;
return !run.cancelled.get()
&& !watch.changed()
&& inputs.equals(fingerprint(root))
&& dirty(root).isEmpty();
} catch (Exception e) {
return false;
}
};
if (!valid.getAsBoolean()) throw new Stale("Inputs changed while building");
var snapshot =
store.publish(context, inputs, captured.runtime(), details, valid, run.cancelled::get);
run.result.complete(
new Outcome(
Status.READY,
snapshot.id(),
List.of(),
"Successful immutable build snapshot",
captured.reactor()));
}
} catch (Exception e) {
Throwable cause = e;
while (cause instanceof ExecutionException && cause.getCause() != null)
cause = cause.getCause();
Status status =
run.cancelled.get() || cause instanceof CancellationException
? Status.CANCELLED
: cause instanceof Stale ? Status.STALE : Status.FAILED;
String message =
cause.getMessage() == null ? cause.getClass().getSimpleName() : cause.getMessage();
try {
store.invalidate(context, message);
} catch (IOException x) {
message += "; cannot persist stale state";
}
try {
host.diagnostics()
.report(
new HostServices.Diagnostic(
"build-refresh", HostServices.Severity.ERROR, message, Optional.empty()));
} finally {
run.result.complete(new Outcome(status, null, List.of(), message, null));
}
} finally {
run.process.set(null);
if (work != null)
try {
InputTree.delete(work);
} catch (IOException ignored) {
/* An abandoned workspace is never a published snapshot. */
}
}
}
private static Map<String, Path> settingsPaths() {
Path home = Path.of(System.getProperty("user.home"));
return Map.of(
"settings",
canonicalSetting(home.resolve(".m2/settings.xml")),
"toolchains",
canonicalSetting(home.resolve(".m2/toolchains.xml")));
}
private static Path canonicalSetting(Path path) {
try {
return path.toRealPath();
} catch (IOException e) {
return path.toAbsolutePath().normalize();
}
}
private static Map<String, String> fingerprint(Path root) throws IOException {
var map = new TreeMap<>(InputTree.scan(root));
for (var e : settingsPaths().entrySet())
map.put(
"@maven-" + e.getKey(),
Files.exists(e.getValue()) ? InputTree.signature(e.getValue()) : "absent");
return Map.copyOf(map);
}
private static void isolateSettings(Path work) throws IOException {
for (var e : settingsPaths().entrySet()) {
Path dest = work.resolve(e.getKey() + ".xml");
if (Files.exists(e.getValue())) Files.copy(e.getValue(), dest);
else Files.writeString(dest, "<" + e.getKey() + "/>");
}
}
private static final class Stale extends IOException {
Stale(String message) {
super(message);
}
}
private static void checkCancelled(Run run) {
if (run.cancelled.get()) throw new CancellationException("Build cancelled");
}
static List<String> command(
Path workspace, Path metadata, DesignerConfiguration.Context context, Path fallback)
throws IOException {
var modules = new LinkedHashSet<String>();
modules.add(selector(workspace, context.applicationModule()));
if (context.preview().sourceSet().equals("module"))
modules.add(selector(workspace, context.preview().module()));
boolean windows = System.getProperty("os.name").startsWith("Windows");
Path wrapper = workspace.resolve(windows ? "mvnw.cmd" : "mvnw");
Path executable = Files.isRegularFile(wrapper) ? wrapper : fallback;
if (executable == null
|| !executable.isAbsolute()
|| !Files.isRegularFile(executable)
|| !Files.isExecutable(executable))
throw new IOException("Provide an executable Maven wrapper or explicit Maven installation");
var command = new ArrayList<String>();
command.add(executable.toString());
command.addAll(
List.of(
"-s",
workspace.getParent().resolve("settings.xml").toString(),
"-t",
workspace.getParent().resolve("toolchains.xml").toString(),
"-Ddesigner.workspace=" + workspace));
command.addAll(List.of("-B", "-ntp", "-pl", String.join(",", modules), "-am"));
for (String profile : context.mavenProfiles())
if (!profile.matches("[A-Za-z0-9_][A-Za-z0-9_.-]*"))
throw new IOException("Invalid active Maven profile name");
if (!context.mavenProfiles().isEmpty()) {
command.add("-P");
command.add(String.join(",", context.mavenProfiles()));
}
command.addAll(
List.of(
"-DskipTests=true",
"-Dmaven.test.skip=false",
"de.neitzel.fxdesigner:designer-maven-plugin:0.1.0-SNAPSHOT:plan",
"clean",
"de.neitzel.fxdesigner:designer-maven-plugin:0.1.0-SNAPSHOT:dependencies",
"package",
"de.neitzel.fxdesigner:designer-maven-plugin:0.1.0-SNAPSHOT:capture",
"-Ddesigner.captureDir=" + metadata));
return List.copyOf(command);
}
private static String selector(Path root, String value) throws IOException {
Path relative = Path.of(value);
if (relative.isAbsolute()
|| value.contains(",")
|| value.startsWith("-")
|| value.startsWith("!")) throw new IOException("Invalid Maven module selector");
Path resolved = root.resolve(relative).normalize();
if (!resolved.startsWith(root) || !Files.isRegularFile(resolved.resolve("pom.xml")))
throw new IOException("Module outside reactor or missing POM");
String selector = root.relativize(resolved).toString();
return selector.isEmpty() ? "." : selector;
}
public SnapshotStore.Lease previewCurrent(Request request) throws Exception {
var state = store.state(request.context());
if (state.outdated() || state.lastSuccessful() == null)
throw new IllegalStateException("Choose Preview with last successful build explicitly");
var lease = store.lease(state.lastSuccessful());
try {
Path root = request.configuration().toRealPath().getParent();
if (!lease
.snapshot()
.details()
.get("configuration")
.equals(request.configuration().toRealPath().toUri().toString())
|| !runtimeMatches(request, lease.snapshot())
|| !lease.snapshot().inputs().equals(fingerprint(root))
|| !dirty(root).isEmpty())
throw new IllegalStateException(
"Build is outdated; choose last successful build explicitly");
return lease;
} catch (Exception e) {
lease.close();
store.invalidate(request.context(), "Build inputs or runtime mappings changed");
throw e;
}
}
private boolean runtimeMatches(Request request, SnapshotStore.Snapshot snapshot)
throws Exception {
var context =
ConfigurationFiles.load(request.configuration()).contexts().get(request.context());
if (context == null) return false;
var build =
host.runtimes()
.resolve(context.buildRuntime(), HostServices.RuntimeRole.BUILD)
.toCompletableFuture()
.get();
var target =
host.runtimes()
.resolve(context.targetRuntime(), HostServices.RuntimeRole.TARGET)
.toCompletableFuture()
.get();
return build.javaHome().toString().equals(snapshot.details().get("buildJavaHome"))
&& target.javaHome().toString().equals(snapshot.details().get("targetJavaHome"))
&& Integer.toString(build.javaFeature()).equals(snapshot.details().get("buildJava"))
&& Integer.toString(target.javaFeature()).equals(snapshot.details().get("targetJava"));
}
public SnapshotStore.Lease previewLastSuccessful(String context) throws IOException {
var state = store.state(context);
if (state.lastSuccessful() == null) throw new IOException("No successful build available");
return store.lease(state.lastSuccessful());
}
public synchronized void close() {
closed = true;
if (active != null) active.cancel();
}
}
@@ -0,0 +1,95 @@
package de.neitzel.fxdesigner.build;
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.security.*;
import java.util.*;
/** Conservative reactor input closure, excluding mutable build/cache and editor metadata. */
final class InputTree {
private static final Set<String> IGNORED =
Set.of(".git", ".idea", ".gradle", ".neitzel-fx-designer");
static boolean included(Path root, Path relative) {
Path parent = root;
for (Path part : relative) {
String name = part.toString();
if (IGNORED.contains(name)
|| (name.equals("target") && Files.isRegularFile(parent.resolve("pom.xml"))))
return false;
parent = parent.resolve(part);
}
return true;
}
static String hash(Path file) throws IOException {
try {
var digest = MessageDigest.getInstance("SHA-256");
try (var in = Files.newInputStream(file)) {
byte[] b = new byte[65536];
for (int n; (n = in.read(b)) != -1; ) digest.update(b, 0, n);
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
static String signature(Path file) throws IOException {
var a = Files.readAttributes(file, BasicFileAttributes.class);
return hash(file) + ":" + a.size() + ":" + a.lastModifiedTime() + ":" + a.fileKey();
}
static Map<String, String> scan(Path root) throws IOException {
var files = new TreeMap<String, String>();
Files.walkFileTree(
root,
new SimpleFileVisitor<>() {
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes a)
throws IOException {
return !included(root, root.relativize(dir))
? FileVisitResult.SKIP_SUBTREE
: FileVisitResult.CONTINUE;
}
public FileVisitResult visitFile(Path file, BasicFileAttributes a) throws IOException {
Path r = root.relativize(file);
if (!included(root, r)) return FileVisitResult.CONTINUE;
if (!a.isRegularFile() || a.isSymbolicLink())
throw new IOException("Unsupported non-regular build input: " + r);
var before =
Files.readAttributes(file, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
String hash = hash(file);
var after =
Files.readAttributes(file, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!before.lastModifiedTime().equals(after.lastModifiedTime())
|| before.size() != after.size()
|| !Objects.equals(before.fileKey(), after.fileKey()))
throw new IOException("Input changed while reading: " + r);
files.put(
r.toString(),
hash + ":" + after.size() + ":" + after.lastModifiedTime() + ":" + after.fileKey());
return FileVisitResult.CONTINUE;
}
});
return Map.copyOf(files);
}
static void copy(Path root, Path destination, Map<String, String> inputs) throws IOException {
for (String name : inputs.keySet()) {
Path source = root.resolve(name), target = destination.resolve(name);
Files.createDirectories(target.getParent());
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
if (!hash(target).equals(inputs.get(name).substring(0, 64)))
throw new IOException("Source changed during copy: " + name);
}
}
static void delete(Path root) throws IOException {
if (!Files.exists(root)) return;
try (var paths = Files.walk(root)) {
for (Path p : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(p);
}
}
}
@@ -0,0 +1,99 @@
package de.neitzel.fxdesigner.build;
import de.neitzel.fxdesigner.config.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/** Reads machine-readable metadata emitted by the dedicated Maven plugin, never console parsing. */
final class MavenCapture {
record Result(
List<Path> runtime,
ContextCatalog.ReactorView reactor,
Map<String, String> coordinates,
Map<Path, String> dependencyProof) {}
static List<String> list(Properties p, String key) throws IOException {
int count = Integer.parseInt(p.getProperty(key + ".count"));
if (count < 0 || count > 100000) throw new IOException("Invalid metadata count");
var values = new ArrayList<String>();
for (int i = 0; i < count; i++)
values.add(Objects.requireNonNull(p.getProperty(key + "." + i)));
return values;
}
static Result read(
Path directory, Path workspace, Path original, DesignerConfiguration.Context context)
throws IOException {
var projects = new HashMap<Path, Properties>();
try (var files = Files.list(directory)) {
for (Path file : files.filter(p -> p.toString().endsWith(".properties")).toList()) {
var p = new Properties();
try (var in = Files.newInputStream(file)) {
p.load(in);
}
if (!"2".equals(p.getProperty("format")))
throw new IOException("Unsupported Maven metadata");
Path module = Path.of(p.getProperty("module")).toRealPath();
if (!module.startsWith(workspace)) throw new IOException("Module outside isolated reactor");
projects.put(module, p);
}
}
Path app = workspace.resolve(context.applicationModule()).normalize();
Path preview =
context.preview().sourceSet().equals("module")
? workspace.resolve(context.preview().module()).normalize()
: app;
var ordered = new LinkedHashSet<Path>();
var coords = new TreeMap<String, String>();
var proof = new LinkedHashMap<Path, String>();
var modules = new ArrayList<ContextCatalog.MavenModule>();
for (var e : projects.entrySet()) {
var p = e.getValue();
int artifactCount = Integer.parseInt(p.getProperty("artifact.count"));
if (artifactCount < 0 || artifactCount > 100000)
throw new IOException("Invalid dependency proof count");
for (int i = 0; i < artifactCount; i++) {
Path artifact = Path.of(p.getProperty("artifact." + i + ".path"));
String signature = p.getProperty("artifact." + i + ".signature");
if (proof.putIfAbsent(artifact, signature) != null
&& !proof.get(artifact).equals(signature))
throw new IOException("Dependency identity changed between projects");
coords.put("artifact:" + artifact, p.getProperty("artifact." + i + ".coordinates"));
}
var sources = new ArrayList<Path>();
for (String s : list(p, "sources")) {
Path source = Path.of(s).normalize();
if (!source.startsWith(e.getKey())) throw new IOException("Source root outside its module");
Path originalSource = original.resolve(workspace.relativize(source));
if (Files.isDirectory(originalSource)
&& InputTree.included(original, original.relativize(originalSource)))
sources.add(originalSource);
}
var deps = new HashSet<Path>();
for (String d : list(p, "dependencies"))
deps.add(original.resolve(workspace.relativize(Path.of(d))));
modules.add(
new ContextCatalog.MavenModule(
original.resolve(workspace.relativize(e.getKey())), sources, deps));
coords.put(workspace.relativize(e.getKey()).toString(), p.getProperty("coordinates"));
}
for (Path selected : new LinkedHashSet<>(List.of(preview, app))) {
Properties p = projects.get(selected);
if (p == null) throw new IOException("Selected module absent from Maven result");
boolean test = selected.equals(app) && context.preview().sourceSet().equals("test");
for (String item : list(p, test ? "test" : "runtime")) {
Path path = Path.of(item).toAbsolutePath().normalize();
if (Files.exists(path)) ordered.add(path.toRealPath());
else if (!path.startsWith(workspace))
throw new IOException("Missing runtime dependency: " + path);
}
}
if (ordered.isEmpty()) throw new IOException("Maven returned no runtime outputs");
return new Result(
List.copyOf(ordered),
new ContextCatalog.ReactorView(context.mavenProfiles(), modules),
Map.copyOf(coords),
Map.copyOf(proof));
}
}
@@ -0,0 +1,364 @@
package de.neitzel.fxdesigner.build;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.function.BooleanSupplier;
/**
* Atomic private copies with persistent last-successful state and cross-process eviction leases.
*/
public final class SnapshotStore {
private static final ObjectMapper JSON = new ObjectMapper();
public record Snapshot(
String id,
String context,
String created,
Map<String, String> inputs,
Map<String, String> files,
List<String> runtime,
Map<String, String> details) {
public Snapshot {
inputs = Map.copyOf(inputs);
files = Map.copyOf(files);
runtime = List.copyOf(runtime);
details = Map.copyOf(details);
}
}
public record State(String lastSuccessful, boolean outdated, String reason) {}
private final Path root;
public SnapshotStore(Path root) throws IOException {
Files.createDirectories(root);
this.root = root.toRealPath();
Files.createDirectories(this.root.resolve("leases"));
}
interface Action<T> {
T run() throws IOException;
}
private <T> T locked(Action<T> action) throws IOException {
synchronized (root.toString().intern()) {
try (var channel =
FileChannel.open(
root.resolve("store.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
var lock = channel.lock()) {
return action.run();
}
}
}
private static String key(String context) {
try {
return HexFormat.of()
.formatHex(
java.security.MessageDigest.getInstance("SHA-256")
.digest(context.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new AssertionError(e);
}
}
private Path stateFile(String context) {
return root.resolve("context-" + key(context) + ".json");
}
private State readState(String context) throws IOException {
Path p = stateFile(context);
return Files.exists(p)
? JSON.readValue(p.toFile(), State.class)
: new State(null, true, "No successful build");
}
public State state(String context) throws IOException {
return locked(() -> readState(context));
}
private void writeState(String context, State state) throws IOException {
Path temp = Files.createTempFile(root, "state-", ".tmp");
try {
JSON.writeValue(temp.toFile(), state);
Files.move(
temp,
stateFile(context),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(temp);
}
}
public void invalidate(String context, String reason) throws IOException {
locked(
() -> {
var old = readState(context);
writeState(context, new State(old.lastSuccessful(), true, reason));
return null;
});
}
public Snapshot publish(
String context,
Map<String, String> inputs,
List<Path> runtime,
Map<String, String> details,
BooleanSupplier valid)
throws IOException {
return publish(context, inputs, runtime, details, valid, () -> false);
}
public Snapshot publish(
String context,
Map<String, String> inputs,
List<Path> runtime,
Map<String, String> details,
BooleanSupplier valid,
BooleanSupplier cancelled)
throws IOException {
return locked(
() -> {
String id = UUID.randomUUID().toString();
Path staging = root.resolve(".staging-" + id);
Files.createDirectory(staging);
boolean moved = false;
try {
var files = new TreeMap<String, String>();
var paths = new ArrayList<String>();
var sourceHashes = new LinkedHashMap<Path, String>();
var trees = new LinkedHashMap<Path, Map<String, String>>();
check(valid);
for (int i = 0; i < runtime.size(); i++) {
check(() -> !cancelled.getAsBoolean());
Path source = runtime.get(i).toRealPath();
String relative =
"runtime/"
+ i
+ "/"
+ (Files.isDirectory(source) ? "classes" : source.getFileName().toString());
Path dest = staging.resolve(relative);
paths.add(relative);
if (Files.isDirectory(source)) {
trees.put(source, tree(source));
Files.createDirectories(dest);
try (var tree = Files.walk(source)) {
for (Path p : tree.toList()) {
if (Files.isSymbolicLink(p)) throw new IOException("Symlink in runtime output");
if (Files.isRegularFile(p))
copy(
p,
dest.resolve(source.relativize(p)),
staging,
files,
sourceHashes,
() -> !cancelled.getAsBoolean());
}
}
} else
copy(source, dest, staging, files, sourceHashes, () -> !cancelled.getAsBoolean());
}
// Recheck every source after all copies: a source changed early in capture invalidates
// the whole set.
for (var e : sourceHashes.entrySet()) {
check(() -> !cancelled.getAsBoolean());
if (!InputTree.hash(e.getKey()).equals(e.getValue()))
throw new IOException("Runtime output/dependency changed during capture");
}
for (var entry : trees.entrySet())
if (!entry.getValue().equals(tree(entry.getKey())))
throw new IOException("Runtime tree changed during capture");
var snapshot =
new Snapshot(id, context, Instant.now().toString(), inputs, files, paths, details);
JSON.writeValue(staging.resolve("manifest.json").toFile(), snapshot);
check(valid);
Files.move(staging, root.resolve(id), StandardCopyOption.ATOMIC_MOVE);
moved = true;
check(valid);
writeState(context, new State(id, false, "Build succeeded"));
return snapshot;
} finally {
if (!moved) InputTree.delete(staging);
}
});
}
private static Map<String, String> tree(Path root) throws IOException {
var map = new TreeMap<String, String>();
try (var paths = Files.walk(root)) {
for (Path p : paths.toList()) {
if (Files.isSymbolicLink(p)) throw new IOException("Symlink in runtime tree");
if (Files.isRegularFile(p)) {
var attrs = Files.readAttributes(p, java.nio.file.attribute.BasicFileAttributes.class);
map.put(
root.relativize(p).toString(),
InputTree.hash(p)
+ ":"
+ attrs.size()
+ ":"
+ attrs.lastModifiedTime()
+ ":"
+ attrs.fileKey());
}
}
}
return map;
}
private static void check(BooleanSupplier valid) throws IOException {
if (!valid.getAsBoolean())
throw new IOException("Build cancelled or inputs changed before publication");
}
private static void copy(
Path source,
Path dest,
Path stage,
Map<String, String> files,
Map<Path, String> sources,
BooleanSupplier valid)
throws IOException {
check(valid);
String before = InputTree.hash(source);
Files.createDirectories(dest.getParent());
Files.copy(source, dest);
String copied = InputTree.hash(dest);
if (!before.equals(copied) || !before.equals(InputTree.hash(source)))
throw new IOException("Runtime file changed while copying");
files.put(stage.relativize(dest).toString(), copied);
sources.put(source, before);
}
public final class Lease implements AutoCloseable {
private final Snapshot snapshot;
private final Path ticket;
private final FileChannel channel;
private final FileLock lock;
private Lease(Snapshot snapshot, Path ticket, FileChannel channel, FileLock lock) {
this.snapshot = snapshot;
this.ticket = ticket;
this.channel = channel;
this.lock = lock;
}
public Snapshot snapshot() {
return snapshot;
}
public List<Path> runtime() {
return snapshot.runtime().stream().map(p -> root.resolve(snapshot.id()).resolve(p)).toList();
}
public synchronized void close() throws IOException {
if (!channel.isOpen()) return;
lock.release();
channel.close();
Files.deleteIfExists(ticket);
}
}
public Lease lease(String id) throws IOException {
return locked(
() -> {
if (id == null || !id.matches("[0-9a-f-]{36}"))
throw new IOException("Invalid snapshot identity");
Path directory = root.resolve(id);
var snapshot =
JSON.readValue(directory.resolve("manifest.json").toFile(), Snapshot.class);
if (!id.equals(snapshot.id())) throw new IOException("Manifest identity mismatch");
for (var e : snapshot.files().entrySet()) {
Path file = directory.resolve(e.getKey()).normalize();
if (!file.startsWith(directory)
|| !file.toRealPath().startsWith(directory)
|| !InputTree.hash(file).equals(e.getValue()))
throw new IOException("Snapshot integrity failure");
}
var actual = new TreeMap<String, String>();
try (var paths = Files.walk(directory.resolve("runtime"))) {
for (Path p : paths.toList()) {
if (Files.isSymbolicLink(p)) throw new IOException("Symlink in published snapshot");
if (Files.isRegularFile(p))
actual.put(directory.relativize(p).toString(), InputTree.hash(p));
}
}
if (!actual.equals(snapshot.files())) throw new IOException("Snapshot file set changed");
for (String p : snapshot.runtime())
if (!directory.resolve(p).normalize().startsWith(directory)
|| !Files.exists(directory.resolve(p)))
throw new IOException("Invalid snapshot runtime path");
Path ticket = Files.createTempFile(root.resolve("leases"), id + "-", ".lock");
var channel = FileChannel.open(ticket, StandardOpenOption.WRITE);
try {
return new Lease(snapshot, ticket, channel, channel.lock());
} catch (Exception e) {
channel.close();
Files.deleteIfExists(ticket);
throw e;
}
});
}
/**
* Removes unpinned snapshots except retained last-successful builds; cleans abandoned staging.
*/
public int evict() throws IOException {
return locked(
() -> {
var retained = new HashSet<String>();
try (var paths = Files.list(root)) {
for (Path p :
paths
.filter(
p ->
p.getFileName().toString().startsWith("context-")
&& p.toString().endsWith(".json"))
.toList()) {
var state = JSON.readValue(p.toFile(), State.class);
if (state.lastSuccessful() != null) retained.add(state.lastSuccessful());
}
}
int removed = 0;
try (var paths = Files.list(root)) {
for (Path p : paths.filter(Files::isDirectory).toList()) {
String id = p.getFileName().toString();
if (id.startsWith(".staging-")) {
InputTree.delete(p);
continue;
}
if (!id.matches("[0-9a-f-]{36}") || retained.contains(id) || pinned(id)) continue;
InputTree.delete(p);
removed++;
}
}
return removed;
});
}
private boolean pinned(String id) throws IOException {
boolean pinned = false;
try (var tickets = Files.list(root.resolve("leases"))) {
for (Path ticket :
tickets.filter(p -> p.getFileName().toString().startsWith(id + "-")).toList()) {
try (var c = FileChannel.open(ticket, StandardOpenOption.WRITE)) {
try (var lock = c.tryLock()) {
if (lock == null) {
pinned = true;
continue;
}
} catch (OverlappingFileLockException e) {
pinned = true;
continue;
}
}
Files.deleteIfExists(ticket);
}
}
return pinned;
}
}
@@ -0,0 +1,49 @@
package de.neitzel.fxdesigner.build;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/** Detects transient changes and overflow in addition to pre/post hashes and file attributes. */
final class SourceWatch implements AutoCloseable {
private final WatchService watch;
private final Map<WatchKey, Path> keys = new HashMap<>();
private final Path root;
SourceWatch(Path root) throws IOException {
this.root = root;
watch = root.getFileSystem().newWatchService();
try (var dirs = Files.walk(root)) {
for (Path dir :
dirs.filter(Files::isDirectory)
.filter(p -> InputTree.included(root, root.relativize(p)))
.toList())
keys.put(
dir.register(
watch,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY),
dir);
}
}
boolean changed() {
boolean changed = false;
for (WatchKey key; (key = watch.poll()) != null; ) {
Path dir = keys.get(key);
for (var event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW
|| dir == null
|| InputTree.included(root, root.relativize(dir.resolve((Path) event.context()))))
changed = true;
}
if (!key.reset()) changed = true;
}
return changed;
}
public void close() throws IOException {
watch.close();
}
}
@@ -25,6 +25,11 @@ public record HostServices(
public interface Documents {
CompletionStage<DocumentSession> open(URI document);
default CompletionStage<List<de.neitzel.fxdesigner.contracts.DocumentSnapshot>> unsaved() {
return java.util.concurrent.CompletableFuture.failedFuture(
new UnsupportedOperationException("Host must enumerate known unsaved build inputs"));
}
}
public enum RuntimeRole {
@@ -0,0 +1,164 @@
package de.neitzel.fxdesigner.build;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class SnapshotStoreTest {
@TempDir Path root;
@Test
void copiesSurviveOutputReplacementAndLeasesPreventEvictionAcrossStoreInstances()
throws Exception {
Path output = Files.createDirectories(root.resolve("output"));
Files.writeString(output.resolve("A.class"), "old");
var store = new SnapshotStore(root.resolve("store"));
var first =
store.publish("desktop", Map.of("source", "hash"), List.of(output), Map.of(), () -> true);
var lease = store.lease(first.id());
Files.writeString(output.resolve("A.class"), "new");
var second = store.publish("desktop", Map.of(), List.of(output), Map.of(), () -> true);
assertEquals("old", Files.readString(lease.runtime().getFirst().resolve("A.class")));
assertEquals(0, new SnapshotStore(root.resolve("store")).evict());
lease.close();
assertEquals(1, store.evict());
try (var next = store.lease(second.id())) {
assertEquals("new", Files.readString(next.runtime().getFirst().resolve("A.class")));
}
}
@Test
void failedCaptureDoesNotReplaceLastSuccessAndAbandonedStagingIsRecoverable() throws Exception {
Path jar = Files.writeString(root.resolve("lib.jar"), "dependency");
var store = new SnapshotStore(root.resolve("store"));
var first = store.publish("c", Map.of(), List.of(jar), Map.of(), () -> true);
assertThrows(
java.io.IOException.class,
() -> store.publish("c", Map.of(), List.of(jar), Map.of(), () -> false));
assertEquals(first.id(), store.state("c").lastSuccessful());
Path abandoned = Files.createDirectory(root.resolve("store/.staging-crashed"));
store.evict();
assertFalse(Files.exists(abandoned));
store.invalidate("c", "Failed build");
assertTrue(new SnapshotStore(root.resolve("store")).state("c").outdated());
}
@Test
void mutationDuringCaptureAndSnapshotTamperingAreRejected() throws Exception {
Path output = Files.createDirectories(root.resolve("classes"));
Path a = Files.writeString(output.resolve("A.class"), "original");
var store = new SnapshotStore(root.resolve("store"));
var calls = new AtomicInteger();
assertThrows(
java.io.IOException.class,
() ->
store.publish(
"c",
Map.of(),
List.of(output),
Map.of(),
() -> true,
() -> {
if (calls.incrementAndGet() == 3)
try {
Files.writeString(a, "changed");
} catch (Exception e) {
throw new RuntimeException(e);
}
return false;
}));
assertNull(store.state("c").lastSuccessful());
var snapshot = store.publish("c", Map.of(), List.of(output), Map.of(), () -> true);
try (var lease = store.lease(snapshot.id())) {
Files.writeString(lease.runtime().getFirst().resolve("A.class"), "tampered");
}
assertThrows(java.io.IOException.class, () -> store.lease(snapshot.id()));
}
@Test
void inputIsolationRejectsSymlinksAndRecordsAdditionsAndSameContentRewrites() throws Exception {
Path source = Files.createDirectories(root.resolve("source"));
Path file = Files.writeString(source.resolve("A.java"), "same");
var before = InputTree.scan(source);
Files.setLastModifiedTime(
file,
java.nio.file.attribute.FileTime.fromMillis(
Files.getLastModifiedTime(file).toMillis() + 1000));
assertNotEquals(before, InputTree.scan(source));
Files.createSymbolicLink(source.resolve("link"), file);
assertThrows(java.io.IOException.class, () -> InputTree.scan(source));
}
public static final class LeaseKeeper {
public static void main(String[] args) throws Exception {
try (var lease = new SnapshotStore(Path.of(args[0])).lease(args[1])) {
System.out.println("LEASED");
System.out.flush();
System.in.read();
}
}
}
@Test
void workerProcessLeaseSurvivesAnotherHostAndReleasesAfterOwnerDeath() throws Exception {
Path jar = Files.writeString(root.resolve("lib.jar"), "one");
Path cache = root.resolve("store");
var store = new SnapshotStore(cache);
var first = store.publish("c", Map.of(), List.of(jar), Map.of(), () -> true);
var child =
new ProcessBuilder(
Path.of(System.getProperty("java.home"), "bin/java").toString(),
"-cp",
System.getProperty("java.class.path"),
LeaseKeeper.class.getName(),
cache.toString(),
first.id())
.redirectErrorStream(true)
.start();
try {
var line =
java.util.concurrent.CompletableFuture.supplyAsync(
() -> {
try {
return new java.io.BufferedReader(
new java.io.InputStreamReader(child.getInputStream()))
.readLine();
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.get(10, java.util.concurrent.TimeUnit.SECONDS);
assertEquals("LEASED", line);
store.publish("c", Map.of(), List.of(jar), Map.of(), () -> true);
assertEquals(0, store.evict());
child.destroyForcibly();
assertTrue(child.waitFor(10, java.util.concurrent.TimeUnit.SECONDS));
assertEquals(1, new SnapshotStore(cache).evict());
} finally {
child.destroyForcibly();
}
}
@Test
void targetNamedSourcePackageIsNotConfusedWithMavenOutput() throws Exception {
Files.writeString(root.resolve("pom.xml"), "<project/>");
Files.createDirectories(root.resolve("target/classes"));
Files.writeString(root.resolve("target/classes/Old.class"), "old");
Files.createDirectories(root.resolve("src/main/java/example/target"));
Files.writeString(root.resolve("src/main/java/example/target/Model.java"), "class Model {}");
var inputs = InputTree.scan(root);
assertTrue(inputs.containsKey("src/main/java/example/target/Model.java"));
assertFalse(inputs.containsKey("target/classes/Old.class"));
}
@Test
void oldMavenCaptureWithoutDependencyProofIsRejected() throws Exception {
Path metadata = Files.createDirectory(root.resolve("metadata"));
Files.writeString(metadata.resolve("old.properties"), "format=1\n");
assertThrows(java.io.IOException.class, () -> MavenCapture.read(metadata, root, root, null));
}
}