ue-mcp 1.3.3 → 1.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dialog-guard.d.ts +287 -0
- package/dist/dialog-guard.js +700 -0
- package/dist/dialog-guard.js.map +1 -0
- package/dist/editor-control.d.ts +101 -3
- package/dist/editor-control.js +233 -110
- package/dist/editor-control.js.map +1 -1
- package/dist/epic-cache.d.ts +12 -0
- package/dist/epic-cache.js +13 -0
- package/dist/epic-cache.js.map +1 -1
- package/dist/flow/guarded-bridge.d.ts +44 -10
- package/dist/flow/guarded-bridge.js +88 -20
- package/dist/flow/guarded-bridge.js.map +1 -1
- package/dist/flow/http-server.js +47 -4
- package/dist/flow/http-server.js.map +1 -1
- package/dist/flow/task-factory.js +41 -0
- package/dist/flow/task-factory.js.map +1 -1
- package/dist/flow/task-guards.js +17 -1
- package/dist/flow/task-guards.js.map +1 -1
- package/dist/index.js +191 -68
- package/dist/index.js.map +1 -1
- package/dist/locking.js +15 -0
- package/dist/locking.js.map +1 -1
- package/dist/offline.js +11 -2
- package/dist/offline.js.map +1 -1
- package/dist/session.js +8 -1
- package/dist/session.js.map +1 -1
- package/dist/tool-counts.json +2 -2
- package/dist/tools/editor.js +27 -5
- package/dist/tools/editor.js.map +1 -1
- package/package.json +2 -2
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BridgeProtocolTests.cpp +1 -1
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SequencerScrubTests.cpp +1 -1
- package/plugin/ue_mcp_bridge/UE_MCP_Bridge.uplugin +1 -1
- package/dist/dialog-gate.d.ts +0 -73
- package/dist/dialog-gate.js +0 -197
- package/dist/dialog-gate.js.map +0 -1
package/dist/editor-control.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
4
|
import * as net from "net";
|
|
5
|
+
import * as os from "node:os";
|
|
5
6
|
import WebSocket from "ws";
|
|
6
7
|
import { readUeMcpConfig } from "./project.js";
|
|
7
8
|
import { EngineResolutionError, selectEngine, trySelectEngine } from "./engine-root.js";
|
|
@@ -10,6 +11,7 @@ import { editorOwnsProject, findEditorByPid, findInteractiveEditors, readEngineS
|
|
|
10
11
|
import { findLiveInstanceRecord, isPidAlive, lockfileIsFromThisLaunch, resolveBridgeTarget } from "./editor-target.js";
|
|
11
12
|
import { startProgress } from "./ui/progress.js";
|
|
12
13
|
import { getDialogMode, getUserStatePath } from "./user-state.js";
|
|
14
|
+
import { DialogGuard } from "./dialog-guard.js";
|
|
13
15
|
// Process control is cross-platform: the editor binary path and the running-
|
|
14
16
|
// process probe differ per OS, and stopping goes through the bridge (#790).
|
|
15
17
|
const IS_WINDOWS = process.platform === "win32";
|
|
@@ -176,10 +178,7 @@ const WINDOW_PROBE_INTERVAL_MS = 60_000;
|
|
|
176
178
|
* waits for the snapshot to say `ready` and reports the whole startup as a
|
|
177
179
|
* progress bar rather than handing control back early.
|
|
178
180
|
*/
|
|
179
|
-
export async function
|
|
180
|
-
return waitForEditorReady(projectPath, projectDir, maxWaitSeconds, { launchedAtMs });
|
|
181
|
-
}
|
|
182
|
-
async function waitForEditorReady(projectPath, projectDir, maxWaitSeconds, opts = {}) {
|
|
181
|
+
export async function waitForEditorReady(projectPath, projectDir, maxWaitSeconds, opts = {}) {
|
|
183
182
|
const startTime = Date.now();
|
|
184
183
|
const maxWaitMs = maxWaitSeconds * 1000;
|
|
185
184
|
const timeline = [];
|
|
@@ -291,7 +290,11 @@ async function waitForEditorReady(projectPath, projectDir, maxWaitSeconds, opts
|
|
|
291
290
|
message: snapshot.modal.message ?? "",
|
|
292
291
|
buttons,
|
|
293
292
|
choices: buttons.map((label) => ({ buttonLabel: label, respondWith: respondCallFor(label) })),
|
|
294
|
-
},
|
|
293
|
+
},
|
|
294
|
+
// Forwarded, not defaulted: describeBlockingDialog applies the
|
|
295
|
+
// default, and a second copy of it here is a second place for the
|
|
296
|
+
// rule to drift away from the guard's.
|
|
297
|
+
{ pressCalls: opts.pressCalls }),
|
|
295
298
|
state: await readEngineState(projectPath ?? null),
|
|
296
299
|
});
|
|
297
300
|
}
|
|
@@ -683,14 +686,14 @@ function respondCallFor(label) {
|
|
|
683
686
|
? `editor(action='respond_to_dialog', buttonLabel="${label}")`
|
|
684
687
|
: `editor(action='respond_to_dialog', buttonLabel='${label}')`;
|
|
685
688
|
}
|
|
686
|
-
|
|
689
|
+
const UNREADABLE = { dialog: null, readable: false };
|
|
687
690
|
async function readBlockingDialog(port, host) {
|
|
688
691
|
const reply = await callBridgeOnce(port, "list_dialogs", {}, host);
|
|
689
692
|
if (!accepted(reply) || !reply.result)
|
|
690
|
-
return
|
|
693
|
+
return UNREADABLE;
|
|
691
694
|
const dialogs = reply.result.dialogs;
|
|
692
695
|
if (!Array.isArray(dialogs) || dialogs.length === 0)
|
|
693
|
-
return null;
|
|
696
|
+
return { dialog: null, readable: true };
|
|
694
697
|
const first = dialogs[0];
|
|
695
698
|
const buttons = Array.isArray(first.buttons)
|
|
696
699
|
? first.buttons.filter((b) => typeof b === "string" && b !== "")
|
|
@@ -704,10 +707,13 @@ async function readBlockingDialog(port, host) {
|
|
|
704
707
|
};
|
|
705
708
|
});
|
|
706
709
|
return {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
710
|
+
readable: true,
|
|
711
|
+
dialog: {
|
|
712
|
+
title: typeof first.title === "string" ? first.title : "",
|
|
713
|
+
message: typeof first.message === "string" ? first.message : "",
|
|
714
|
+
buttons,
|
|
715
|
+
choices,
|
|
716
|
+
},
|
|
711
717
|
};
|
|
712
718
|
}
|
|
713
719
|
/**
|
|
@@ -830,7 +836,13 @@ export function resolveDialogMode(opts) {
|
|
|
830
836
|
* of having them, so each says that plainly rather than leaving the reader to
|
|
831
837
|
* infer it from a mode name.
|
|
832
838
|
*/
|
|
833
|
-
function dialogModeGuidance(resolved, canElicit, opts = {}) {
|
|
839
|
+
export function dialogModeGuidance(resolved, canElicit, opts = {}) {
|
|
840
|
+
// What the mode MEANS is the guard's answer, not a second reading of it
|
|
841
|
+
// here. This branched on the configured mode and re-derived the "interactive
|
|
842
|
+
// with nobody to ask behaves as defer" rule inline, which is one more place
|
|
843
|
+
// for it to drift away from the rule the refusal is actually built from.
|
|
844
|
+
const effective = DialogGuard.effectiveMode(resolved.mode, canElicit);
|
|
845
|
+
const downgraded = effective !== resolved.mode;
|
|
834
846
|
const applied = `Dialog handling mode: ${resolved.mode} (${resolved.source}).`;
|
|
835
847
|
// Whether a quit went out is a fact about the call, and this text is reused
|
|
836
848
|
// on both sides of it: before the quit, where nothing was asked to close, and
|
|
@@ -838,89 +850,56 @@ function dialogModeGuidance(resolved, canElicit, opts = {}) {
|
|
|
838
850
|
const quitState = opts.quitSent
|
|
839
851
|
? "The quit went out before this dialog appeared and the editor has not closed."
|
|
840
852
|
: "The editor was not asked to quit.";
|
|
841
|
-
|
|
853
|
+
const again = "then call editor(action='stop_editor') again.";
|
|
854
|
+
if (effective === "auto") {
|
|
842
855
|
return (`${applied} Nothing was pressed for you: in auto mode the whole dialog is handed back and the ` +
|
|
843
|
-
|
|
844
|
-
"editor(action='stop_editor') again.");
|
|
856
|
+
`choice is yours. Press the button you choose with the call listed beside it, ${again}`);
|
|
845
857
|
}
|
|
846
|
-
if (
|
|
858
|
+
if (effective === "interactive") {
|
|
859
|
+
return `${applied} ${quitState} Answer the dialog, ${again}`;
|
|
860
|
+
}
|
|
861
|
+
if (downgraded) {
|
|
847
862
|
return (`${applied} Interactive mode shows this dialog to the user in an MCP elicitation form, and this ` +
|
|
848
863
|
"client did not advertise that capability, so no form could be shown and nothing was pressed. " +
|
|
849
864
|
"Answer the dialog in the Unreal Editor window, or set UE_MCP_DIALOG_MODE=auto to answer it from " +
|
|
850
|
-
|
|
851
|
-
}
|
|
852
|
-
if (resolved.mode === "interactive") {
|
|
853
|
-
return `${applied} ${quitState} Answer the dialog, then call editor(action='stop_editor') again.`;
|
|
865
|
+
`here with editor(action='respond_to_dialog'), ${again}`);
|
|
854
866
|
}
|
|
855
867
|
return (`${applied} Nothing was pressed and nothing was asked of you: in defer mode a blocking dialog is ` +
|
|
856
|
-
|
|
857
|
-
"editor(action='stop_editor') again.");
|
|
868
|
+
`left alone. Go to the Unreal Editor window and answer the dialog quoted above yourself, ${again}`);
|
|
858
869
|
}
|
|
859
870
|
/**
|
|
860
|
-
* Put the dialog to the person
|
|
861
|
-
* button they choose.
|
|
871
|
+
* Put the dialog to the person and press what they choose.
|
|
862
872
|
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
873
|
+
* The decision, the form and the press all belong to DialogGuard: this only
|
|
874
|
+
* supplies the transport, because a quit may already be in flight and the
|
|
875
|
+
* session's own bridge is not usable then. Keeping a second copy of the
|
|
876
|
+
* interactive logic here is what let the two disagree.
|
|
867
877
|
*
|
|
868
|
-
* Returns
|
|
869
|
-
* it pressed nothing. A send whose outcome is unknown comes back as a press
|
|
870
|
-
* with confirmed: false, because that is what it is.
|
|
878
|
+
* Returns the button pressed, or null when nothing was.
|
|
871
879
|
*/
|
|
872
|
-
async function askUserToAnswerDialog(port, host, dialog, elicit) {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
},
|
|
896
|
-
},
|
|
897
|
-
required: ["button"],
|
|
898
|
-
},
|
|
899
|
-
});
|
|
900
|
-
}
|
|
901
|
-
catch {
|
|
902
|
-
// No elicitation UI, or the prompt failed. The dialog stays up and the
|
|
903
|
-
// report is what the caller gets.
|
|
904
|
-
return null;
|
|
905
|
-
}
|
|
906
|
-
if (answer.action !== "accept")
|
|
907
|
-
return null;
|
|
908
|
-
const chosen = answer.content?.button;
|
|
909
|
-
if (typeof chosen !== "string" || chosen === LEAVE_OPEN)
|
|
910
|
-
return null;
|
|
911
|
-
if (!dialog.buttons.includes(chosen))
|
|
912
|
-
return null;
|
|
913
|
-
const pressed = await callBridgeOnce(port, "respond_to_dialog", { buttonLabel: chosen }, host);
|
|
914
|
-
if (accepted(pressed))
|
|
915
|
-
return { button: chosen, confirmed: true };
|
|
916
|
-
// The bridge answered and said it did not press: an unregistered method on an
|
|
917
|
-
// older plugin build, or a handler that refused. Nothing happened to the
|
|
918
|
-
// dialog, and "nothing was sent" is the honest report.
|
|
919
|
-
if (pressed.answered)
|
|
920
|
-
return null;
|
|
921
|
-
// It did not answer at all. The frame was already on the wire, so whether the
|
|
922
|
-
// button was pressed is genuinely unknown and must not be reported either way.
|
|
923
|
-
return { button: chosen, confirmed: false };
|
|
880
|
+
async function askUserToAnswerDialog(port, host, dialog, elicit, mode, canElicit, sessionGuard) {
|
|
881
|
+
// The editor's own guard when the caller had one, so its ask-once record and
|
|
882
|
+
// its knowledge of what is on screen are shared rather than duplicated. The
|
|
883
|
+
// fallback exists for callers with no session in hand.
|
|
884
|
+
const guard = sessionGuard ?? new DialogGuard({
|
|
885
|
+
// The resolved mode, not a hardcoded one. Hardcoding it here meant this
|
|
886
|
+
// route ignored what the machine asked for and elicited under auto and
|
|
887
|
+
// defer too.
|
|
888
|
+
mode: () => mode,
|
|
889
|
+
probe: () => callBridgeOnce(port, "list_dialogs", {}, host),
|
|
890
|
+
press: (buttonLabel) => callBridgeOnce(port, "respond_to_dialog", { buttonLabel }, host),
|
|
891
|
+
elicit: () => elicit,
|
|
892
|
+
});
|
|
893
|
+
const decision = await guard.decideFor("editor.stop_editor", {
|
|
894
|
+
title: dialog.title,
|
|
895
|
+
message: dialog.message,
|
|
896
|
+
buttons: dialog.buttons,
|
|
897
|
+
choices: (dialog.choices ?? []).filter((c) => typeof c.respondWith === "string"),
|
|
898
|
+
}, { canElicit });
|
|
899
|
+
// An unconfirmed press refuses (the guard cannot prove the way is clear) but
|
|
900
|
+
// still has to be reported, so read the outcome rather than the decision.
|
|
901
|
+
void decision;
|
|
902
|
+
return guard.lastPressed;
|
|
924
903
|
}
|
|
925
904
|
/**
|
|
926
905
|
* What is holding the editor open, in the words of the thing holding it.
|
|
@@ -1054,6 +1033,42 @@ export async function resolveOwnedEditor(projectDir, projectPath) {
|
|
|
1054
1033
|
"for this project republishes it.",
|
|
1055
1034
|
};
|
|
1056
1035
|
}
|
|
1036
|
+
/**
|
|
1037
|
+
* What a restart returns. It is the start's result plus the stop half's account
|
|
1038
|
+
* of any dialog met on the way, because the restart's stop is a full
|
|
1039
|
+
* editor(stop_editor) and a caller cannot see which dialog handling mode
|
|
1040
|
+
* applied if the fields are dropped on the way out.
|
|
1041
|
+
*/
|
|
1042
|
+
/**
|
|
1043
|
+
* The stop half's account of a dialog, forwarded onto the restart's result.
|
|
1044
|
+
*
|
|
1045
|
+
* Both the record of WHAT HAPPENED (mode, source, the dialog, any press) and
|
|
1046
|
+
* the guard's refusal fields travel. Copying only the former dropped
|
|
1047
|
+
* dialogBlocking, refusedMethod, dialogTitle, dialogMessage and error, so a
|
|
1048
|
+
* restart refused by a dialog came back unrecognisable to isDialogRefusal and
|
|
1049
|
+
* to every client branching on that flag.
|
|
1050
|
+
*/
|
|
1051
|
+
export function restartDialogAccount(stopResult) {
|
|
1052
|
+
return {
|
|
1053
|
+
...(stopResult.dialogBlocking ? { dialogBlocking: stopResult.dialogBlocking } : {}),
|
|
1054
|
+
...(stopResult.refusedMethod ? { refusedMethod: stopResult.refusedMethod } : {}),
|
|
1055
|
+
...(stopResult.dialogTitle ? { dialogTitle: stopResult.dialogTitle } : {}),
|
|
1056
|
+
...(stopResult.dialogMessage !== undefined ? { dialogMessage: stopResult.dialogMessage } : {}),
|
|
1057
|
+
...(stopResult.buttons ? { buttons: stopResult.buttons } : {}),
|
|
1058
|
+
...(stopResult.choices ? { choices: stopResult.choices } : {}),
|
|
1059
|
+
...(stopResult.error ? { error: stopResult.error } : {}),
|
|
1060
|
+
...(stopResult.refusedReason ? { refusedReason: stopResult.refusedReason } : {}),
|
|
1061
|
+
...(stopResult.dialogMode ? { dialogMode: stopResult.dialogMode } : {}),
|
|
1062
|
+
...(stopResult.dialogModeSource ? { dialogModeSource: stopResult.dialogModeSource } : {}),
|
|
1063
|
+
...(stopResult.blockingDialog ? { blockingDialog: stopResult.blockingDialog } : {}),
|
|
1064
|
+
...(stopResult.dialogAnsweredByUser
|
|
1065
|
+
? { dialogAnsweredByUser: stopResult.dialogAnsweredByUser }
|
|
1066
|
+
: {}),
|
|
1067
|
+
...(stopResult.dialogPressUnconfirmed
|
|
1068
|
+
? { dialogPressUnconfirmed: stopResult.dialogPressUnconfirmed }
|
|
1069
|
+
: {}),
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1057
1072
|
/**
|
|
1058
1073
|
* What this tool will and will not do to a process that is not closing.
|
|
1059
1074
|
*
|
|
@@ -1155,7 +1170,13 @@ export async function stopEditor(projectDir, opts = {}) {
|
|
|
1155
1170
|
* every button label in order - and nothing that actuates it.
|
|
1156
1171
|
*/
|
|
1157
1172
|
const dialogMode = resolveDialogMode({ projectDir, canElicit });
|
|
1158
|
-
|
|
1173
|
+
// The guard owns this rule. Computing it here as well meant the same
|
|
1174
|
+
// decision existed twice and the two renderings disagreed about what defer
|
|
1175
|
+
// strips.
|
|
1176
|
+
// canElicit matters here too: interactive with nobody to ask handed the
|
|
1177
|
+
// press calls over anyway, which is the leak defer exists to close, on the
|
|
1178
|
+
// mode that promises a person chooses.
|
|
1179
|
+
const pressCalls = DialogGuard.handsOverPressCalls(dialogMode.mode, canElicit);
|
|
1159
1180
|
const ownership = await resolveOwnedEditor(projectDir, projectPath);
|
|
1160
1181
|
if (!ownership.owned) {
|
|
1161
1182
|
// No editor was closed, so the verdict is false on every branch here. What
|
|
@@ -1201,7 +1222,22 @@ export async function stopEditor(projectDir, opts = {}) {
|
|
|
1201
1222
|
// while the game thread is parked inside the modal loop, which is exactly the
|
|
1202
1223
|
// state where sending a quit would time out and teach nobody anything. Ask
|
|
1203
1224
|
// before sending, and report the whole question rather than acting on it.
|
|
1204
|
-
|
|
1225
|
+
const firstRead = await readBlockingDialog(port, host);
|
|
1226
|
+
if (!firstRead.readable) {
|
|
1227
|
+
// The same rule readDirtyPackages already follows: a question that cannot
|
|
1228
|
+
// be answered is not an answer of "clean". Sending a quit here is how an
|
|
1229
|
+
// editor sitting on a modal got one anyway, under every mode including
|
|
1230
|
+
// defer, because a slow reply was reported as no dialog at all.
|
|
1231
|
+
return {
|
|
1232
|
+
success: false,
|
|
1233
|
+
refusedReason: "unknown-dialog-state",
|
|
1234
|
+
message: "The editor's bridge did not answer when asked whether a dialog is blocking it, so whether "
|
|
1235
|
+
+ "one is on screen cannot be established and stopping could dismiss it. Nothing was asked "
|
|
1236
|
+
+ "to quit. Read it with editor(action='list_dialogs'), or close the editor yourself and "
|
|
1237
|
+
+ "answer whatever it asks.",
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
let dialog = firstRead.dialog;
|
|
1205
1241
|
let answeredByUser;
|
|
1206
1242
|
/** A press that went out with no answer. Not an answer, and not silence. */
|
|
1207
1243
|
let unconfirmedPress;
|
|
@@ -1226,11 +1262,11 @@ export async function stopEditor(projectDir, opts = {}) {
|
|
|
1226
1262
|
}
|
|
1227
1263
|
: {};
|
|
1228
1264
|
if (dialog) {
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
if (
|
|
1233
|
-
const press = await askUserToAnswerDialog(port, host, dialog, opts.elicit);
|
|
1265
|
+
// Always ask the guard. It applies the mode, so branching on the mode here
|
|
1266
|
+
// as well was a second copy of that decision and the two could disagree.
|
|
1267
|
+
// auto and defer press nothing; interactive puts it to the person.
|
|
1268
|
+
if (opts.elicit) {
|
|
1269
|
+
const press = await askUserToAnswerDialog(port, host, dialog, opts.elicit, dialogMode.mode, canElicit, opts.guard);
|
|
1234
1270
|
if (press !== null) {
|
|
1235
1271
|
if (press.confirmed)
|
|
1236
1272
|
answeredByUser = press.button;
|
|
@@ -1239,11 +1275,26 @@ export async function stopEditor(projectDir, opts = {}) {
|
|
|
1239
1275
|
// Their choice may have raised the next one, and on the unconfirmed
|
|
1240
1276
|
// path this read is also the only evidence available about whether the
|
|
1241
1277
|
// press landed. Look again rather than assuming the editor is clear.
|
|
1242
|
-
dialog
|
|
1278
|
+
// Unreadable here keeps the dialog rather than dropping it: this read
|
|
1279
|
+
// is the only evidence about whether the press landed, and treating
|
|
1280
|
+
// silence as success is what would report a clear editor.
|
|
1281
|
+
const after = await readBlockingDialog(port, host);
|
|
1282
|
+
dialog = after.readable ? after.dialog : dialog;
|
|
1243
1283
|
}
|
|
1244
1284
|
}
|
|
1245
1285
|
if (dialog) {
|
|
1246
1286
|
return {
|
|
1287
|
+
// The guard's own refusal fields, so this route hands a caller exactly
|
|
1288
|
+
// what every other route does: dialogBlocking, refusedMethod,
|
|
1289
|
+
// dialogTitle, dialogMessage, buttons, choices and error. Assembling
|
|
1290
|
+
// its own object left most of those undefined depending on which route
|
|
1291
|
+
// did the refusing.
|
|
1292
|
+
...DialogGuard.describeRefusal("editor.stop_editor", {
|
|
1293
|
+
title: dialog.title,
|
|
1294
|
+
message: dialog.message,
|
|
1295
|
+
buttons: dialog.buttons,
|
|
1296
|
+
choices: (dialog.choices ?? []).filter((c) => typeof c.respondWith === "string"),
|
|
1297
|
+
}, dialogMode.mode, canElicit),
|
|
1247
1298
|
success: false,
|
|
1248
1299
|
refusedReason: "blocking-dialog",
|
|
1249
1300
|
blockingDialog: pressCalls ? dialog : forRecognitionOnly(dialog),
|
|
@@ -1333,14 +1384,15 @@ export async function stopEditor(projectDir, opts = {}) {
|
|
|
1333
1384
|
// It was clean and unblocked when the quit went out, so whatever is holding
|
|
1334
1385
|
// it started afterwards. Say what that is, in full.
|
|
1335
1386
|
let blockedState = await readEngineState(projectPath, { probeWindows: false });
|
|
1336
|
-
|
|
1387
|
+
const lateRead = await readBlockingDialog(port, host);
|
|
1388
|
+
let late = lateRead.dialog;
|
|
1337
1389
|
if (late)
|
|
1338
1390
|
dialogSeen = true;
|
|
1339
1391
|
// The same mode governs a dialog that came up behind the quit. Interactive
|
|
1340
1392
|
// puts it to the user exactly as it would have before the quit; auto and
|
|
1341
1393
|
// defer leave it alone, so the loop below never runs for them.
|
|
1342
|
-
if (late &&
|
|
1343
|
-
const press = await askUserToAnswerDialog(port, host, late, opts.elicit);
|
|
1394
|
+
if (late && opts.elicit) {
|
|
1395
|
+
const press = await askUserToAnswerDialog(port, host, late, opts.elicit, dialogMode.mode, canElicit, opts.guard);
|
|
1344
1396
|
if (press !== null) {
|
|
1345
1397
|
if (press.confirmed)
|
|
1346
1398
|
answeredByUser = press.button;
|
|
@@ -1364,7 +1416,8 @@ export async function stopEditor(projectDir, opts = {}) {
|
|
|
1364
1416
|
};
|
|
1365
1417
|
}
|
|
1366
1418
|
}
|
|
1367
|
-
|
|
1419
|
+
const lateAfter = await readBlockingDialog(port, host);
|
|
1420
|
+
late = lateAfter.readable ? lateAfter.dialog : late;
|
|
1368
1421
|
// The snapshot above was taken BEFORE the elicitation, so it still holds
|
|
1369
1422
|
// the modal the user has since answered. Rendering it after the press is
|
|
1370
1423
|
// how one message came to quote a dialog that no longer exists, hand back
|
|
@@ -1409,6 +1462,7 @@ export async function stopEditor(projectDir, opts = {}) {
|
|
|
1409
1462
|
...(late
|
|
1410
1463
|
? {
|
|
1411
1464
|
blockingDialog: pressCalls ? late : forRecognitionOnly(late),
|
|
1465
|
+
dialogBlocking: true,
|
|
1412
1466
|
refusedReason: "blocking-dialog",
|
|
1413
1467
|
}
|
|
1414
1468
|
: {}),
|
|
@@ -1428,11 +1482,19 @@ export async function restartEditor(project, bridge, opts = {}) {
|
|
|
1428
1482
|
// result: the stop only reports one when it MET a dialog, and the start half
|
|
1429
1483
|
// has to keep defer's no-actuation promise whether or not the stop ran into
|
|
1430
1484
|
// anything.
|
|
1485
|
+
const restartCanElicit = clientAdvertisesElicitation(opts.elicit);
|
|
1431
1486
|
const dialogMode = resolveDialogMode({
|
|
1432
1487
|
projectDir: project.projectDir ?? null,
|
|
1433
|
-
canElicit:
|
|
1488
|
+
canElicit: restartCanElicit,
|
|
1489
|
+
});
|
|
1490
|
+
// Forward the editor's OWN guard. Without it the stop half fell back to
|
|
1491
|
+
// constructing a second one, with its own ask-once record, so a restart
|
|
1492
|
+
// asked the person again about a dialog the session guard had already put
|
|
1493
|
+
// to them, and pressed a second real button for it.
|
|
1494
|
+
const stopResult = await stopEditor(project.projectDir ?? undefined, {
|
|
1495
|
+
elicit: opts.elicit,
|
|
1496
|
+
guard: opts.guard,
|
|
1434
1497
|
});
|
|
1435
|
-
const stopResult = await stopEditor(project.projectDir ?? undefined, { elicit: opts.elicit });
|
|
1436
1498
|
// Whether the stop mattered is a question about THIS project's editor: a
|
|
1437
1499
|
// failed stop with nothing of ours left running just means it was already
|
|
1438
1500
|
// down, and another project's editor being up says nothing either way.
|
|
@@ -1440,22 +1502,16 @@ export async function restartEditor(project, bridge, opts = {}) {
|
|
|
1440
1502
|
// account of it rather than flattening it into a message string. The doc
|
|
1441
1503
|
// promises dialogMode on every result that met a dialog, and a return type
|
|
1442
1504
|
// that cannot hold it would make that promise unkeepable here.
|
|
1443
|
-
const dialogAccount =
|
|
1444
|
-
...(stopResult.dialogMode ? { dialogMode: stopResult.dialogMode } : {}),
|
|
1445
|
-
...(stopResult.dialogModeSource ? { dialogModeSource: stopResult.dialogModeSource } : {}),
|
|
1446
|
-
...(stopResult.blockingDialog ? { blockingDialog: stopResult.blockingDialog } : {}),
|
|
1447
|
-
...(stopResult.dialogAnsweredByUser ? { dialogAnsweredByUser: stopResult.dialogAnsweredByUser } : {}),
|
|
1448
|
-
...(stopResult.dialogPressUnconfirmed
|
|
1449
|
-
? { dialogPressUnconfirmed: stopResult.dialogPressUnconfirmed }
|
|
1450
|
-
: {}),
|
|
1451
|
-
};
|
|
1505
|
+
const dialogAccount = restartDialogAccount(stopResult);
|
|
1452
1506
|
if (!stopResult.success && (await findInteractiveEditors(project.projectPath)).length > 0) {
|
|
1453
1507
|
return { success: false, message: `Failed to stop editor: ${stopResult.message}`, ...dialogAccount };
|
|
1454
1508
|
}
|
|
1455
1509
|
// Wait for process to fully terminate and release locks
|
|
1456
1510
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
1457
1511
|
const startResult = await startEditor(project, undefined, undefined, {
|
|
1458
|
-
|
|
1512
|
+
// The guard's rule, not a second copy of it. This one also ignored
|
|
1513
|
+
// canElicit, so interactive with nobody to ask handed the buttons over.
|
|
1514
|
+
pressCalls: DialogGuard.handsOverPressCalls(dialogMode.mode, restartCanElicit),
|
|
1459
1515
|
});
|
|
1460
1516
|
if (!startResult.success) {
|
|
1461
1517
|
return { ...startResult, ...dialogAccount };
|
|
@@ -1478,6 +1534,56 @@ function getPlatformString() {
|
|
|
1478
1534
|
return "Mac";
|
|
1479
1535
|
return "Linux";
|
|
1480
1536
|
}
|
|
1537
|
+
/**
|
|
1538
|
+
* How many compiles this machine can run at once without running out of room.
|
|
1539
|
+
*
|
|
1540
|
+
* UnrealBuildTool defaults to one process per physical core. Each one maps the
|
|
1541
|
+
* Unreal precompiled header, which costs several GB, so on a machine with less
|
|
1542
|
+
* memory than cores x PCH the compiler does not queue: it fails outright with
|
|
1543
|
+
* C3859 ("failed to create virtual memory for PCH") or C1076, and the build
|
|
1544
|
+
* dies after several minutes of work. The failure names a paging file, so it
|
|
1545
|
+
* reads as a misconfigured machine rather than a parallelism default that does
|
|
1546
|
+
* not fit the hardware.
|
|
1547
|
+
*
|
|
1548
|
+
* This is not a knob anybody should have to find. The number is computed from
|
|
1549
|
+
* the machine and passed to every build, and the cap is only applied when it
|
|
1550
|
+
* is below what UnrealBuildTool would have chosen, so a machine with headroom
|
|
1551
|
+
* builds exactly as it did before.
|
|
1552
|
+
*
|
|
1553
|
+
* `UE_MCP_MAX_PARALLEL_ACTIONS` overrides it, for a machine whose real ceiling
|
|
1554
|
+
* this estimate gets wrong in either direction.
|
|
1555
|
+
*/
|
|
1556
|
+
export function safeParallelActions(totalMemBytes = os.totalmem(), cores = os.cpus().length) {
|
|
1557
|
+
const override = Number(process.env.UE_MCP_MAX_PARALLEL_ACTIONS);
|
|
1558
|
+
if (Number.isInteger(override) && override > 0)
|
|
1559
|
+
return override;
|
|
1560
|
+
// Measured against Unreal 5.8 on Windows: a PCH compile peaks around 5 GB,
|
|
1561
|
+
// and the OS plus the build server want a few GB that the compiles cannot
|
|
1562
|
+
// have. Both are deliberately pessimistic, because being one process under
|
|
1563
|
+
// costs a little wall clock and being one process over costs the whole build.
|
|
1564
|
+
const GB = 1024 ** 3;
|
|
1565
|
+
const PER_COMPILE_GB = 5;
|
|
1566
|
+
const RESERVED_GB = 4;
|
|
1567
|
+
const usableGB = totalMemBytes / GB - RESERVED_GB;
|
|
1568
|
+
const fits = Math.floor(usableGB / PER_COMPILE_GB);
|
|
1569
|
+
return Math.max(1, Math.min(cores, fits));
|
|
1570
|
+
}
|
|
1571
|
+
/** Whether a build died for want of memory rather than for anything in the code. */
|
|
1572
|
+
export function ranOutOfMemory(output) {
|
|
1573
|
+
return (output.includes("C3859")
|
|
1574
|
+
|| output.includes("C1076")
|
|
1575
|
+
|| output.includes("paging file is too small")
|
|
1576
|
+
|| output.includes("internal heap limit reached"));
|
|
1577
|
+
}
|
|
1578
|
+
/** What to tell somebody whose build ran out of room, in the terms they hit it in. */
|
|
1579
|
+
export function describeMemoryFailure(parallel, totalMemBytes = os.totalmem()) {
|
|
1580
|
+
const gb = Math.round(totalMemBytes / 1024 ** 3);
|
|
1581
|
+
return (`The build ran out of memory rather than hitting anything wrong in the code. This machine has `
|
|
1582
|
+
+ `${gb} GB of RAM and the compile was already limited to ${parallel} parallel `
|
|
1583
|
+
+ `${parallel === 1 ? "process" : "processes"}, each of which needs several GB for Unreal's `
|
|
1584
|
+
+ "precompiled header. Lower it further with UE_MCP_MAX_PARALLEL_ACTIONS, or give Windows a "
|
|
1585
|
+
+ "larger paging file, and run the build again.");
|
|
1586
|
+
}
|
|
1481
1587
|
/**
|
|
1482
1588
|
* Compile a project's C++ out of process, with UnrealBuildTool.
|
|
1483
1589
|
*
|
|
@@ -1519,7 +1625,12 @@ export async function buildProject(projectPath, opts = {}) {
|
|
|
1519
1625
|
// plainly there - while the same command pasted into a terminal worked,
|
|
1520
1626
|
// because the shell removed them first.
|
|
1521
1627
|
const commonArgs = [target, platform, configuration];
|
|
1522
|
-
|
|
1628
|
+
// Only when it is below what UnrealBuildTool would have picked, so a machine
|
|
1629
|
+
// with headroom builds exactly as it did before and nobody has to know this
|
|
1630
|
+
// exists.
|
|
1631
|
+
const parallel = safeParallelActions();
|
|
1632
|
+
const parallelArgs = parallel < os.cpus().length ? [`-MaxParallelActions=${parallel}`] : [];
|
|
1633
|
+
const tailArgs = ["-WaitMutex", "-FromMsBuild", ...parallelArgs, ...(opts.clean ? ["-Clean"] : [])];
|
|
1523
1634
|
const windowsArgs = [...commonArgs, `-Project="${resolvedPath}"`, ...tailArgs];
|
|
1524
1635
|
const posixArgs = [...commonArgs, `-Project=${resolvedPath}`, ...tailArgs];
|
|
1525
1636
|
return new Promise((resolve) => {
|
|
@@ -1532,8 +1643,14 @@ export async function buildProject(projectPath, opts = {}) {
|
|
|
1532
1643
|
else {
|
|
1533
1644
|
proc = spawn(buildTool, posixArgs, { stdio: "pipe" });
|
|
1534
1645
|
}
|
|
1646
|
+
// Kept so a failure can be read afterwards. The compiler reports running
|
|
1647
|
+
// out of room as a paging-file problem, which sends people to their
|
|
1648
|
+
// virtual memory settings for what is really a parallelism default that
|
|
1649
|
+
// does not fit the machine.
|
|
1650
|
+
let transcript = "";
|
|
1535
1651
|
const forward = (data) => {
|
|
1536
1652
|
const text = data.toString();
|
|
1653
|
+
transcript += text;
|
|
1537
1654
|
if (opts.onOutput)
|
|
1538
1655
|
opts.onOutput(text);
|
|
1539
1656
|
else
|
|
@@ -1551,7 +1668,13 @@ export async function buildProject(projectPath, opts = {}) {
|
|
|
1551
1668
|
invalidatePluginFreshness(resolvedPath);
|
|
1552
1669
|
resolve(code === 0
|
|
1553
1670
|
? { success: true, exitCode: 0, message: `Build succeeded (${target} ${platform} ${configuration})` }
|
|
1554
|
-
: {
|
|
1671
|
+
: {
|
|
1672
|
+
success: false,
|
|
1673
|
+
exitCode: code,
|
|
1674
|
+
message: ranOutOfMemory(transcript)
|
|
1675
|
+
? describeMemoryFailure(parallel)
|
|
1676
|
+
: `Build failed with exit code ${code}`,
|
|
1677
|
+
});
|
|
1555
1678
|
});
|
|
1556
1679
|
proc.on("error", (err) => {
|
|
1557
1680
|
resolve({ success: false, exitCode: null, message: `Build error: ${err.message}` });
|