implements PLN-0045 play stop end to end flow
This commit is contained in:
parent
17f9a190d5
commit
a8f7830ccb
@ -29,6 +29,8 @@ public final class StudioActivityEventMapper {
|
||||
Optional.of(new StudioActivityEntry("Assets", failed.message(), StudioActivityEntrySeverity.ERROR, true));
|
||||
case StudioPackerOperationEvent packerEvent ->
|
||||
Optional.of(new StudioActivityEntry("Assets", packerEvent.summary(), severity(packerEvent), packerEvent.kind() == p.packer.events.PackerEventKind.ACTION_FAILED));
|
||||
case StudioExecutionLifecycleEvent executionEvent ->
|
||||
Optional.of(new StudioActivityEntry(executionEvent.source(), executionEvent.message(), executionEvent.severity(), executionEvent.sticky()));
|
||||
default -> Optional.empty();
|
||||
};
|
||||
}
|
||||
|
||||
@ -74,6 +74,7 @@ public final class StudioActivityFeedControl extends VBox implements StudioContr
|
||||
subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshedEvent.class, this::onAssetsRefreshFinished));
|
||||
subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshFailedEvent.class, this::onAssetsRefreshFailed));
|
||||
subscriptions.add(eventBus.subscribe(StudioPackerOperationEvent.class, this::onPackerOperation));
|
||||
subscriptions.add(eventBus.subscribe(StudioExecutionLifecycleEvent.class, this::onEvent));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -11,13 +11,27 @@ import javafx.scene.layout.StackPane;
|
||||
import p.studio.Container;
|
||||
import p.studio.controls.lifecycle.StudioControlLifecycle;
|
||||
import p.studio.controls.lifecycle.StudioControlLifecycleSupport;
|
||||
import p.studio.execution.StudioExecutionSessionService;
|
||||
import p.studio.execution.StudioExecutionState;
|
||||
import p.studio.utilities.events.EventSubscription;
|
||||
import p.studio.utilities.i18n.I18n;
|
||||
|
||||
public final class StudioRunSurfaceControl extends HBox implements StudioControlLifecycle {
|
||||
private final BooleanProperty running = new SimpleBooleanProperty(false);
|
||||
private final StudioExecutionSessionService executionSessionService;
|
||||
private final Runnable playAction;
|
||||
private final Runnable stopAction;
|
||||
private final Button toggleButton = new Button();
|
||||
private EventSubscription sessionSubscription;
|
||||
|
||||
public StudioRunSurfaceControl() {
|
||||
public StudioRunSurfaceControl(
|
||||
final StudioExecutionSessionService executionSessionService,
|
||||
final Runnable playAction,
|
||||
final Runnable stopAction) {
|
||||
StudioControlLifecycleSupport.install(this, this);
|
||||
this.executionSessionService = executionSessionService;
|
||||
this.playAction = playAction;
|
||||
this.stopAction = stopAction;
|
||||
getStyleClass().add("studio-run-surface");
|
||||
setSpacing(8);
|
||||
setAlignment(Pos.CENTER_LEFT);
|
||||
@ -38,10 +52,9 @@ public final class StudioRunSurfaceControl extends HBox implements StudioControl
|
||||
final HBox content = new HBox(8, iconShell, text);
|
||||
content.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
final Button toggleButton = new Button();
|
||||
toggleButton.getStyleClass().add("studio-run-toggle-button");
|
||||
toggleButton.graphicProperty().set(content);
|
||||
toggleButton.setOnAction(ignored -> running.set(!running.get()));
|
||||
toggleButton.setOnAction(ignored -> handleToggleRequested());
|
||||
|
||||
running.addListener((ignored, oldValue, newValue) -> {
|
||||
toggleButton.getStyleClass().removeAll("studio-run-toggle-play", "studio-run-toggle-stop");
|
||||
@ -59,4 +72,36 @@ public final class StudioRunSurfaceControl extends HBox implements StudioControl
|
||||
public BooleanProperty runningProperty() {
|
||||
return running;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void subscribe() {
|
||||
if (sessionSubscription != null) {
|
||||
return;
|
||||
}
|
||||
sessionSubscription = executionSessionService.subscribe(snapshot -> running.set(isRunningState(snapshot.state())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unsubscribe() {
|
||||
if (sessionSubscription == null) {
|
||||
return;
|
||||
}
|
||||
sessionSubscription.unsubscribe();
|
||||
sessionSubscription = null;
|
||||
}
|
||||
|
||||
private void handleToggleRequested() {
|
||||
final StudioExecutionState state = executionSessionService.snapshot().state();
|
||||
if (isRunningState(state)) {
|
||||
stopAction.run();
|
||||
return;
|
||||
}
|
||||
playAction.run();
|
||||
}
|
||||
|
||||
private boolean isRunningState(final StudioExecutionState state) {
|
||||
return state == StudioExecutionState.PREPARING
|
||||
|| state == StudioExecutionState.CONNECTING
|
||||
|| state == StudioExecutionState.RUNNING;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
package p.studio.debug.runtime;
|
||||
|
||||
import p.studio.execution.StudioExecutionSessionService;
|
||||
|
||||
public interface StudioRuntimeHandshakeClient {
|
||||
StudioRuntimeHandshakeResult connect(final StudioExecutionSessionService session);
|
||||
|
||||
StudioRuntimeHandshakeResult connect(
|
||||
final StudioExecutionSessionService session,
|
||||
final StudioRuntimeDebugConnectionSettings settings);
|
||||
|
||||
StudioRuntimeHandshakeResult connectWithRetry(
|
||||
final StudioExecutionSessionService session,
|
||||
final StudioRuntimeDebugConnectionSettings settings,
|
||||
final int maxAttempts,
|
||||
final long retryDelayMillis);
|
||||
|
||||
void disconnect(final StudioExecutionSessionService session);
|
||||
}
|
||||
@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
public final class StudioRuntimeHandshakeService {
|
||||
public final class StudioRuntimeHandshakeService implements StudioRuntimeHandshakeClient {
|
||||
private final ObjectMapper mapper;
|
||||
private final StudioBackgroundTasks backgroundTasks;
|
||||
private final Object monitor = new Object();
|
||||
@ -41,15 +41,28 @@ public final class StudioRuntimeHandshakeService {
|
||||
}
|
||||
|
||||
public StudioRuntimeHandshakeResult connect(final StudioExecutionSessionService session) {
|
||||
return connect(session, StudioRuntimeDebugConnectionSettings.defaults());
|
||||
return connectWithRetry(session, StudioRuntimeDebugConnectionSettings.defaults(), 1, 0L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudioRuntimeHandshakeResult connect(
|
||||
final StudioExecutionSessionService session,
|
||||
final StudioRuntimeDebugConnectionSettings settings) {
|
||||
return connectWithRetry(session, settings, 1, 0L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudioRuntimeHandshakeResult connectWithRetry(
|
||||
final StudioExecutionSessionService session,
|
||||
final StudioRuntimeDebugConnectionSettings settings,
|
||||
final int maxAttempts,
|
||||
final long retryDelayMillis) {
|
||||
final StudioExecutionSessionService safeSession = Objects.requireNonNull(session, "session");
|
||||
final StudioRuntimeDebugConnectionSettings safeSettings = Objects.requireNonNull(settings, "settings");
|
||||
safeSession.transitionToConnecting();
|
||||
final int attempts = Math.max(1, maxAttempts);
|
||||
Exception failure = null;
|
||||
for (int attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
synchronized (monitor) {
|
||||
cleanupLocked();
|
||||
@ -82,15 +95,21 @@ public final class StudioRuntimeHandshakeService {
|
||||
safeSession.transitionToRunning();
|
||||
return StudioRuntimeHandshakeResult.success(safeSettings, handshake.protocolVersion(), handshake.runtimeVersion());
|
||||
} catch (Exception exception) {
|
||||
failure = exception;
|
||||
cleanup();
|
||||
if (attempt < attempts && retryDelayMillis > 0L) {
|
||||
sleepQuietly(retryDelayMillis);
|
||||
}
|
||||
}
|
||||
}
|
||||
safeSession.appendRuntimeLog(
|
||||
StudioExecutionLogSeverity.ERROR,
|
||||
"Runtime handshake failed: " + failureMessage(exception));
|
||||
"Runtime handshake failed: " + failureMessage(failure == null ? new IOException("unknown handshake failure") : failure));
|
||||
safeSession.transitionToRuntimeFailed();
|
||||
return StudioRuntimeHandshakeResult.failure(safeSettings, failureMessage(exception));
|
||||
}
|
||||
return StudioRuntimeHandshakeResult.failure(safeSettings, failureMessage(failure == null ? new IOException("unknown handshake failure") : failure));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect(final StudioExecutionSessionService session) {
|
||||
final StudioExecutionSessionService safeSession = Objects.requireNonNull(session, "session");
|
||||
final boolean wasConnected;
|
||||
@ -238,4 +257,12 @@ public final class StudioRuntimeHandshakeService {
|
||||
? exception.getClass().getSimpleName()
|
||||
: message;
|
||||
}
|
||||
|
||||
private void sleepQuietly(final long retryDelayMillis) {
|
||||
try {
|
||||
Thread.sleep(retryDelayMillis);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,238 @@
|
||||
package p.studio.execution;
|
||||
|
||||
import p.studio.Container;
|
||||
import p.studio.StudioBackgroundTasks;
|
||||
import p.studio.controls.shell.StudioActivityEntrySeverity;
|
||||
import p.studio.debug.runtime.StudioRuntimeDebugConnectionSettings;
|
||||
import p.studio.debug.runtime.StudioRuntimeHandshakeClient;
|
||||
import p.studio.debug.runtime.StudioRuntimeHandshakeResult;
|
||||
import p.studio.execution.runtime.StudioExternalRuntimeProcessLauncher;
|
||||
import p.studio.execution.runtime.StudioRuntimeProcessHandle;
|
||||
import p.studio.execution.runtime.StudioRuntimeProcessLauncher;
|
||||
import p.studio.lsp.events.StudioExecutionLifecycleEvent;
|
||||
import p.studio.projectstate.ProjectLocalStudioSetup;
|
||||
import p.studio.projects.ProjectReference;
|
||||
import p.studio.shipper.StudioShipperService;
|
||||
import p.studio.workspaces.WorkspaceId;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class StudioPlayStopCoordinator {
|
||||
private final Object monitor = new Object();
|
||||
private final ProjectReference projectReference;
|
||||
private final ProjectLocalStudioSetup projectSetup;
|
||||
private final StudioExecutionSessionService executionSession;
|
||||
private final StudioShipperPreparationRunner shipperRunner;
|
||||
private final StudioRuntimeHandshakeClient handshakeClient;
|
||||
private final StudioRuntimeProcessLauncher runtimeProcessLauncher;
|
||||
private final StudioBackgroundTasks backgroundTasks;
|
||||
private final Consumer<WorkspaceId> workspaceSelector;
|
||||
private final Consumer<StudioExecutionLifecycleEvent> lifecyclePublisher;
|
||||
private StudioRuntimeProcessHandle activeProcess;
|
||||
private boolean stopRequested;
|
||||
|
||||
public StudioPlayStopCoordinator(
|
||||
final ProjectReference projectReference,
|
||||
final ProjectLocalStudioSetup projectSetup,
|
||||
final StudioExecutionSessionService executionSession,
|
||||
final Consumer<WorkspaceId> workspaceSelector) {
|
||||
this(
|
||||
projectReference,
|
||||
projectSetup,
|
||||
executionSession,
|
||||
new StudioShipperService()::prepare,
|
||||
new p.studio.debug.runtime.StudioRuntimeHandshakeService(),
|
||||
new StudioExternalRuntimeProcessLauncher(),
|
||||
Container.backgroundTasks(),
|
||||
workspaceSelector,
|
||||
event -> Container.eventBus().publish(event));
|
||||
}
|
||||
|
||||
StudioPlayStopCoordinator(
|
||||
final ProjectReference projectReference,
|
||||
final ProjectLocalStudioSetup projectSetup,
|
||||
final StudioExecutionSessionService executionSession,
|
||||
final StudioShipperPreparationRunner shipperRunner,
|
||||
final StudioRuntimeHandshakeClient handshakeClient,
|
||||
final StudioRuntimeProcessLauncher runtimeProcessLauncher,
|
||||
final StudioBackgroundTasks backgroundTasks,
|
||||
final Consumer<WorkspaceId> workspaceSelector,
|
||||
final Consumer<StudioExecutionLifecycleEvent> lifecyclePublisher) {
|
||||
this.projectReference = Objects.requireNonNull(projectReference, "projectReference");
|
||||
this.projectSetup = Objects.requireNonNull(projectSetup, "projectSetup");
|
||||
this.executionSession = Objects.requireNonNull(executionSession, "executionSession");
|
||||
this.shipperRunner = Objects.requireNonNull(shipperRunner, "shipperRunner");
|
||||
this.handshakeClient = Objects.requireNonNull(handshakeClient, "handshakeClient");
|
||||
this.runtimeProcessLauncher = Objects.requireNonNull(runtimeProcessLauncher, "runtimeProcessLauncher");
|
||||
this.backgroundTasks = Objects.requireNonNull(backgroundTasks, "backgroundTasks");
|
||||
this.workspaceSelector = Objects.requireNonNull(workspaceSelector, "workspaceSelector");
|
||||
this.lifecyclePublisher = Objects.requireNonNull(lifecyclePublisher, "lifecyclePublisher");
|
||||
}
|
||||
|
||||
public void play() {
|
||||
synchronized (monitor) {
|
||||
if (isBusyState(executionSession.snapshot().state())) {
|
||||
return;
|
||||
}
|
||||
stopRequested = false;
|
||||
}
|
||||
workspaceSelector.accept(WorkspaceId.DEBUG);
|
||||
executionSession.beginPreparationCycle();
|
||||
publishLifecycle("Play", "Play started", StudioActivityEntrySeverity.INFO, false);
|
||||
backgroundTasks.submit(this::runPlayFlow);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
final StudioExecutionState state = executionSession.snapshot().state();
|
||||
if (state == StudioExecutionState.PREPARING) {
|
||||
return;
|
||||
}
|
||||
final StudioRuntimeProcessHandle processToStop;
|
||||
synchronized (monitor) {
|
||||
stopRequested = true;
|
||||
processToStop = activeProcess;
|
||||
activeProcess = null;
|
||||
}
|
||||
handshakeClient.disconnect(executionSession);
|
||||
if (processToStop != null && processToStop.isAlive()) {
|
||||
processToStop.destroyForcibly();
|
||||
}
|
||||
publishLifecycle("Play", "Runtime stop requested", StudioActivityEntrySeverity.INFO, false);
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
stop();
|
||||
}
|
||||
|
||||
private void runPlayFlow() {
|
||||
final var prepareResult = shipperRunner.prepare(projectReference, executionSession::appendShipperLog);
|
||||
if (!prepareResult.success()) {
|
||||
executionSession.transitionToPrepareFailed();
|
||||
publishLifecycle("Play", "Preparation failed", StudioActivityEntrySeverity.ERROR, true);
|
||||
return;
|
||||
}
|
||||
|
||||
executionSession.transitionToConnecting();
|
||||
final String runtimePath = validateRuntimePath();
|
||||
if (runtimePath == null) {
|
||||
executionSession.transitionToRuntimeFailed();
|
||||
publishLifecycle("Play", "Runtime preflight failed", StudioActivityEntrySeverity.ERROR, true);
|
||||
return;
|
||||
}
|
||||
|
||||
final StudioRuntimeProcessHandle processHandle;
|
||||
try {
|
||||
processHandle = runtimeProcessLauncher.launch(projectReference, runtimePath);
|
||||
} catch (IOException ioException) {
|
||||
executionSession.appendRuntimeLog(StudioExecutionLogSeverity.ERROR, "Runtime preflight failed: " + ioException.getMessage());
|
||||
executionSession.transitionToRuntimeFailed();
|
||||
publishLifecycle("Play", "Runtime spawn failed", StudioActivityEntrySeverity.ERROR, true);
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (monitor) {
|
||||
activeProcess = processHandle;
|
||||
stopRequested = false;
|
||||
}
|
||||
streamProcessOutput(processHandle.stdout(), StudioExecutionLogSeverity.INFO);
|
||||
streamProcessOutput(processHandle.stderr(), StudioExecutionLogSeverity.ERROR);
|
||||
processHandle.onExit().thenAccept(exitCode -> handleProcessExit(processHandle, exitCode));
|
||||
|
||||
final StudioRuntimeHandshakeResult handshake = handshakeClient.connectWithRetry(
|
||||
executionSession,
|
||||
StudioRuntimeDebugConnectionSettings.defaults(),
|
||||
20,
|
||||
250L);
|
||||
if (!handshake.success()) {
|
||||
processHandle.destroyForcibly();
|
||||
synchronized (monitor) {
|
||||
if (activeProcess == processHandle) {
|
||||
activeProcess = null;
|
||||
}
|
||||
}
|
||||
publishLifecycle("Debug", "Runtime handshake failed", StudioActivityEntrySeverity.ERROR, true);
|
||||
return;
|
||||
}
|
||||
publishLifecycle("Debug", "Runtime started", StudioActivityEntrySeverity.SUCCESS, false);
|
||||
}
|
||||
|
||||
private void handleProcessExit(
|
||||
final StudioRuntimeProcessHandle processHandle,
|
||||
final int exitCode) {
|
||||
final boolean wasStopRequested;
|
||||
synchronized (monitor) {
|
||||
if (activeProcess == processHandle) {
|
||||
activeProcess = null;
|
||||
}
|
||||
wasStopRequested = stopRequested;
|
||||
}
|
||||
if (wasStopRequested || executionSession.snapshot().state() == StudioExecutionState.STOPPED) {
|
||||
executionSession.appendRuntimeLog(StudioExecutionLogSeverity.INFO, "Runtime process stopped.");
|
||||
publishLifecycle("Debug", "Runtime stopped", StudioActivityEntrySeverity.INFO, false);
|
||||
return;
|
||||
}
|
||||
if (exitCode == 0) {
|
||||
executionSession.appendRuntimeLog(StudioExecutionLogSeverity.INFO, "Runtime process exited successfully.");
|
||||
executionSession.transitionToStopped();
|
||||
publishLifecycle("Debug", "Runtime finished", StudioActivityEntrySeverity.SUCCESS, false);
|
||||
return;
|
||||
}
|
||||
executionSession.appendRuntimeLog(StudioExecutionLogSeverity.ERROR, "Runtime process exited with code " + exitCode + ".");
|
||||
executionSession.transitionToRuntimeFailed();
|
||||
publishLifecycle("Debug", "Runtime failed", StudioActivityEntrySeverity.ERROR, true);
|
||||
}
|
||||
|
||||
private void streamProcessOutput(
|
||||
final InputStream stream,
|
||||
final StudioExecutionLogSeverity severity) {
|
||||
backgroundTasks.submit(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
executionSession.appendRuntimeLog(severity, line);
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String validateRuntimePath() {
|
||||
final String runtimePathValue = projectSetup.prometeuRuntimePath();
|
||||
if (runtimePathValue == null) {
|
||||
executionSession.appendRuntimeLog(StudioExecutionLogSeverity.ERROR, "Runtime preflight failed: prometeuRuntimePath is missing.");
|
||||
return null;
|
||||
}
|
||||
final Path runtimePath = Path.of(runtimePathValue).toAbsolutePath().normalize();
|
||||
if (!Files.isRegularFile(runtimePath)) {
|
||||
executionSession.appendRuntimeLog(StudioExecutionLogSeverity.ERROR, "Runtime preflight failed: runtime not found at " + runtimePath + ".");
|
||||
return null;
|
||||
}
|
||||
if (!Files.isExecutable(runtimePath)) {
|
||||
executionSession.appendRuntimeLog(StudioExecutionLogSeverity.ERROR, "Runtime preflight failed: runtime is not executable at " + runtimePath + ".");
|
||||
return null;
|
||||
}
|
||||
return runtimePath.toString();
|
||||
}
|
||||
|
||||
private void publishLifecycle(
|
||||
final String source,
|
||||
final String message,
|
||||
final StudioActivityEntrySeverity severity,
|
||||
final boolean sticky) {
|
||||
lifecyclePublisher.accept(new StudioExecutionLifecycleEvent(source, message, severity, sticky));
|
||||
}
|
||||
|
||||
private boolean isBusyState(final StudioExecutionState state) {
|
||||
return state == StudioExecutionState.PREPARING
|
||||
|| state == StudioExecutionState.CONNECTING
|
||||
|| state == StudioExecutionState.RUNNING;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package p.studio.execution;
|
||||
|
||||
import p.studio.projects.ProjectReference;
|
||||
import p.studio.shipper.StudioShipperLogEntry;
|
||||
import p.studio.shipper.StudioShipperPrepareResult;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface StudioShipperPreparationRunner {
|
||||
StudioShipperPrepareResult prepare(ProjectReference projectReference, Consumer<StudioShipperLogEntry> logSink);
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package p.studio.execution.runtime;
|
||||
|
||||
import p.studio.projects.ProjectReference;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public final class StudioExternalRuntimeProcessLauncher implements StudioRuntimeProcessLauncher {
|
||||
@Override
|
||||
public StudioRuntimeProcessHandle launch(
|
||||
final ProjectReference projectReference,
|
||||
final String runtimePath) throws IOException {
|
||||
final Process process = new ProcessBuilder(runtimePath, "run", "build")
|
||||
.directory(projectReference.rootPath().toFile())
|
||||
.start();
|
||||
return new ExternalProcessHandle(process);
|
||||
}
|
||||
|
||||
private record ExternalProcessHandle(Process process) implements StudioRuntimeProcessHandle {
|
||||
@Override
|
||||
public InputStream stdout() {
|
||||
return process.getInputStream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream stderr() {
|
||||
return process.getErrorStream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAlive() {
|
||||
return process.isAlive();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroyForcibly() {
|
||||
process.descendants().forEach(ProcessHandle::destroyForcibly);
|
||||
process.destroyForcibly();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Integer> onExit() {
|
||||
return process.onExit().thenApply(Process::exitValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package p.studio.execution.runtime;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public interface StudioRuntimeProcessHandle {
|
||||
InputStream stdout();
|
||||
|
||||
InputStream stderr();
|
||||
|
||||
boolean isAlive();
|
||||
|
||||
void destroyForcibly();
|
||||
|
||||
CompletableFuture<Integer> onExit();
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package p.studio.execution.runtime;
|
||||
|
||||
import p.studio.projects.ProjectReference;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public interface StudioRuntimeProcessLauncher {
|
||||
StudioRuntimeProcessHandle launch(ProjectReference projectReference, String runtimePath) throws IOException;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package p.studio.lsp.events;
|
||||
|
||||
import p.studio.controls.shell.StudioActivityEntrySeverity;
|
||||
|
||||
public record StudioExecutionLifecycleEvent(
|
||||
String source,
|
||||
String message,
|
||||
StudioActivityEntrySeverity severity,
|
||||
boolean sticky) implements StudioEvent {
|
||||
}
|
||||
@ -3,6 +3,7 @@ package p.studio.window;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import p.studio.Container;
|
||||
import p.studio.controls.shell.*;
|
||||
import p.studio.execution.StudioPlayStopCoordinator;
|
||||
import p.studio.projectstate.ProjectLocalStudioSetup;
|
||||
import p.studio.lsp.events.StudioWorkspaceSelectedEvent;
|
||||
import p.studio.projectstate.ProjectLocalStudioState;
|
||||
@ -25,6 +26,7 @@ public final class MainView extends BorderPane {
|
||||
private final AssetWorkspace assetWorkspace;
|
||||
private final EditorWorkspace editorWorkspace;
|
||||
private final StudioWorkspaceRailControl<WorkspaceId> workspaceRail;
|
||||
private final StudioPlayStopCoordinator playStopCoordinator;
|
||||
private boolean initializing;
|
||||
private boolean workspaceLifecycleInitialized;
|
||||
|
||||
@ -35,7 +37,15 @@ public final class MainView extends BorderPane {
|
||||
final ProjectLocalStudioState persistedState = projectSession.projectLocalStudioState();
|
||||
final ProjectLocalStudioSetup projectSetup = projectSession.projectLocalStudioSetup();
|
||||
final var menuBar = new StudioShellMenuBarControl();
|
||||
final var runSurface = new StudioRunSurfaceControl();
|
||||
playStopCoordinator = new StudioPlayStopCoordinator(
|
||||
projectReference,
|
||||
projectSetup,
|
||||
projectSession.executionSession(),
|
||||
this::selectWorkspace);
|
||||
final var runSurface = new StudioRunSurfaceControl(
|
||||
projectSession.executionSession(),
|
||||
playStopCoordinator::play,
|
||||
playStopCoordinator::stop);
|
||||
setTop(new StudioShellTopBarControl(menuBar));
|
||||
|
||||
assetWorkspace = new AssetWorkspace(projectReference);
|
||||
@ -103,6 +113,7 @@ public final class MainView extends BorderPane {
|
||||
}
|
||||
|
||||
public void close() {
|
||||
playStopCoordinator.shutdown();
|
||||
persistProjectLocalState();
|
||||
host.deactivateCurrentWorkspace();
|
||||
}
|
||||
@ -116,6 +127,11 @@ public final class MainView extends BorderPane {
|
||||
Container.eventBus().publish(new StudioWorkspaceSelectedEvent(workspaceId));
|
||||
}
|
||||
|
||||
private void selectWorkspace(final WorkspaceId workspaceId) {
|
||||
workspaceRail.select(workspaceId);
|
||||
loadWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
private WorkspaceId restoreInitialWorkspace(final ProjectLocalStudioState.OpenShellState openShellState) {
|
||||
if (openShellState.selectedWorkspaceId() == null) {
|
||||
return WorkspaceId.ASSETS;
|
||||
|
||||
@ -2,6 +2,7 @@ package p.studio.controls.shell;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import p.packer.events.PackerEventKind;
|
||||
import p.studio.lsp.events.StudioExecutionLifecycleEvent;
|
||||
import p.studio.lsp.events.StudioPackerOperationEvent;
|
||||
import p.studio.lsp.events.StudioProjectOpenedEvent;
|
||||
import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshFailedEvent;
|
||||
@ -54,4 +55,15 @@ final class StudioActivityEventMapperTest {
|
||||
assertEquals(StudioActivityEntrySeverity.SUCCESS, entry.severity());
|
||||
assertEquals("Asset created.", entry.message());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsExecutionLifecycleToActivityEntry() {
|
||||
final StudioActivityEntry entry = StudioActivityEventMapper
|
||||
.map(new StudioExecutionLifecycleEvent("Debug", "Runtime started", StudioActivityEntrySeverity.SUCCESS, false))
|
||||
.orElseThrow();
|
||||
|
||||
assertEquals("Debug", entry.source());
|
||||
assertEquals(StudioActivityEntrySeverity.SUCCESS, entry.severity());
|
||||
assertEquals("Runtime started", entry.message());
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,252 @@
|
||||
package p.studio.execution;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import p.studio.StudioBackgroundTasks;
|
||||
import p.studio.controls.shell.StudioActivityEntrySeverity;
|
||||
import p.studio.debug.runtime.StudioRuntimeDebugConnectionSettings;
|
||||
import p.studio.debug.runtime.StudioRuntimeHandshakeClient;
|
||||
import p.studio.debug.runtime.StudioRuntimeHandshakeResult;
|
||||
import p.studio.execution.runtime.StudioRuntimeProcessHandle;
|
||||
import p.studio.execution.runtime.StudioRuntimeProcessLauncher;
|
||||
import p.studio.projectstate.ProjectLocalStudioSetup;
|
||||
import p.studio.projects.ProjectReference;
|
||||
import p.studio.shipper.StudioShipperPrepareResult;
|
||||
import p.studio.shipper.StudioShipperPreparationStatus;
|
||||
import p.studio.workspaces.WorkspaceId;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class StudioPlayStopCoordinatorTest {
|
||||
@Test
|
||||
void playNavigatesToDebugAndStopsOnPreparationFailure() throws Exception {
|
||||
final StudioExecutionSessionService session = new StudioExecutionSessionService();
|
||||
final AtomicReference<WorkspaceId> selectedWorkspace = new AtomicReference<>();
|
||||
final AtomicInteger launcherCalls = new AtomicInteger();
|
||||
final StudioBackgroundTasks backgroundTasks = new StudioBackgroundTasks(Executors.newCachedThreadPool());
|
||||
try {
|
||||
final StudioPlayStopCoordinator coordinator = new StudioPlayStopCoordinator(
|
||||
projectReference(),
|
||||
new ProjectLocalStudioSetup("/bin/echo", ProjectLocalStudioSetup.EditorIndentationSetup.defaults()),
|
||||
session,
|
||||
(project, sink) -> new StudioShipperPrepareResult(StudioShipperPreparationStatus.FAILED, List.of(), null, null, null, null),
|
||||
new RecordingHandshakeClient(),
|
||||
(projectReference, runtimePath) -> {
|
||||
launcherCalls.incrementAndGet();
|
||||
return new FakeRuntimeProcessHandle();
|
||||
},
|
||||
backgroundTasks,
|
||||
selectedWorkspace::set,
|
||||
ignored -> { });
|
||||
|
||||
coordinator.play();
|
||||
|
||||
assertEquals(WorkspaceId.DEBUG, selectedWorkspace.get());
|
||||
assertTrue(waitForState(session, StudioExecutionState.PREPARE_FAILED));
|
||||
assertEquals(0, launcherCalls.get());
|
||||
} finally {
|
||||
backgroundTasks.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedPlayIsIgnoredWhileRuntimeIsActiveAndStopKillsProcess() throws Exception {
|
||||
final StudioExecutionSessionService session = new StudioExecutionSessionService();
|
||||
final ArrayList<WorkspaceId> selected = new ArrayList<>();
|
||||
final AtomicInteger launches = new AtomicInteger();
|
||||
final RecordingHandshakeClient handshakeClient = new RecordingHandshakeClient();
|
||||
final FakeRuntimeProcessHandle processHandle = new FakeRuntimeProcessHandle();
|
||||
final StudioBackgroundTasks backgroundTasks = new StudioBackgroundTasks(Executors.newCachedThreadPool());
|
||||
try {
|
||||
final StudioPlayStopCoordinator coordinator = new StudioPlayStopCoordinator(
|
||||
projectReference(),
|
||||
new ProjectLocalStudioSetup("/bin/echo", ProjectLocalStudioSetup.EditorIndentationSetup.defaults()),
|
||||
session,
|
||||
(project, sink) -> new StudioShipperPrepareResult(StudioShipperPreparationStatus.SUCCESS, List.of(), null, null, null, null),
|
||||
handshakeClient,
|
||||
(projectReference, runtimePath) -> {
|
||||
launches.incrementAndGet();
|
||||
return processHandle;
|
||||
},
|
||||
backgroundTasks,
|
||||
selected::add,
|
||||
ignored -> { });
|
||||
|
||||
coordinator.play();
|
||||
assertTrue(waitForState(session, StudioExecutionState.RUNNING));
|
||||
|
||||
coordinator.play();
|
||||
assertEquals(1, launches.get());
|
||||
|
||||
coordinator.stop();
|
||||
assertTrue(waitForState(session, StudioExecutionState.STOPPED));
|
||||
assertTrue(processHandle.destroyed);
|
||||
assertEquals(1, handshakeClient.disconnectCalls.get());
|
||||
assertFalse(selected.isEmpty());
|
||||
} finally {
|
||||
backgroundTasks.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopDuringPreparingIsANoOp() throws Exception {
|
||||
final StudioExecutionSessionService session = new StudioExecutionSessionService();
|
||||
final CountDownLatch started = new CountDownLatch(1);
|
||||
final CountDownLatch release = new CountDownLatch(1);
|
||||
final StudioBackgroundTasks backgroundTasks = new StudioBackgroundTasks(Executors.newCachedThreadPool());
|
||||
try {
|
||||
final StudioPlayStopCoordinator coordinator = new StudioPlayStopCoordinator(
|
||||
projectReference(),
|
||||
new ProjectLocalStudioSetup("/bin/echo", ProjectLocalStudioSetup.EditorIndentationSetup.defaults()),
|
||||
session,
|
||||
(project, sink) -> {
|
||||
started.countDown();
|
||||
await(release);
|
||||
return new StudioShipperPrepareResult(StudioShipperPreparationStatus.FAILED, List.of(), null, null, null, null);
|
||||
},
|
||||
new RecordingHandshakeClient(),
|
||||
(projectReference, runtimePath) -> new FakeRuntimeProcessHandle(),
|
||||
backgroundTasks,
|
||||
ignored -> { },
|
||||
ignored -> { });
|
||||
|
||||
coordinator.play();
|
||||
assertTrue(started.await(2, TimeUnit.SECONDS));
|
||||
assertEquals(StudioExecutionState.PREPARING, session.snapshot().state());
|
||||
|
||||
coordinator.stop();
|
||||
assertEquals(StudioExecutionState.PREPARING, session.snapshot().state());
|
||||
|
||||
release.countDown();
|
||||
assertTrue(waitForState(session, StudioExecutionState.PREPARE_FAILED));
|
||||
} finally {
|
||||
backgroundTasks.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void spawnFailureBecomesRuntimeFailed() throws Exception {
|
||||
final StudioExecutionSessionService session = new StudioExecutionSessionService();
|
||||
final StudioBackgroundTasks backgroundTasks = new StudioBackgroundTasks(Executors.newCachedThreadPool());
|
||||
try {
|
||||
final StudioPlayStopCoordinator coordinator = new StudioPlayStopCoordinator(
|
||||
projectReference(),
|
||||
new ProjectLocalStudioSetup("/definitely/missing/runtime", ProjectLocalStudioSetup.EditorIndentationSetup.defaults()),
|
||||
session,
|
||||
(project, sink) -> new StudioShipperPrepareResult(StudioShipperPreparationStatus.SUCCESS, List.of(), null, null, null, null),
|
||||
new RecordingHandshakeClient(),
|
||||
(projectReference, runtimePath) -> new FakeRuntimeProcessHandle(),
|
||||
backgroundTasks,
|
||||
ignored -> { },
|
||||
ignored -> { });
|
||||
|
||||
coordinator.play();
|
||||
|
||||
assertTrue(waitForState(session, StudioExecutionState.RUNTIME_FAILED));
|
||||
assertTrue(session.snapshot().logs().stream().anyMatch(entry -> entry.message().contains("Runtime preflight failed")));
|
||||
} finally {
|
||||
backgroundTasks.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean waitForState(
|
||||
final StudioExecutionSessionService session,
|
||||
final StudioExecutionState expected) throws InterruptedException {
|
||||
for (int attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (session.snapshot().state() == expected) {
|
||||
return true;
|
||||
}
|
||||
Thread.sleep(50L);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void await(final CountDownLatch latch) {
|
||||
try {
|
||||
latch.await(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectReference projectReference() {
|
||||
return new ProjectReference("Main", "1.0.0", "pbs", 1, Path.of("/tmp/prometeu-play-stop"));
|
||||
}
|
||||
|
||||
private static final class RecordingHandshakeClient implements StudioRuntimeHandshakeClient {
|
||||
private final AtomicInteger disconnectCalls = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public StudioRuntimeHandshakeResult connect(final StudioExecutionSessionService session) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudioRuntimeHandshakeResult connect(
|
||||
final StudioExecutionSessionService session,
|
||||
final StudioRuntimeDebugConnectionSettings settings) {
|
||||
return connectWithRetry(session, settings, 1, 0L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudioRuntimeHandshakeResult connectWithRetry(
|
||||
final StudioExecutionSessionService session,
|
||||
final StudioRuntimeDebugConnectionSettings settings,
|
||||
final int maxAttempts,
|
||||
final long retryDelayMillis) {
|
||||
session.transitionToRunning();
|
||||
session.appendRuntimeLog(StudioExecutionLogSeverity.INFO, "Fake runtime handshake connected.");
|
||||
return StudioRuntimeHandshakeResult.success(settings, 1, "test");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect(final StudioExecutionSessionService session) {
|
||||
disconnectCalls.incrementAndGet();
|
||||
if (session.snapshot().state() == StudioExecutionState.CONNECTING || session.snapshot().state() == StudioExecutionState.RUNNING) {
|
||||
session.transitionToStopped();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeRuntimeProcessHandle implements StudioRuntimeProcessHandle {
|
||||
private final CompletableFuture<Integer> exit = new CompletableFuture<>();
|
||||
private boolean destroyed;
|
||||
|
||||
@Override
|
||||
public InputStream stdout() {
|
||||
return new ByteArrayInputStream(new byte[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream stderr() {
|
||||
return new ByteArrayInputStream(new byte[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAlive() {
|
||||
return !destroyed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroyForcibly() {
|
||||
destroyed = true;
|
||||
exit.complete(137);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Integer> onExit() {
|
||||
return exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user