Establish buildable modules and shared host-service boundaries
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user