Implement retained target sessions, runtime sizing and Component navigation
This commit is contained in:
@@ -96,7 +96,9 @@ public final class ConfigurationFiles {
|
||||
p.path("module").asText(null),
|
||||
p.get("provider").asText(),
|
||||
p.path("defaultExample").asText(null),
|
||||
strings(p.path("examples"))),
|
||||
strings(p.path("examples")),
|
||||
p.path("requestTimeoutSeconds").asInt(30),
|
||||
p.path("readinessTimeoutSeconds").asInt(20)),
|
||||
m == null
|
||||
? null
|
||||
: new ModulePath(
|
||||
|
||||
@@ -31,6 +31,13 @@ public record HostServices(
|
||||
return () -> {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved resources restart affected interactive sessions; draft edits only mark them outdated.
|
||||
*/
|
||||
default AutoCloseable onSave(Consumer<URI> listener) {
|
||||
return () -> {};
|
||||
}
|
||||
|
||||
default CompletionStage<List<de.neitzel.fxdesigner.contracts.DocumentSnapshot>> snapshots() {
|
||||
return unsaved();
|
||||
}
|
||||
@@ -66,6 +73,11 @@ public record HostServices(
|
||||
|
||||
public interface Navigation {
|
||||
CompletionStage<Void> open(Location location);
|
||||
|
||||
/** Editable attached sources whose owning project is known to the host. */
|
||||
default CompletionStage<List<Location>> componentSources(String type) {
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(List.of());
|
||||
}
|
||||
}
|
||||
|
||||
public enum Severity {
|
||||
|
||||
@@ -25,7 +25,9 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
int height,
|
||||
byte[] rgba,
|
||||
Map<String, Long> revisions,
|
||||
String snapshot) {
|
||||
String snapshot,
|
||||
LayoutMetrics metrics,
|
||||
String session) {
|
||||
public Image {
|
||||
rgba = rgba.clone();
|
||||
revisions = Map.copyOf(revisions);
|
||||
@@ -37,12 +39,26 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
}
|
||||
|
||||
private final HostServices host;
|
||||
private final Path reactor;
|
||||
private final BuildRefresh build;
|
||||
private final BuildRefresh.Request request;
|
||||
private final List<Path> tools;
|
||||
private final Consumer<String> log;
|
||||
private PreviewWorker active;
|
||||
private long generation;
|
||||
private final LatestSession<PreviewWorker> rendering =
|
||||
new LatestSession<>(PreviewWorker::healthy);
|
||||
private final java.util.concurrent.atomic.AtomicLong renderRequests =
|
||||
new java.util.concurrent.atomic.AtomicLong();
|
||||
private volatile long renderingPid = -1;
|
||||
|
||||
public long renderingPid() {
|
||||
return renderingPid;
|
||||
}
|
||||
|
||||
private record RenderKey(
|
||||
String snapshot, String example, List<DocumentSnapshot> drafts, boolean last) {}
|
||||
|
||||
private volatile long generation;
|
||||
private boolean closed;
|
||||
|
||||
public DesignerWorkflow(
|
||||
@@ -54,6 +70,7 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
Consumer<String> log)
|
||||
throws Exception {
|
||||
this.host = host;
|
||||
reactor = configuration.toAbsolutePath().getParent().toRealPath();
|
||||
this.tools = List.copyOf(tools);
|
||||
this.log = log;
|
||||
request = new BuildRefresh.Request(configuration, context, maven);
|
||||
@@ -68,27 +85,110 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
host.lifecycle().own(this);
|
||||
}
|
||||
|
||||
public CompletionStage<URI> javaSource(String type) {
|
||||
if (type == null || !type.matches("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*"))
|
||||
return CompletableFuture.failedFuture(new IllegalArgumentException("Invalid Java type"));
|
||||
return CompletableFuture.supplyAsync(
|
||||
() -> {
|
||||
try (var selected = build.previewLastSuccessful(request.context())) {
|
||||
var roots =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readValue(selected.snapshot().details().get("sourceRoots"), String[].class);
|
||||
var matches = new LinkedHashSet<URI>();
|
||||
for (String root : roots) {
|
||||
Path file = Path.of(root).resolve(type.replace('.', '/') + ".java");
|
||||
if (Files.isRegularFile(file)) matches.add(file.toRealPath().toUri());
|
||||
}
|
||||
if (matches.size() != 1)
|
||||
throw new IllegalStateException("No unique Java source for " + type);
|
||||
return matches.iterator().next();
|
||||
} catch (Exception error) {
|
||||
throw new CompletionException(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public record ComponentSource(URI document, String origin, boolean editable) {
|
||||
public String toString() {
|
||||
return (editable ? "Source: " : "Read-only: ") + origin;
|
||||
}
|
||||
}
|
||||
|
||||
public CompletionStage<URI> componentSource(String type) {
|
||||
return componentSources(type)
|
||||
.thenApply(
|
||||
sources -> {
|
||||
if (sources.size() != 1)
|
||||
throw new IllegalStateException("Component has no unique source: " + type);
|
||||
return sources.getFirst().document();
|
||||
});
|
||||
}
|
||||
|
||||
public CompletionStage<List<ComponentSource>> componentSources(String type) {
|
||||
if (!type.matches("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*"))
|
||||
return CompletableFuture.failedFuture(
|
||||
new IllegalArgumentException("Invalid Component class name"));
|
||||
return CompletableFuture.supplyAsync(
|
||||
() -> {
|
||||
try (var lease = build.previewLastSuccessful(request.context())) {
|
||||
String resource = type.replace('.', '/') + ".fxml";
|
||||
var roots =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readValue(lease.snapshot().details().get("sourceRoots"), String[].class);
|
||||
var matches = new LinkedHashSet<Path>();
|
||||
var sources = new LinkedHashMap<URI, ComponentSource>();
|
||||
for (String root : roots) {
|
||||
Path path = Path.of(root).resolve(type.replace('.', '/') + ".fxml");
|
||||
if (Files.isRegularFile(path)) matches.add(path.toRealPath());
|
||||
Path path = Path.of(root).resolve(resource);
|
||||
if (Files.isRegularFile(path)) {
|
||||
URI uri = path.toRealPath().toUri();
|
||||
sources.put(uri, new ComponentSource(uri, path.toString(), Files.isWritable(path)));
|
||||
}
|
||||
}
|
||||
if (matches.size() != 1)
|
||||
if (!sources.isEmpty()) return List.copyOf(sources.values());
|
||||
for (Path artifact : lease.runtime()) {
|
||||
if (Files.isDirectory(artifact)) continue;
|
||||
try (var jar = new java.util.zip.ZipFile(artifact.toFile())) {
|
||||
var entry = jar.getEntry(resource);
|
||||
if (entry == null) continue;
|
||||
byte[] bytes;
|
||||
try (var input = jar.getInputStream(entry)) {
|
||||
bytes = input.readNBytes(1024 * 1024 + 1);
|
||||
}
|
||||
if (bytes.length > 1024 * 1024)
|
||||
throw new java.io.IOException("Component source exceeds 1 MiB");
|
||||
String hash =
|
||||
HexFormat.of()
|
||||
.formatHex(
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
Path file =
|
||||
request
|
||||
.configuration()
|
||||
.getParent()
|
||||
.resolve(".neitzel-fx-designer/sources")
|
||||
.resolve(hash)
|
||||
.resolve(resource);
|
||||
Files.createDirectories(file.getParent());
|
||||
if (!Files.exists(file)) {
|
||||
Files.write(file, bytes, StandardOpenOption.CREATE_NEW);
|
||||
if (!file.toFile().setWritable(false, false))
|
||||
throw new java.io.IOException("Cannot protect dependency source");
|
||||
}
|
||||
String origin =
|
||||
"build "
|
||||
+ lease.snapshot().id()
|
||||
+ " / "
|
||||
+ artifact.getFileName()
|
||||
+ "!/"
|
||||
+ resource;
|
||||
Files.writeString(file.resolveSibling(file.getFileName() + ".origin"), origin);
|
||||
sources.put(file.toUri(), new ComponentSource(file.toUri(), origin, false));
|
||||
}
|
||||
}
|
||||
if (sources.isEmpty())
|
||||
throw new IllegalStateException(
|
||||
"Component has no unique editable FXML source in the captured reactor: " + type);
|
||||
return matches.iterator().next().toUri();
|
||||
} catch (Exception e) {
|
||||
throw new CompletionException(e);
|
||||
"No Component FXML in captured sources/runtime: " + type);
|
||||
return List.copyOf(sources.values());
|
||||
} catch (Exception error) {
|
||||
throw new CompletionException(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -99,9 +199,14 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
}
|
||||
|
||||
public CompletionStage<List<Example>> discover(boolean lastSuccessful) {
|
||||
return discover(lastSuccessful, false, List.of());
|
||||
}
|
||||
|
||||
private CompletionStage<List<Example>> discover(
|
||||
boolean lastSuccessful, boolean allProviders, List<DocumentSnapshot> documents) {
|
||||
return execute(
|
||||
lastSuccessful,
|
||||
List.of(),
|
||||
documents,
|
||||
worker -> {
|
||||
var packet =
|
||||
worker.request(
|
||||
@@ -117,7 +222,7 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
for (int i = 0; i < count; i++) {
|
||||
String p = "example." + i + ".";
|
||||
String id = data.get(p + "id");
|
||||
if (config.preview().provider().equals(data.get(p + "providerClass"))
|
||||
if ((allProviders || config.preview().provider().equals(data.get(p + "providerClass")))
|
||||
&& (config.preview().examples().isEmpty()
|
||||
|| config.preview().examples().contains(id)))
|
||||
result.add(
|
||||
@@ -131,6 +236,91 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
public CompletionStage<List<Example>> examplesFor(boolean last, URI document) {
|
||||
return host.documents()
|
||||
.snapshots()
|
||||
.thenCompose(
|
||||
drafts ->
|
||||
discover(
|
||||
last,
|
||||
true,
|
||||
drafts.stream()
|
||||
.filter(
|
||||
d ->
|
||||
d.document().toString().endsWith(".fxml")
|
||||
&& Path.of(d.document()).startsWith(reactor)
|
||||
&& !Path.of(d.document())
|
||||
.startsWith(reactor.resolve(".neitzel-fx-designer")))
|
||||
.toList()))
|
||||
.thenApply(
|
||||
entries -> {
|
||||
try (var selected = lease(true, List.of())) {
|
||||
var roots =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readValue(
|
||||
selected.snapshot().details().get("sourceRoots"), String[].class);
|
||||
Path file = Path.of(document).toRealPath();
|
||||
var matching = new HashSet<String>();
|
||||
for (String root : roots) {
|
||||
if (!Files.isDirectory(Path.of(root))) continue;
|
||||
Path base = Path.of(root).toRealPath();
|
||||
if (file.startsWith(base))
|
||||
matching.add(
|
||||
base.relativize(file).toString().replace(java.io.File.separatorChar, '/'));
|
||||
}
|
||||
return entries.stream().filter(e -> matching.contains(e.viewResource())).toList();
|
||||
} catch (Exception error) {
|
||||
throw new CompletionException(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private volatile long interactivePid = -1;
|
||||
private volatile Runnable interactiveClosed = () -> {};
|
||||
|
||||
public void onInteractiveClosed(Runnable action) {
|
||||
interactiveClosed = Objects.requireNonNull(action);
|
||||
}
|
||||
|
||||
public CompletionStage<Void> interactive(
|
||||
boolean last, String example, List<DocumentSnapshot> drafts) {
|
||||
long ownerGeneration;
|
||||
synchronized (this) {
|
||||
ownerGeneration = generation;
|
||||
}
|
||||
return executeRetained(
|
||||
last,
|
||||
example,
|
||||
drafts,
|
||||
worker -> {
|
||||
var revisions = new TreeMap<String, Long>();
|
||||
drafts.forEach(d -> revisions.put(d.document().toString(), d.revision()));
|
||||
if (interactivePid == worker.pid()) return null;
|
||||
worker.request(
|
||||
Operation.INTERACTIVE,
|
||||
revisions,
|
||||
640,
|
||||
480,
|
||||
PreviewMessages.encode(Map.of("example", example, "node", "")),
|
||||
Duration.ofSeconds(worker.context().preview().requestTimeoutSeconds()));
|
||||
synchronized (this) {
|
||||
if (generation != ownerGeneration) throw new CancellationException();
|
||||
interactivePid = worker.pid();
|
||||
}
|
||||
long pid = worker.pid();
|
||||
worker
|
||||
.exited()
|
||||
.thenRun(
|
||||
() -> {
|
||||
if (generation == ownerGeneration && interactivePid == pid) {
|
||||
cancel();
|
||||
interactiveClosed.run();
|
||||
}
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public record Size(int width, int height) {}
|
||||
|
||||
public CompletionStage<Image> render(
|
||||
@@ -169,8 +359,9 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
if (bytes > 64L * 1024 * 1024)
|
||||
return CompletableFuture.failedFuture(
|
||||
new IllegalArgumentException("Render batch exceeds 64 MiB"));
|
||||
return execute(
|
||||
return executeRetained(
|
||||
lastSuccessful,
|
||||
example,
|
||||
captured,
|
||||
worker -> {
|
||||
var revisions = new TreeMap<String, Long>();
|
||||
@@ -179,14 +370,24 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
for (var entry : requested.entrySet()) {
|
||||
var size = entry.getValue();
|
||||
String node = entry.getKey();
|
||||
var measured =
|
||||
worker.request(
|
||||
Operation.MEASURE,
|
||||
revisions,
|
||||
size.width(),
|
||||
size.height(),
|
||||
PreviewMessages.encode(Map.of("example", example, "node", node)),
|
||||
Duration.ofSeconds(worker.context().preview().requestTimeoutSeconds()));
|
||||
var metrics = LayoutMetrics.decode(PreviewMessages.decode(measured.payload()));
|
||||
var result =
|
||||
worker.request(
|
||||
Operation.RENDER,
|
||||
revisions,
|
||||
size.width(),
|
||||
size.height(),
|
||||
PreviewMessages.encode(Map.of("example", example, "node", node)),
|
||||
Duration.ofSeconds(30));
|
||||
PreviewMessages.encode(
|
||||
Map.of("example", example, "node", metrics.occurrence())),
|
||||
Duration.ofSeconds(worker.context().preview().requestTimeoutSeconds()));
|
||||
images.add(
|
||||
new Image(
|
||||
node,
|
||||
@@ -194,7 +395,9 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
size.height(),
|
||||
result.payload(),
|
||||
revisions,
|
||||
result.identity().snapshot()));
|
||||
result.identity().snapshot(),
|
||||
metrics,
|
||||
result.identity().session()));
|
||||
}
|
||||
for (var draft : captured) {
|
||||
try (var doc = host.documents().open(draft.document()).toCompletableFuture().get()) {
|
||||
@@ -206,6 +409,61 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private SnapshotStore.Lease lease(boolean last, List<DocumentSnapshot> drafts) throws Exception {
|
||||
return last
|
||||
? build.previewLastSuccessful(request.context())
|
||||
: build.previewCurrent(
|
||||
request,
|
||||
drafts.stream()
|
||||
.map(DocumentSnapshot::document)
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
private <T> CompletionStage<T> executeRetained(
|
||||
boolean last, String example, List<DocumentSnapshot> drafts, Work<T> work) {
|
||||
long ticket = renderRequests.incrementAndGet();
|
||||
return CompletableFuture.supplyAsync(
|
||||
() -> {
|
||||
try (var selected = lease(last, drafts)) {
|
||||
return new RenderKey(
|
||||
selected.snapshot().id(),
|
||||
example,
|
||||
drafts.stream()
|
||||
.sorted(Comparator.comparing(d -> d.document().toString()))
|
||||
.toList(),
|
||||
last);
|
||||
} catch (Exception e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
})
|
||||
.thenCompose(
|
||||
key -> {
|
||||
synchronized (this) {
|
||||
if (closed || renderRequests.get() != ticket)
|
||||
return CompletableFuture.failedFuture(new CancellationException("Superseded"));
|
||||
return rendering.submit(
|
||||
key,
|
||||
() -> {
|
||||
var selected = lease(last, drafts);
|
||||
if (!selected.snapshot().id().equals(key.snapshot())) {
|
||||
selected.close();
|
||||
throw new CancellationException("Build changed during worker startup");
|
||||
}
|
||||
var worker = new PreviewWorker(new DraftOverlay(selected, drafts));
|
||||
return worker;
|
||||
},
|
||||
worker -> {
|
||||
worker.ensureStarted(tools, log);
|
||||
renderingPid = worker.pid();
|
||||
T value = work.run(worker);
|
||||
if (ticket != renderRequests.get())
|
||||
throw new CancellationException("Superseded");
|
||||
return value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private interface Work<T> {
|
||||
T run(PreviewWorker worker) throws Exception;
|
||||
}
|
||||
@@ -274,6 +532,10 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
|
||||
public synchronized void cancel() {
|
||||
generation++;
|
||||
renderRequests.incrementAndGet();
|
||||
interactivePid = -1;
|
||||
rendering.cancel();
|
||||
renderingPid = -1;
|
||||
var old = active;
|
||||
active = null;
|
||||
if (old != null) Thread.ofVirtual().start(old::close);
|
||||
@@ -283,6 +545,7 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
cancel();
|
||||
rendering.close();
|
||||
build.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package de.neitzel.fxdesigner.workflow;
|
||||
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** One retained resource, one running operation and one replaceable pending operation. */
|
||||
public final class LatestSession<R extends AutoCloseable> implements AutoCloseable {
|
||||
public interface Factory<R> {
|
||||
R open() throws Exception;
|
||||
}
|
||||
|
||||
public interface Work<R, T> {
|
||||
T run(R resource) throws Exception;
|
||||
}
|
||||
|
||||
private record Job<R, T>(
|
||||
Object key,
|
||||
Factory<R> factory,
|
||||
Work<R, T> work,
|
||||
CompletableFuture<T> result,
|
||||
long revision) {}
|
||||
|
||||
private final java.util.function.Predicate<R> healthy;
|
||||
|
||||
public LatestSession() {
|
||||
this(value -> true);
|
||||
}
|
||||
|
||||
public LatestSession(java.util.function.Predicate<R> healthy) {
|
||||
this.healthy = healthy;
|
||||
}
|
||||
|
||||
private Job<R, ?> pending;
|
||||
private R resource;
|
||||
private Object resourceKey;
|
||||
private long revision;
|
||||
private boolean draining, closed;
|
||||
|
||||
public <T> CompletionStage<T> submit(Object key, Factory<R> factory, Work<R, T> work) {
|
||||
Job<R, ?> obsolete;
|
||||
R replaced = null;
|
||||
boolean start;
|
||||
var result = new CompletableFuture<T>();
|
||||
synchronized (this) {
|
||||
if (closed)
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Session closed"));
|
||||
obsolete = pending;
|
||||
if (resource != null && !key.equals(resourceKey)) {
|
||||
replaced = resource;
|
||||
resource = null;
|
||||
resourceKey = null;
|
||||
}
|
||||
pending = new Job<>(key, factory, work, result, ++revision);
|
||||
start = !draining;
|
||||
draining = true;
|
||||
}
|
||||
if (obsolete != null)
|
||||
obsolete.result.completeExceptionally(new CancellationException("Superseded"));
|
||||
closeAsync(replaced);
|
||||
if (start) Thread.ofVirtual().start(this::drain);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
while (true) {
|
||||
Job<R, ?> job;
|
||||
synchronized (this) {
|
||||
job = pending;
|
||||
pending = null;
|
||||
if (job == null) {
|
||||
draining = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
run(job);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> void run(Job<R, T> job) {
|
||||
R current = null;
|
||||
try {
|
||||
synchronized (this) {
|
||||
if (closed || job.revision != revision) throw new CancellationException();
|
||||
if (job.key.equals(resourceKey) && resource != null && healthy.test(resource))
|
||||
current = resource;
|
||||
}
|
||||
if (current == null) {
|
||||
disposeResource();
|
||||
current = job.factory.open();
|
||||
boolean obsolete;
|
||||
synchronized (this) {
|
||||
obsolete = closed || job.revision != revision;
|
||||
if (!obsolete) {
|
||||
resource = current;
|
||||
resourceKey = job.key;
|
||||
}
|
||||
}
|
||||
if (obsolete) {
|
||||
closeResource(current);
|
||||
throw new CancellationException();
|
||||
}
|
||||
}
|
||||
T value = job.work.run(current);
|
||||
synchronized (this) {
|
||||
if (closed || job.revision != revision) throw new CancellationException("Superseded");
|
||||
}
|
||||
// Never execute client continuations under the resource lock.
|
||||
job.result.complete(value);
|
||||
} catch (Throwable failure) {
|
||||
if (!(failure instanceof CancellationException)) disposeResource();
|
||||
job.result.completeExceptionally(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void disposeResource() {
|
||||
R old;
|
||||
synchronized (this) {
|
||||
old = resource;
|
||||
resource = null;
|
||||
resourceKey = null;
|
||||
}
|
||||
closeResource(old);
|
||||
}
|
||||
|
||||
private static void closeResource(AutoCloseable value) {
|
||||
if (value != null)
|
||||
try {
|
||||
value.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeAsync(AutoCloseable value) {
|
||||
if (value != null) Thread.ofVirtual().start(() -> closeResource(value));
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
R old;
|
||||
Job<R, ?> obsolete;
|
||||
synchronized (this) {
|
||||
revision++;
|
||||
obsolete = pending;
|
||||
pending = null;
|
||||
old = resource;
|
||||
resource = null;
|
||||
resourceKey = null;
|
||||
}
|
||||
if (obsolete != null) obsolete.result.completeExceptionally(new CancellationException());
|
||||
closeAsync(old);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
synchronized (this) {
|
||||
closed = true;
|
||||
}
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
@@ -27,16 +27,38 @@ public final class PreviewWorker implements AutoCloseable {
|
||||
this.overlay = overlay;
|
||||
}
|
||||
|
||||
public synchronized boolean healthy() {
|
||||
return !closed && (process == null || process.isAlive());
|
||||
}
|
||||
|
||||
public synchronized long pid() {
|
||||
return process == null ? -1 : process.pid();
|
||||
}
|
||||
|
||||
public synchronized CompletionStage<Process> exited() {
|
||||
if (process == null)
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Worker not started"));
|
||||
return process.onExit();
|
||||
}
|
||||
|
||||
public DesignerConfiguration.Context context() throws Exception {
|
||||
return new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readValue(
|
||||
overlay.snapshot().details().get("contextSpec"), DesignerConfiguration.Context.class);
|
||||
}
|
||||
|
||||
private boolean started;
|
||||
|
||||
/** Called only by the serialized session executor; close remains independently callable. */
|
||||
public void ensureStarted(List<Path> tools, Consumer<String> log) throws Exception {
|
||||
if (!started) {
|
||||
start(tools, log);
|
||||
started = true;
|
||||
}
|
||||
if (closed || !process.isAlive())
|
||||
throw new IOException("Target worker exited; Retry starts a fresh session");
|
||||
}
|
||||
|
||||
public void start(List<Path> tools, Consumer<String> log) throws Exception {
|
||||
var snapshot = overlay.snapshot();
|
||||
var config = context();
|
||||
@@ -75,13 +97,14 @@ public final class PreviewWorker implements AutoCloseable {
|
||||
server.setSoTimeout(15000);
|
||||
command.add(Integer.toString(server.getLocalPort()));
|
||||
command.add(id);
|
||||
command.add(Integer.toString(config.preview().readinessTimeoutSeconds()));
|
||||
process = new ProcessBuilder(command).start();
|
||||
protocol =
|
||||
new ProtocolSession(
|
||||
id,
|
||||
process.pid(),
|
||||
token,
|
||||
Set.of("discover-v1", "render-rgba-v1"),
|
||||
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1"),
|
||||
config.requiredCapabilities());
|
||||
}
|
||||
drain(process.getInputStream(), log);
|
||||
@@ -184,6 +207,11 @@ public final class PreviewWorker implements AutoCloseable {
|
||||
child.destroyForcibly();
|
||||
}
|
||||
}
|
||||
if (child != null && child.isAlive()) child.onExit().thenRun(this::releaseOverlay);
|
||||
else releaseOverlay();
|
||||
}
|
||||
|
||||
private void releaseOverlay() {
|
||||
try {
|
||||
overlay.close();
|
||||
} catch (IOException ignored) {
|
||||
|
||||
@@ -28,6 +28,23 @@ class ConfigurationTest {
|
||||
n.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8), "configuration");
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewDeadlinesAreBoundedAndOrdered() throws Exception {
|
||||
var n = valid();
|
||||
var preview = (ObjectNode) context(n).get("preview");
|
||||
preview.put("requestTimeoutSeconds", 40).put("readinessTimeoutSeconds", 25);
|
||||
Path file = root.resolve("timeouts.json");
|
||||
Files.writeString(file, n.toString());
|
||||
assertEquals(
|
||||
40,
|
||||
ConfigurationFiles.load(file).contexts().get("desktop").preview().requestTimeoutSeconds());
|
||||
preview.put("requestTimeoutSeconds", 0);
|
||||
assertThrows(ConfigurationException.class, () -> validate(n));
|
||||
preview.put("requestTimeoutSeconds", 10);
|
||||
Files.writeString(file, n.toString());
|
||||
assertThrows(IllegalArgumentException.class, () -> ConfigurationFiles.load(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagedFixturesValidate() throws Exception {
|
||||
for (String name : List.of("classpath-module", "module-path-test")) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.neitzel.fxdesigner.workflow;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import de.neitzel.fxdesigner.build.*;
|
||||
import de.neitzel.fxdesigner.host.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ComponentSourceTest {
|
||||
@TempDir Path root;
|
||||
|
||||
@Test
|
||||
void jarSourceIsReadOnlyPersistentAndUnambiguousSourcesArePreferred() throws Exception {
|
||||
Path jar = root.resolve("components.jar");
|
||||
try (var zip = new java.util.zip.ZipOutputStream(Files.newOutputStream(jar))) {
|
||||
zip.putNextEntry(new java.util.zip.ZipEntry("example/Card.fxml"));
|
||||
zip.write("<VBox/>".getBytes());
|
||||
zip.closeEntry();
|
||||
}
|
||||
Path sources = Files.createDirectories(root.resolve("resources"));
|
||||
var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots"));
|
||||
store.publish(
|
||||
"test",
|
||||
Map.of(),
|
||||
List.of(jar),
|
||||
Map.of(
|
||||
"sourceRoots",
|
||||
new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.writeValueAsString(List.of(sources.toString()))),
|
||||
() -> true);
|
||||
try (var scope = new SessionScope()) {
|
||||
var host =
|
||||
new HostServices(
|
||||
uri -> CompletableFuture.failedFuture(new UnsupportedOperationException()),
|
||||
(r, o) -> {
|
||||
throw new UnsupportedOperationException();
|
||||
},
|
||||
(n, r) -> CompletableFuture.failedFuture(new UnsupportedOperationException()),
|
||||
l -> CompletableFuture.completedFuture(null),
|
||||
d -> {},
|
||||
scope);
|
||||
try (var workflow =
|
||||
new DesignerWorkflow(
|
||||
host, root.resolve("neitzel-fx-designer.json"), "test", null, List.of(), s -> {})) {
|
||||
var result = workflow.componentSources("example.Card").toCompletableFuture().get();
|
||||
assertEquals(1, result.size());
|
||||
assertFalse(result.getFirst().editable());
|
||||
Path file = Path.of(result.getFirst().document());
|
||||
assertEquals("<VBox/>", Files.readString(file));
|
||||
assertFalse(Files.isWritable(file));
|
||||
assertTrue(result.getFirst().origin().contains("components.jar!/example/Card.fxml"));
|
||||
Path editable = sources.resolve("example/Card.fxml");
|
||||
Files.createDirectories(editable.getParent());
|
||||
Files.writeString(editable, "<VBox/>");
|
||||
assertEquals(
|
||||
editable.toRealPath().toUri(),
|
||||
workflow.componentSource("example.Card").toCompletableFuture().get());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
-1
@@ -36,7 +36,7 @@ public final class ConnectedWorkflowContract {
|
||||
var built = await(workflow.build(Map.of()).completion());
|
||||
check(built.status() == BuildRefresh.Status.READY, "Build failed: " + built.message());
|
||||
var examples = await(workflow.discover(false));
|
||||
check(examples.size() == 9, "Discovery from snapshot");
|
||||
check(examples.size() == 11, "Discovery from snapshot");
|
||||
Path file = fixture.resolve("app/src/main/resources/fixture/components/Person.fxml"),
|
||||
child = fixture.resolve("app/src/main/resources/fixture/components/Card.fxml");
|
||||
try (var doc = await(host.documents().open(file.toUri()));
|
||||
@@ -44,6 +44,8 @@ public final class ConnectedWorkflowContract {
|
||||
var initial = await(doc.snapshot());
|
||||
var first = await(workflow.render(false, "standard", "card", 310, 220, List.of(initial)));
|
||||
check(first.rgba().length == 310 * 220 * 4, "Image frame");
|
||||
long firstPid = workflow.renderingPid();
|
||||
check(first.metrics().prefWidth() >= 0, "Target measurements missing");
|
||||
var batch =
|
||||
await(
|
||||
workflow.renderImages(
|
||||
@@ -59,6 +61,26 @@ public final class ConnectedWorkflowContract {
|
||||
batch.size() == 2
|
||||
&& batch.stream().allMatch(image -> image.snapshot().equals(first.snapshot())),
|
||||
"Shared parent render batch");
|
||||
check(workflow.renderingPid() == firstPid, "Same snapshot restarted worker");
|
||||
check(
|
||||
first
|
||||
.metrics()
|
||||
.occurrence()
|
||||
.equals(
|
||||
batch.stream()
|
||||
.filter(i -> i.node().equals("card"))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.metrics()
|
||||
.occurrence()),
|
||||
"Occurrence identity changed");
|
||||
var process = ProcessHandle.of(firstPid).orElseThrow();
|
||||
process.destroyForcibly();
|
||||
process.onExit().get(5, TimeUnit.SECONDS);
|
||||
var retried = await(workflow.render(false, "standard", "card", 310, 220, List.of(initial)));
|
||||
check(
|
||||
workflow.renderingPid() != firstPid && !retried.session().equals(first.session()),
|
||||
"Crash retry did not replace session");
|
||||
check(
|
||||
Path.of(await(workflow.componentSource("fixture.components.Card")))
|
||||
.equals(child.toRealPath()),
|
||||
@@ -76,6 +98,7 @@ public final class ConnectedWorkflowContract {
|
||||
initial.text().length(),
|
||||
initial.text().replace("FXML caption", "Draft caption"))))));
|
||||
var second = await(workflow.render(false, "standard", "card", 310, 220, List.of(changed)));
|
||||
check(workflow.renderingPid() != firstPid, "Draft change reused worker");
|
||||
check(!Arrays.equals(first.rgba(), second.rgba()), "Parent draft not rendered");
|
||||
check(first.snapshot().equals(second.snapshot()), "FXML edit rebuilt Java");
|
||||
var childInitial = await(component.snapshot());
|
||||
@@ -169,6 +192,56 @@ public final class ConnectedWorkflowContract {
|
||||
checks++;
|
||||
}
|
||||
}
|
||||
Path full = fixture.resolve("app/src/main/resources/fixture/components/Full.fxml");
|
||||
try (var fullDoc = await(host.documents().open(full.toUri()))) {
|
||||
var fullDraft = await(fullDoc.snapshot());
|
||||
var instrumented =
|
||||
new DocumentSnapshot(
|
||||
fullDraft.document(),
|
||||
fullDraft.revision(),
|
||||
fullDraft
|
||||
.text()
|
||||
.replace(
|
||||
"id=\"different-css-id\"",
|
||||
"id=\"different-css-id\""
|
||||
+ " de.neitzel.fxdesigner.target.Occurrences.key=\"home\""),
|
||||
fullDraft.writable(),
|
||||
fullDraft.dirty());
|
||||
var fullDrafts = new ArrayList<>(await(host.documents().snapshots()));
|
||||
var fixtureRoot = fixture.toRealPath();
|
||||
fullDrafts.removeIf(d -> !Path.of(d.document()).startsWith(fixtureRoot));
|
||||
fullDrafts.removeIf(d -> d.document().equals(fullDraft.document()));
|
||||
fullDrafts.add(instrumented);
|
||||
var frames =
|
||||
await(
|
||||
workflow.renderImages(
|
||||
false,
|
||||
"full",
|
||||
Map.of(
|
||||
"home",
|
||||
new DesignerWorkflow.Size(220, 180),
|
||||
"visits",
|
||||
new DesignerWorkflow.Size(420, 240),
|
||||
"table",
|
||||
new DesignerWorkflow.Size(420, 250)),
|
||||
fullDrafts));
|
||||
check(frames.size() == 3, "Nested/repeated/Table host render");
|
||||
long pid = workflow.renderingPid();
|
||||
var resized = await(workflow.render(false, "full", "home", 110, 180, fullDrafts));
|
||||
check(workflow.renderingPid() == pid, "Sizing replaced parent Model graph");
|
||||
check(
|
||||
resized.metrics().prefHeight()
|
||||
>= frames.stream()
|
||||
.filter(f -> f.node().equals("home"))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.metrics()
|
||||
.prefHeight(),
|
||||
"Host wrapping measurement");
|
||||
check(
|
||||
await(fullDoc.snapshot()).text().equals(fullDraft.text()),
|
||||
"Derived identity leaked into source");
|
||||
}
|
||||
workflow.close();
|
||||
return checks;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package de.neitzel.fxdesigner.workflow;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LatestSessionTest {
|
||||
static class Resource implements AutoCloseable {
|
||||
final AtomicInteger closes;
|
||||
|
||||
Resource(AtomicInteger closes) {
|
||||
this.closes = closes;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
closes.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retainsReplacesAndReleases() throws Exception {
|
||||
var opens = new AtomicInteger();
|
||||
var closes = new AtomicInteger();
|
||||
try (var session = new LatestSession<Resource>()) {
|
||||
LatestSession.Factory<Resource> factory =
|
||||
() -> {
|
||||
opens.incrementAndGet();
|
||||
return new Resource(closes);
|
||||
};
|
||||
var first = session.submit("a", factory, r -> r).toCompletableFuture().get();
|
||||
assertSame(first, session.submit("a", factory, r -> r).toCompletableFuture().get());
|
||||
assertNotSame(first, session.submit("b", factory, r -> r).toCompletableFuture().get());
|
||||
assertEquals(2, opens.get());
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (closes.get() < 1 && System.nanoTime() < deadline) Thread.sleep(10);
|
||||
assertEquals(1, closes.get());
|
||||
}
|
||||
long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (closes.get() != 2 && System.nanoTime() < until) Thread.sleep(10);
|
||||
assertEquals(2, closes.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void coalescesPendingAndRejectsRunningStaleResult() throws Exception {
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var opens = new AtomicInteger();
|
||||
var closes = new AtomicInteger();
|
||||
try (var session = new LatestSession<Resource>()) {
|
||||
LatestSession.Factory<Resource> factory =
|
||||
() -> {
|
||||
opens.incrementAndGet();
|
||||
return new Resource(closes);
|
||||
};
|
||||
var first =
|
||||
session
|
||||
.submit(
|
||||
"a",
|
||||
factory,
|
||||
r -> {
|
||||
entered.countDown();
|
||||
release.await();
|
||||
return 1;
|
||||
})
|
||||
.toCompletableFuture();
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var second =
|
||||
session.submit("a", factory, r -> fail("Obsolete work ran")).toCompletableFuture();
|
||||
var third = session.submit("a", factory, r -> 3).toCompletableFuture();
|
||||
release.countDown();
|
||||
assertThrows(CancellationException.class, first::join);
|
||||
assertThrows(CancellationException.class, second::join);
|
||||
assertEquals(3, third.get(5, TimeUnit.SECONDS));
|
||||
assertEquals(1, opens.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user