From 98b11f69a27b7b50fd5cf3e3f6dfd7417d1010c8 Mon Sep 17 00:00:00 2001 From: Henrique Date: Mon, 3 Aug 2026 18:12:21 -0300 Subject: [PATCH] fix(editor): prevent PIE compile-error modal deadlocks --- CHANGELOG.md | 4 + Docs/API_REFERENCE.md | 2 +- Docs/specs/SPEC_MonolithEditor.md | 2 +- .../Private/MonolithEditorActions.cpp | 39 +++++++++- .../Private/Tests/MonolithStartPieTests.cpp | 74 +++++++++++++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 Source/MonolithEditor/Private/Tests/MonolithStartPieTests.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a16c6a76..3b7d688fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **`editor start_pie` no longer strands MCP behind the Blueprint compile-error dialog.** The action now pre-flights loaded Blueprints using the engine's own unresolved-error condition. Its default `on_compile_errors: "refuse"` policy returns the offending `{name, path}` entries without starting PIE; `"suppress"` explicitly starts anyway under a scoped unattended-script guard, so the confirmation modal cannot block the game thread and the in-process MCP server. + ## [0.22.0] - 2026-08-01 ### Internal diff --git a/Docs/API_REFERENCE.md b/Docs/API_REFERENCE.md index 3ca1aee66..47d2160e6 100644 --- a/Docs/API_REFERENCE.md +++ b/Docs/API_REFERENCE.md @@ -629,7 +629,7 @@ Execute a console command. Routes to the first PIE `PlayerController` found (so ### `editor.start_pie` · `editor.stop_pie` · NEW in v0.14.10 -`start_pie` queues an in-viewport Play-In-Editor session (refuses to queue a duplicate when a PIE world is already alive); response includes `mode: 'in_viewport'`. `stop_pie` calls `RequestEndPlayMap` when a PIE world exists, no-op (`stopped: false`) otherwise. Both take *no parameters*. Pairs with `run_python` / `load_level` for fully automated in-game test flows. +`start_pie` queues an in-viewport Play-In-Editor session (refuses to queue a duplicate when a PIE world is already alive); response includes `mode: 'in_viewport'`. It accepts `on_compile_errors: "refuse" | "suppress"` (`"refuse"` by default). Refuse mode returns the loaded Blueprints with unresolved compiler errors without starting PIE. Suppress mode starts PIE anyway under a narrowly scoped unattended-script guard, preventing the engine's compile-error confirmation from blocking the game thread and MCP server. `stop_pie` calls `RequestEndPlayMap` when a PIE world exists, no-op (`stopped: false`) otherwise. Pairs with `run_python` / `load_level` for fully automated in-game test flows. ### `editor.run_python` · NEW in v0.14.9 diff --git a/Docs/specs/SPEC_MonolithEditor.md b/Docs/specs/SPEC_MonolithEditor.md index b3fc91460..a78063878 100644 --- a/Docs/specs/SPEC_MonolithEditor.md +++ b/Docs/specs/SPEC_MonolithEditor.md @@ -95,7 +95,7 @@ Pattern table: | Action | Description | |--------|-------------| -| `start_pie` | Begin a PIE session pinned to in-viewport mode (`EPlaySessionWorldType::PlayInEditor` + first active level viewport via `FLevelEditorModule::GetFirstActiveViewport`). Independent of the user's `LastExecutedPlayModeType` toolbar choice. Returns `started: true, mode: 'in_viewport'`. Refuses to queue duplicates when PIE is already running. | +| `start_pie` | Begin a PIE session pinned to in-viewport mode (`EPlaySessionWorldType::PlayInEditor` + first active level viewport via `FLevelEditorModule::GetFirstActiveViewport`). Independent of the user's `LastExecutedPlayModeType` toolbar choice. Pre-flights loaded Blueprints for the engine's unresolved-compile-error condition. `on_compile_errors: "refuse"` (default) returns a structured error with `errored_blueprints` and does not start PIE; `"suppress"` starts anyway under a scoped `GIsRunningUnattendedScript` guard so the engine cannot open a modal that blocks the game thread and MCP server. Success returns `started`, `mode`, `compile_error_policy`, `errored_blueprint_count`, and `errored_blueprints`. Refuses to queue duplicates when PIE is already running. | | `stop_pie` | End the active PIE session via `GUnrealEd->RequestEndPlayMap()`. No-op (returns `stopped: false`) if PIE not active. | | `run_console_command` | Execute a console command. Routes to the first PIE PlayerController found (multi-client PIE not disambiguated); falls back to `GEngine->Exec` (with null-guard) when no PIE session is active. | diff --git a/Source/MonolithEditor/Private/MonolithEditorActions.cpp b/Source/MonolithEditor/Private/MonolithEditorActions.cpp index f746c6079..83d3da178 100644 --- a/Source/MonolithEditor/Private/MonolithEditorActions.cpp +++ b/Source/MonolithEditor/Private/MonolithEditorActions.cpp @@ -527,9 +527,11 @@ void FMonolithEditorActions::RegisterActions(FMonolithLogCapture* LogCapture) .Build()); Registry.RegisterAction(TEXT("editor"), TEXT("start_pie"), - TEXT("Start a Play-In-Editor session (equivalent to pressing Cmd+P in the editor)."), + TEXT("Start an in-viewport Play-In-Editor session. Pre-flights loaded Blueprint compile errors so the action never opens a blocking modal: on_compile_errors=\"refuse\" (default) returns the offending assets; \"suppress\" starts PIE anyway while silencing that prompt."), FMonolithActionHandler::CreateStatic(&HandleStartPIE), - MakeShared()); + FParamSchemaBuilder() + .Optional(TEXT("on_compile_errors"), TEXT("string"), TEXT("Policy when loaded Blueprints have unresolved compile errors: \"refuse\" (default, safe) returns an error + the offending {name,path} list without starting PIE; \"suppress\" starts PIE anyway and silences the engine's blocking compile-error modal."), TEXT("refuse")) + .Build()); Registry.RegisterAction(TEXT("editor"), TEXT("stop_pie"), TEXT("Stop the active Play-In-Editor session."), @@ -2017,8 +2019,36 @@ FMonolithActionResult FMonolithEditorActions::HandleStartPIE(const TSharedPtrTryGetStringField(TEXT("on_compile_errors"), CompileMode); + } + const bool bRefuseCompileErrors = CompileMode.Equals(TEXT("refuse"), ESearchCase::IgnoreCase); + const bool bSuppressCompileErrors = CompileMode.Equals(TEXT("suppress"), ESearchCase::IgnoreCase); + if (!bRefuseCompileErrors && !bSuppressCompileErrors) + { + return FMonolithActionResult::Error( + FString::Printf(TEXT("Invalid on_compile_errors policy '%s'. Expected 'refuse' or 'suppress'."), *CompileMode), + -32602); + } + + TArray Errored; + ScanErroredBlueprints(Errored); + if (Errored.Num() > 0 && bRefuseCompileErrors) + { + TSharedPtr ErrorData = MakeShared(); + ErrorData->SetNumberField(TEXT("errored_blueprint_count"), Errored.Num()); + ErrorData->SetArrayField(TEXT("errored_blueprints"), ErroredBlueprintsToJson(Errored)); + return FMonolithActionResult::Error( + FString::Printf(TEXT("start_pie refused: %d Blueprint(s) have unresolved compile errors. ") + TEXT("Fix them, or pass on_compile_errors=\"suppress\" to start PIE without opening the blocking modal."), + Errored.Num())) + .WithErrorData(ErrorData); + } + FString StartError; - if (!StartPieInternal(StartError)) + if (!StartPieInternal(StartError, bSuppressCompileErrors)) { return FMonolithActionResult::Error(StartError); } @@ -2026,6 +2056,9 @@ FMonolithActionResult FMonolithEditorActions::HandleStartPIE(const TSharedPtr Root = MakeShared(); Root->SetBoolField(TEXT("started"), true); Root->SetStringField(TEXT("mode"), TEXT("in_viewport")); + Root->SetStringField(TEXT("compile_error_policy"), bSuppressCompileErrors ? TEXT("suppress") : TEXT("refuse")); + Root->SetNumberField(TEXT("errored_blueprint_count"), Errored.Num()); + Root->SetArrayField(TEXT("errored_blueprints"), ErroredBlueprintsToJson(Errored)); return FMonolithActionResult::Success(Root); } diff --git a/Source/MonolithEditor/Private/Tests/MonolithStartPieTests.cpp b/Source/MonolithEditor/Private/Tests/MonolithStartPieTests.cpp new file mode 100644 index 000000000..914e8d459 --- /dev/null +++ b/Source/MonolithEditor/Private/Tests/MonolithStartPieTests.cpp @@ -0,0 +1,74 @@ +// Copyright tumourlove. All Rights Reserved. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "CoreMinimal.h" +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "Engine/Blueprint.h" +#include "Misc/AutomationTest.h" +#include "MonolithEditorActions.h" +#include "MonolithToolRegistry.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FMonolithStartPieRejectsInvalidCompilePolicyTest, + "Monolith.Editor.PIE.StartPieRejectsInvalidCompilePolicy", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FMonolithStartPieRejectsInvalidCompilePolicyTest::RunTest(const FString& /*Parameters*/) +{ + TSharedPtr Params = MakeShared(); + Params->SetStringField(TEXT("on_compile_errors"), TEXT("prompt")); + + const FMonolithActionResult Result = FMonolithEditorActions::HandleStartPIE(Params); + TestFalse(TEXT("Invalid compile-error policy is rejected"), Result.bSuccess); + TestEqual(TEXT("Invalid policy is an invalid-params error"), Result.ErrorCode, -32602); + TestTrue(TEXT("Error names the accepted policies"), + Result.ErrorMessage.Contains(TEXT("refuse")) && Result.ErrorMessage.Contains(TEXT("suppress"))); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FMonolithStartPieRefusesErroredBlueprintTest, + "Monolith.Editor.PIE.StartPieRefusesErroredBlueprint", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FMonolithStartPieRefusesErroredBlueprintTest::RunTest(const FString& /*Parameters*/) +{ + UBlueprint* BrokenBlueprint = NewObject( + GetTransientPackage(), + TEXT("MonolithStartPieBrokenBlueprint")); + BrokenBlueprint->Status = BS_Error; + BrokenBlueprint->bDisplayCompilePIEWarning = true; + + const FMonolithActionResult Result = FMonolithEditorActions::HandleStartPIE(MakeShared()); + TestFalse(TEXT("Default policy refuses an errored Blueprint"), Result.bSuccess); + TestTrue(TEXT("Refusal carries structured error data"), Result.ErrorData.IsValid()); + + bool bFoundTestBlueprint = false; + if (Result.ErrorData.IsValid() && Result.ErrorData->Type == EJson::Object) + { + const TSharedPtr ErrorData = Result.ErrorData->AsObject(); + const TArray>* Blueprints = nullptr; + if (ErrorData.IsValid() && ErrorData->TryGetArrayField(TEXT("errored_blueprints"), Blueprints) && Blueprints) + { + for (const TSharedPtr& Value : *Blueprints) + { + const TSharedPtr Entry = Value.IsValid() ? Value->AsObject() : nullptr; + if (Entry.IsValid() && Entry->GetStringField(TEXT("name")) == BrokenBlueprint->GetName()) + { + bFoundTestBlueprint = true; + break; + } + } + } + } + TestTrue(TEXT("Structured refusal lists the offending Blueprint"), bFoundTestBlueprint); + + BrokenBlueprint->bDisplayCompilePIEWarning = false; + BrokenBlueprint->Status = BS_UpToDate; + BrokenBlueprint->MarkAsGarbage(); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS