Implement retained target sessions, runtime sizing and Component navigation

This commit is contained in:
2026-11-12 14:20:26 +01:00
parent e41b276b13
commit 6e25cf3ac6
71 changed files with 2538 additions and 120 deletions
@@ -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());
}
}
}
}
@@ -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();
}
}
}