Establish buildable modules and shared host-service boundaries

This commit is contained in:
2026-11-12 07:49:06 +01:00
parent 1326b18746
commit 306266ae40
41 changed files with 1980 additions and 23 deletions
@@ -0,0 +1,27 @@
package de.neitzel.fxdesigner.host;
import java.util.*;
import java.util.concurrent.*;
/**
* Explicit local mappings; no fallback to the host JVM. Discovery/requirements arrive with
* configuration.
*/
public final class ConfiguredRuntimes implements HostServices.Runtimes {
public record Key(String logicalName, HostServices.RuntimeRole role) {}
private final Map<Key, HostServices.RuntimeInstallation> installations;
public ConfiguredRuntimes(Map<Key, HostServices.RuntimeInstallation> installations) {
this.installations = Map.copyOf(installations);
}
public CompletionStage<HostServices.RuntimeInstallation> resolve(
String name, HostServices.RuntimeRole role) {
var found = installations.get(new Key(name, role));
return found == null
? CompletableFuture.failedFuture(
new IllegalStateException("Configure runtime " + name + " for " + role))
: CompletableFuture.completedFuture(found);
}
}
@@ -0,0 +1,23 @@
package de.neitzel.fxdesigner.host;
import de.neitzel.fxdesigner.contracts.*;
import java.util.concurrent.CompletionStage;
/**
* Host draft/undo authority. Futures complete after host-thread work; never block its UI thread.
*/
public interface DocumentSession extends AutoCloseable {
CompletionStage<DocumentSnapshot> snapshot();
CompletionStage<DocumentSnapshot> apply(EditBatch edits);
CompletionStage<DocumentSnapshot> undo();
CompletionStage<DocumentSnapshot> redo();
CompletionStage<DocumentSnapshot> save(long expectedRevision);
/** Releases this handle; does not save, discard or close unrelated host documents. */
@Override
void close();
}
@@ -0,0 +1,109 @@
package de.neitzel.fxdesigner.host;
import java.net.URI;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
/** Host integrations supplied to each designer session, with no host SDK types. */
public record HostServices(
Documents documents,
Builds builds,
Runtimes runtimes,
Navigation navigation,
Diagnostics diagnostics,
SessionScope lifecycle) {
public HostServices {
Objects.requireNonNull(documents);
Objects.requireNonNull(builds);
Objects.requireNonNull(runtimes);
Objects.requireNonNull(navigation);
Objects.requireNonNull(diagnostics);
Objects.requireNonNull(lifecycle);
}
public interface Documents {
CompletionStage<DocumentSession> open(URI document);
}
public enum RuntimeRole {
BUILD,
TARGET
}
public record RuntimeInstallation(Path javaHome, int javaFeature) {
public RuntimeInstallation {
Objects.requireNonNull(javaHome);
if (javaFeature < 1) throw new IllegalArgumentException("Invalid Java feature");
}
}
public interface Runtimes {
CompletionStage<RuntimeInstallation> resolve(String logicalName, RuntimeRole role);
}
public record Location(URI document, int offset) {
public Location {
Objects.requireNonNull(document);
if (offset < 0) throw new IllegalArgumentException("Negative offset");
}
}
public interface Navigation {
CompletionStage<Void> open(Location location);
}
public enum Severity {
INFO,
WARNING,
ERROR
}
public record Diagnostic(
String operation, Severity severity, String message, Optional<Location> location) {
public Diagnostic {
Objects.requireNonNull(operation);
Objects.requireNonNull(severity);
Objects.requireNonNull(message);
Objects.requireNonNull(location);
}
}
public interface Diagnostics {
void report(Diagnostic diagnostic);
}
public record BuildRequest(
Path directory, List<String> command, Map<String, String> environment) {
public BuildRequest {
Objects.requireNonNull(directory);
command = List.copyOf(command);
environment = Map.copyOf(environment);
if (command.isEmpty()) throw new IllegalArgumentException("Empty build command");
}
}
public record BuildOutput(boolean stderr, String line) {}
public record BuildResult(int exitCode, boolean cancelled) {}
public interface BuildRun extends AutoCloseable {
CompletionStage<BuildResult> completion();
void cancel();
@Override
default void close() {
cancel();
}
}
/**
* Executes an already-resolved build plan; Maven resolution/snapshot capture belong to later
* steps.
*/
public interface Builds {
BuildRun start(BuildRequest request, Consumer<BuildOutput> output) throws java.io.IOException;
}
}
@@ -0,0 +1,81 @@
package de.neitzel.fxdesigner.host;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/** Shared process executor used by both adapters; host output is supplied explicitly. */
public final class ProcessBuilds implements HostServices.Builds {
@Override
public HostServices.BuildRun start(
HostServices.BuildRequest request, Consumer<HostServices.BuildOutput> output)
throws IOException {
var builder = new ProcessBuilder(request.command()).directory(request.directory().toFile());
builder.environment().putAll(request.environment());
var process = builder.start();
var cancelled = new AtomicBoolean();
var completion = new CompletableFuture<HostServices.BuildResult>();
var out = pump(process.getInputStream(), false, output);
var err = pump(process.getErrorStream(), true, output);
Thread.ofVirtual()
.start(
() -> {
try {
int exit = process.waitFor();
out.join();
err.join();
completion.complete(new HostServices.BuildResult(exit, cancelled.get()));
} catch (Exception e) {
completion.completeExceptionally(e);
}
});
return new HostServices.BuildRun() {
public CompletionStage<HostServices.BuildResult> completion() {
return completion;
}
public void cancel() {
if (!process.isAlive()) return;
cancelled.set(true);
var descendants = process.descendants().toList();
descendants.forEach(ProcessHandle::destroy);
process.destroy();
Thread.ofVirtual()
.start(
() -> {
try {
if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly();
descendants.stream()
.filter(ProcessHandle::isAlive)
.forEach(ProcessHandle::destroyForcibly);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
process.destroyForcibly();
}
});
}
};
}
private static Thread pump(
InputStream stream, boolean stderr, Consumer<HostServices.BuildOutput> output) {
return Thread.ofVirtual()
.start(
() -> {
try (var reader =
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
for (String line; (line = reader.readLine()) != null; ) {
try {
output.accept(new HostServices.BuildOutput(stderr, line));
} catch (RuntimeException ignored) {
/* A UI callback cannot stop draining the process pipe. */
}
}
} catch (IOException ignored) {
/* Process cancellation may close the pipe. */
}
});
}
}
@@ -0,0 +1,38 @@
package de.neitzel.fxdesigner.host;
import java.util.ArrayDeque;
/** Session-local resource ownership, closed once in reverse acquisition order. */
public final class SessionScope implements AutoCloseable {
private final ArrayDeque<AutoCloseable> resources = new ArrayDeque<>();
private boolean closed;
public synchronized <T extends AutoCloseable> T own(T resource) {
if (closed) {
try {
resource.close();
} catch (Exception e) {
throw new IllegalStateException("Closed scope cleanup failed", e);
}
throw new IllegalStateException("Session closed");
}
resources.push(resource);
return resource;
}
@Override
public synchronized void close() {
if (closed) return;
closed = true;
RuntimeException failure = null;
while (!resources.isEmpty()) {
try {
resources.pop().close();
} catch (Exception e) {
if (failure == null) failure = new IllegalStateException("Session cleanup failed");
failure.addSuppressed(e);
}
}
if (failure != null) throw failure;
}
}
@@ -0,0 +1,94 @@
package de.neitzel.fxdesigner.host;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import org.junit.jupiter.api.Test;
class HostServicesTest {
@Test
void scopesAreIndependentAndCloseInReverseOrder() {
var seen = new ArrayList<Integer>();
var first = new SessionScope();
var other = new SessionScope();
first.own(() -> seen.add(1));
first.own(() -> seen.add(2));
other.own(() -> seen.add(3));
first.close();
first.close();
assertEquals(List.of(2, 1), seen);
other.close();
assertEquals(List.of(2, 1, 3), seen);
}
@Test
void cleanupContinuesAfterFailureAndLateResourcesClose() {
var scope = new SessionScope();
var seen = new ArrayList<Integer>();
scope.own(() -> seen.add(1));
scope.own(
() -> {
throw new Exception("broken");
});
assertThrows(IllegalStateException.class, scope::close);
assertEquals(List.of(1), seen);
assertThrows(IllegalStateException.class, () -> scope.own(() -> seen.add(2)));
assertEquals(List.of(1, 2), seen);
}
@Test
void runtimeRolesNeverFallBack() {
var runtime = new HostServices.RuntimeInstallation(Path.of("/explicit"), 21);
var r =
new ConfiguredRuntimes(
Map.of(new ConfiguredRuntimes.Key("java", HostServices.RuntimeRole.TARGET), runtime));
assertEquals(
runtime, r.resolve("java", HostServices.RuntimeRole.TARGET).toCompletableFuture().join());
assertThrows(
CompletionException.class,
() -> r.resolve("java", HostServices.RuntimeRole.BUILD).toCompletableFuture().join());
}
@Test
void buildCapturesBothStreamsAndCancellation() throws Exception {
String java = Path.of(System.getProperty("java.home"), "bin", "java").toString();
var lines = new CopyOnWriteArrayList<HostServices.BuildOutput>();
var builds = new ProcessBuilds();
var run =
builds.start(
new HostServices.BuildRequest(
Path.of("."),
List.of(java, "-cp", System.getProperty("java.class.path"), Child.class.getName()),
Map.of()),
lines::add);
var done = run.completion().toCompletableFuture().get(10, TimeUnit.SECONDS);
assertEquals(0, done.exitCode());
assertFalse(done.cancelled());
assertTrue(lines.stream().anyMatch(x -> x.stderr() && x.line().equals("error output")));
assertTrue(lines.stream().anyMatch(x -> !x.stderr() && x.line().equals("normal output")));
var pending =
builds.start(
new HostServices.BuildRequest(
Path.of("."),
List.of(
java,
"-cp",
System.getProperty("java.class.path"),
Child.class.getName(),
"wait"),
Map.of()),
x -> {});
pending.cancel();
assertTrue(pending.completion().toCompletableFuture().get(10, TimeUnit.SECONDS).cancelled());
}
public static class Child {
public static void main(String[] args) throws Exception {
if (args.length > 0) Thread.sleep(30000);
System.out.println("normal output");
System.err.println("error output");
}
}
}