diff --git a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java index 312ad3a5..7fc2630c 100644 --- a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java +++ b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java @@ -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(); }; } diff --git a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java index cf57e8c1..dd927770 100644 --- a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java +++ b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java @@ -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 diff --git a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioRunSurfaceControl.java b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioRunSurfaceControl.java index 7aa073ca..7d70ed84 100644 --- a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioRunSurfaceControl.java +++ b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioRunSurfaceControl.java @@ -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; + } } diff --git a/prometeu-studio/src/main/java/p/studio/debug/runtime/StudioRuntimeHandshakeClient.java b/prometeu-studio/src/main/java/p/studio/debug/runtime/StudioRuntimeHandshakeClient.java new file mode 100644 index 00000000..5491392f --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/debug/runtime/StudioRuntimeHandshakeClient.java @@ -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); +} diff --git a/prometeu-studio/src/main/java/p/studio/debug/runtime/StudioRuntimeHandshakeService.java b/prometeu-studio/src/main/java/p/studio/debug/runtime/StudioRuntimeHandshakeService.java index 175e5eda..978db5d0 100644 --- a/prometeu-studio/src/main/java/p/studio/debug/runtime/StudioRuntimeHandshakeService.java +++ b/prometeu-studio/src/main/java/p/studio/debug/runtime/StudioRuntimeHandshakeService.java @@ -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,56 +41,75 @@ 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(); - try { - synchronized (monitor) { - cleanupLocked(); - intentionalDisconnect = false; - socket = new Socket(); - socket.connect(new InetSocketAddress(safeSettings.host(), safeSettings.port()), 5_000); - socket.setSoTimeout(5_000); - reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); - writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); - } - - final String handshakeLine = readHandshakeLine(); - final StudioRuntimeHandshakeRuntimeToClient handshake = mapper.readValue( - handshakeLine, - StudioRuntimeHandshakeRuntimeToClient.class); - writeMessage(mapper.writeValueAsString(new StudioRuntimeHandshakeClientToRuntime("start"))); - synchronized (monitor) { - if (socket != null) { - socket.setSoTimeout(0); + final int attempts = Math.max(1, maxAttempts); + Exception failure = null; + for (int attempt = 1; attempt <= attempts; attempt += 1) { + try { + synchronized (monitor) { + cleanupLocked(); + intentionalDisconnect = false; + socket = new Socket(); + socket.connect(new InetSocketAddress(safeSettings.host(), safeSettings.port()), 5_000); + socket.setSoTimeout(5_000); + reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); } - receiverTask = backgroundTasks.submit(() -> receiveLoop(safeSession)); - } - safeSession.appendRuntimeLog( - StudioExecutionLogSeverity.INFO, - "Runtime handshake connected to %s:%d".formatted(safeSettings.host(), safeSettings.port())); - safeSession.appendRuntimeLog( - StudioExecutionLogSeverity.INFO, - "Runtime protocol %d (%s)".formatted(handshake.protocolVersion(), handshake.runtimeVersion())); - safeSession.transitionToRunning(); - return StudioRuntimeHandshakeResult.success(safeSettings, handshake.protocolVersion(), handshake.runtimeVersion()); - } catch (Exception exception) { - cleanup(); - safeSession.appendRuntimeLog( - StudioExecutionLogSeverity.ERROR, - "Runtime handshake failed: " + failureMessage(exception)); - safeSession.transitionToRuntimeFailed(); - return StudioRuntimeHandshakeResult.failure(safeSettings, failureMessage(exception)); + final String handshakeLine = readHandshakeLine(); + final StudioRuntimeHandshakeRuntimeToClient handshake = mapper.readValue( + handshakeLine, + StudioRuntimeHandshakeRuntimeToClient.class); + writeMessage(mapper.writeValueAsString(new StudioRuntimeHandshakeClientToRuntime("start"))); + synchronized (monitor) { + if (socket != null) { + socket.setSoTimeout(0); + } + receiverTask = backgroundTasks.submit(() -> receiveLoop(safeSession)); + } + + safeSession.appendRuntimeLog( + StudioExecutionLogSeverity.INFO, + "Runtime handshake connected to %s:%d".formatted(safeSettings.host(), safeSettings.port())); + safeSession.appendRuntimeLog( + StudioExecutionLogSeverity.INFO, + "Runtime protocol %d (%s)".formatted(handshake.protocolVersion(), handshake.runtimeVersion())); + 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(failure == null ? new IOException("unknown handshake failure") : failure)); + safeSession.transitionToRuntimeFailed(); + 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(); + } + } } diff --git a/prometeu-studio/src/main/java/p/studio/execution/StudioPlayStopCoordinator.java b/prometeu-studio/src/main/java/p/studio/execution/StudioPlayStopCoordinator.java new file mode 100644 index 00000000..762d7a1c --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/StudioPlayStopCoordinator.java @@ -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 workspaceSelector; + private final Consumer lifecyclePublisher; + private StudioRuntimeProcessHandle activeProcess; + private boolean stopRequested; + + public StudioPlayStopCoordinator( + final ProjectReference projectReference, + final ProjectLocalStudioSetup projectSetup, + final StudioExecutionSessionService executionSession, + final Consumer 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 workspaceSelector, + final Consumer 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; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/execution/StudioShipperPreparationRunner.java b/prometeu-studio/src/main/java/p/studio/execution/StudioShipperPreparationRunner.java new file mode 100644 index 00000000..a3664fcb --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/StudioShipperPreparationRunner.java @@ -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 logSink); +} diff --git a/prometeu-studio/src/main/java/p/studio/execution/runtime/StudioExternalRuntimeProcessLauncher.java b/prometeu-studio/src/main/java/p/studio/execution/runtime/StudioExternalRuntimeProcessLauncher.java new file mode 100644 index 00000000..d1a6ced1 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/runtime/StudioExternalRuntimeProcessLauncher.java @@ -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 onExit() { + return process.onExit().thenApply(Process::exitValue); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/execution/runtime/StudioRuntimeProcessHandle.java b/prometeu-studio/src/main/java/p/studio/execution/runtime/StudioRuntimeProcessHandle.java new file mode 100644 index 00000000..f6bd09ea --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/runtime/StudioRuntimeProcessHandle.java @@ -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 onExit(); +} diff --git a/prometeu-studio/src/main/java/p/studio/execution/runtime/StudioRuntimeProcessLauncher.java b/prometeu-studio/src/main/java/p/studio/execution/runtime/StudioRuntimeProcessLauncher.java new file mode 100644 index 00000000..eba18efb --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/runtime/StudioRuntimeProcessLauncher.java @@ -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; +} diff --git a/prometeu-studio/src/main/java/p/studio/lsp/events/StudioExecutionLifecycleEvent.java b/prometeu-studio/src/main/java/p/studio/lsp/events/StudioExecutionLifecycleEvent.java new file mode 100644 index 00000000..a89b516f --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/lsp/events/StudioExecutionLifecycleEvent.java @@ -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 { +} diff --git a/prometeu-studio/src/main/java/p/studio/window/MainView.java b/prometeu-studio/src/main/java/p/studio/window/MainView.java index 1b0fd59e..5a1a89d8 100644 --- a/prometeu-studio/src/main/java/p/studio/window/MainView.java +++ b/prometeu-studio/src/main/java/p/studio/window/MainView.java @@ -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 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; diff --git a/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java b/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java index 998a8240..0fdbd6a0 100644 --- a/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java +++ b/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java @@ -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()); + } } diff --git a/prometeu-studio/src/test/java/p/studio/execution/StudioPlayStopCoordinatorTest.java b/prometeu-studio/src/test/java/p/studio/execution/StudioPlayStopCoordinatorTest.java new file mode 100644 index 00000000..1eb4e70a --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/execution/StudioPlayStopCoordinatorTest.java @@ -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 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 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 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 onExit() { + return exit; + } + } +}