implements PLN-0045 play stop end to end flow

This commit is contained in:
bQUARKz 2026-04-06 06:48:12 +01:00
parent 17f9a190d5
commit a8f7830ccb
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
14 changed files with 748 additions and 42 deletions

View File

@ -29,6 +29,8 @@ public final class StudioActivityEventMapper {
Optional.of(new StudioActivityEntry("Assets", failed.message(), StudioActivityEntrySeverity.ERROR, true)); Optional.of(new StudioActivityEntry("Assets", failed.message(), StudioActivityEntrySeverity.ERROR, true));
case StudioPackerOperationEvent packerEvent -> case StudioPackerOperationEvent packerEvent ->
Optional.of(new StudioActivityEntry("Assets", packerEvent.summary(), severity(packerEvent), packerEvent.kind() == p.packer.events.PackerEventKind.ACTION_FAILED)); 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(); default -> Optional.empty();
}; };
} }

View File

@ -74,6 +74,7 @@ public final class StudioActivityFeedControl extends VBox implements StudioContr
subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshedEvent.class, this::onAssetsRefreshFinished)); subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshedEvent.class, this::onAssetsRefreshFinished));
subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshFailedEvent.class, this::onAssetsRefreshFailed)); subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshFailedEvent.class, this::onAssetsRefreshFailed));
subscriptions.add(eventBus.subscribe(StudioPackerOperationEvent.class, this::onPackerOperation)); subscriptions.add(eventBus.subscribe(StudioPackerOperationEvent.class, this::onPackerOperation));
subscriptions.add(eventBus.subscribe(StudioExecutionLifecycleEvent.class, this::onEvent));
} }
@Override @Override

View File

@ -11,13 +11,27 @@ import javafx.scene.layout.StackPane;
import p.studio.Container; import p.studio.Container;
import p.studio.controls.lifecycle.StudioControlLifecycle; import p.studio.controls.lifecycle.StudioControlLifecycle;
import p.studio.controls.lifecycle.StudioControlLifecycleSupport; 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; import p.studio.utilities.i18n.I18n;
public final class StudioRunSurfaceControl extends HBox implements StudioControlLifecycle { public final class StudioRunSurfaceControl extends HBox implements StudioControlLifecycle {
private final BooleanProperty running = new SimpleBooleanProperty(false); 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); StudioControlLifecycleSupport.install(this, this);
this.executionSessionService = executionSessionService;
this.playAction = playAction;
this.stopAction = stopAction;
getStyleClass().add("studio-run-surface"); getStyleClass().add("studio-run-surface");
setSpacing(8); setSpacing(8);
setAlignment(Pos.CENTER_LEFT); setAlignment(Pos.CENTER_LEFT);
@ -38,10 +52,9 @@ public final class StudioRunSurfaceControl extends HBox implements StudioControl
final HBox content = new HBox(8, iconShell, text); final HBox content = new HBox(8, iconShell, text);
content.setAlignment(Pos.CENTER_LEFT); content.setAlignment(Pos.CENTER_LEFT);
final Button toggleButton = new Button();
toggleButton.getStyleClass().add("studio-run-toggle-button"); toggleButton.getStyleClass().add("studio-run-toggle-button");
toggleButton.graphicProperty().set(content); toggleButton.graphicProperty().set(content);
toggleButton.setOnAction(ignored -> running.set(!running.get())); toggleButton.setOnAction(ignored -> handleToggleRequested());
running.addListener((ignored, oldValue, newValue) -> { running.addListener((ignored, oldValue, newValue) -> {
toggleButton.getStyleClass().removeAll("studio-run-toggle-play", "studio-run-toggle-stop"); 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() { public BooleanProperty runningProperty() {
return running; 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;
}
} }

View File

@ -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);
}

View File

@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.Future; import java.util.concurrent.Future;
public final class StudioRuntimeHandshakeService { public final class StudioRuntimeHandshakeService implements StudioRuntimeHandshakeClient {
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final StudioBackgroundTasks backgroundTasks; private final StudioBackgroundTasks backgroundTasks;
private final Object monitor = new Object(); private final Object monitor = new Object();
@ -41,56 +41,75 @@ public final class StudioRuntimeHandshakeService {
} }
public StudioRuntimeHandshakeResult connect(final StudioExecutionSessionService session) { public StudioRuntimeHandshakeResult connect(final StudioExecutionSessionService session) {
return connect(session, StudioRuntimeDebugConnectionSettings.defaults()); return connectWithRetry(session, StudioRuntimeDebugConnectionSettings.defaults(), 1, 0L);
} }
@Override
public StudioRuntimeHandshakeResult connect( public StudioRuntimeHandshakeResult connect(
final StudioExecutionSessionService session, final StudioExecutionSessionService session,
final StudioRuntimeDebugConnectionSettings settings) { 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 StudioExecutionSessionService safeSession = Objects.requireNonNull(session, "session");
final StudioRuntimeDebugConnectionSettings safeSettings = Objects.requireNonNull(settings, "settings"); final StudioRuntimeDebugConnectionSettings safeSettings = Objects.requireNonNull(settings, "settings");
safeSession.transitionToConnecting(); safeSession.transitionToConnecting();
try { final int attempts = Math.max(1, maxAttempts);
synchronized (monitor) { Exception failure = null;
cleanupLocked(); for (int attempt = 1; attempt <= attempts; attempt += 1) {
intentionalDisconnect = false; try {
socket = new Socket(); synchronized (monitor) {
socket.connect(new InetSocketAddress(safeSettings.host(), safeSettings.port()), 5_000); cleanupLocked();
socket.setSoTimeout(5_000); intentionalDisconnect = false;
reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); socket = new Socket();
writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); socket.connect(new InetSocketAddress(safeSettings.host(), safeSettings.port()), 5_000);
} socket.setSoTimeout(5_000);
reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
final String handshakeLine = readHandshakeLine(); writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
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( final String handshakeLine = readHandshakeLine();
StudioExecutionLogSeverity.INFO, final StudioRuntimeHandshakeRuntimeToClient handshake = mapper.readValue(
"Runtime handshake connected to %s:%d".formatted(safeSettings.host(), safeSettings.port())); handshakeLine,
safeSession.appendRuntimeLog( StudioRuntimeHandshakeRuntimeToClient.class);
StudioExecutionLogSeverity.INFO, writeMessage(mapper.writeValueAsString(new StudioRuntimeHandshakeClientToRuntime("start")));
"Runtime protocol %d (%s)".formatted(handshake.protocolVersion(), handshake.runtimeVersion())); synchronized (monitor) {
safeSession.transitionToRunning(); if (socket != null) {
return StudioRuntimeHandshakeResult.success(safeSettings, handshake.protocolVersion(), handshake.runtimeVersion()); socket.setSoTimeout(0);
} catch (Exception exception) { }
cleanup(); receiverTask = backgroundTasks.submit(() -> receiveLoop(safeSession));
safeSession.appendRuntimeLog( }
StudioExecutionLogSeverity.ERROR,
"Runtime handshake failed: " + failureMessage(exception)); safeSession.appendRuntimeLog(
safeSession.transitionToRuntimeFailed(); StudioExecutionLogSeverity.INFO,
return StudioRuntimeHandshakeResult.failure(safeSettings, failureMessage(exception)); "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) { public void disconnect(final StudioExecutionSessionService session) {
final StudioExecutionSessionService safeSession = Objects.requireNonNull(session, "session"); final StudioExecutionSessionService safeSession = Objects.requireNonNull(session, "session");
final boolean wasConnected; final boolean wasConnected;
@ -238,4 +257,12 @@ public final class StudioRuntimeHandshakeService {
? exception.getClass().getSimpleName() ? exception.getClass().getSimpleName()
: message; : message;
} }
private void sleepQuietly(final long retryDelayMillis) {
try {
Thread.sleep(retryDelayMillis);
} catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
}
}
} }

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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);
}
}
}

View File

@ -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();
}

View File

@ -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;
}

View File

@ -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 {
}

View File

@ -3,6 +3,7 @@ package p.studio.window;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
import p.studio.Container; import p.studio.Container;
import p.studio.controls.shell.*; import p.studio.controls.shell.*;
import p.studio.execution.StudioPlayStopCoordinator;
import p.studio.projectstate.ProjectLocalStudioSetup; import p.studio.projectstate.ProjectLocalStudioSetup;
import p.studio.lsp.events.StudioWorkspaceSelectedEvent; import p.studio.lsp.events.StudioWorkspaceSelectedEvent;
import p.studio.projectstate.ProjectLocalStudioState; import p.studio.projectstate.ProjectLocalStudioState;
@ -25,6 +26,7 @@ public final class MainView extends BorderPane {
private final AssetWorkspace assetWorkspace; private final AssetWorkspace assetWorkspace;
private final EditorWorkspace editorWorkspace; private final EditorWorkspace editorWorkspace;
private final StudioWorkspaceRailControl<WorkspaceId> workspaceRail; private final StudioWorkspaceRailControl<WorkspaceId> workspaceRail;
private final StudioPlayStopCoordinator playStopCoordinator;
private boolean initializing; private boolean initializing;
private boolean workspaceLifecycleInitialized; private boolean workspaceLifecycleInitialized;
@ -35,7 +37,15 @@ public final class MainView extends BorderPane {
final ProjectLocalStudioState persistedState = projectSession.projectLocalStudioState(); final ProjectLocalStudioState persistedState = projectSession.projectLocalStudioState();
final ProjectLocalStudioSetup projectSetup = projectSession.projectLocalStudioSetup(); final ProjectLocalStudioSetup projectSetup = projectSession.projectLocalStudioSetup();
final var menuBar = new StudioShellMenuBarControl(); 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)); setTop(new StudioShellTopBarControl(menuBar));
assetWorkspace = new AssetWorkspace(projectReference); assetWorkspace = new AssetWorkspace(projectReference);
@ -103,6 +113,7 @@ public final class MainView extends BorderPane {
} }
public void close() { public void close() {
playStopCoordinator.shutdown();
persistProjectLocalState(); persistProjectLocalState();
host.deactivateCurrentWorkspace(); host.deactivateCurrentWorkspace();
} }
@ -116,6 +127,11 @@ public final class MainView extends BorderPane {
Container.eventBus().publish(new StudioWorkspaceSelectedEvent(workspaceId)); Container.eventBus().publish(new StudioWorkspaceSelectedEvent(workspaceId));
} }
private void selectWorkspace(final WorkspaceId workspaceId) {
workspaceRail.select(workspaceId);
loadWorkspace(workspaceId);
}
private WorkspaceId restoreInitialWorkspace(final ProjectLocalStudioState.OpenShellState openShellState) { private WorkspaceId restoreInitialWorkspace(final ProjectLocalStudioState.OpenShellState openShellState) {
if (openShellState.selectedWorkspaceId() == null) { if (openShellState.selectedWorkspaceId() == null) {
return WorkspaceId.ASSETS; return WorkspaceId.ASSETS;

View File

@ -2,6 +2,7 @@ package p.studio.controls.shell;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import p.packer.events.PackerEventKind; import p.packer.events.PackerEventKind;
import p.studio.lsp.events.StudioExecutionLifecycleEvent;
import p.studio.lsp.events.StudioPackerOperationEvent; import p.studio.lsp.events.StudioPackerOperationEvent;
import p.studio.lsp.events.StudioProjectOpenedEvent; import p.studio.lsp.events.StudioProjectOpenedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshFailedEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshFailedEvent;
@ -54,4 +55,15 @@ final class StudioActivityEventMapperTest {
assertEquals(StudioActivityEntrySeverity.SUCCESS, entry.severity()); assertEquals(StudioActivityEntrySeverity.SUCCESS, entry.severity());
assertEquals("Asset created.", entry.message()); 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());
}
} }

View File

@ -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;
}
}
}