Implement isolated Build and Refresh with immutable leased snapshots
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user