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

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(StudioAssetsWorkspaceRefreshFailedEvent.class, this::onAssetsRefreshFailed));
subscriptions.add(eventBus.subscribe(StudioPackerOperationEvent.class, this::onPackerOperation));
subscriptions.add(eventBus.subscribe(StudioExecutionLifecycleEvent.class, this::onEvent));
}
@Override

View File

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

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

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

View File

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

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