@chrrxs/robloxstudio-mcp 3.2.0 → 3.2.1
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/index.js +122 -49
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +120 -53
package/dist/index.js
CHANGED
|
@@ -3296,7 +3296,7 @@ Tool descriptions explain selection. Input schemas explain arguments. This guide
|
|
|
3296
3296
|
## Connection and paths
|
|
3297
3297
|
|
|
3298
3298
|
- Use canonical DataModel paths returned by the tools. Paths usually start with game.
|
|
3299
|
-
- Call get_connected_instances when more than one Studio process may be connected. Pass either a top-level Instance ID or a multiplayer group's role-suffixed Instance ID as instance_id on later calls.
|
|
3299
|
+
- Call get_connected_instances when more than one Studio process may be connected. Pass either a top-level Instance ID or a multiplayer group's role-suffixed Instance ID as instance_id on later calls. A role-suffixed ID selects that server or client as the default target; a conflicting single-peer target is rejected.
|
|
3300
3300
|
- Use get_place_info for the active place identity and settings.
|
|
3301
3301
|
|
|
3302
3302
|
## Discovery and edit work
|
|
@@ -3879,6 +3879,9 @@ var ManagedInstanceRegistry = class {
|
|
|
3879
3879
|
if (!options.observeProcess || !(parsed.nativeProcessId || parsed.spawnPid))
|
|
3880
3880
|
continue;
|
|
3881
3881
|
const observation = await options.observeProcess(parsed);
|
|
3882
|
+
const previousObservationAt = parsed.lastProcessObservationAt;
|
|
3883
|
+
if (previousObservationAt !== void 0 && observation.observedAt < previousObservationAt)
|
|
3884
|
+
continue;
|
|
3882
3885
|
if (observation.status === "unknown") {
|
|
3883
3886
|
parsed.processObservationStatus = "unknown";
|
|
3884
3887
|
parsed.lastProcessObservationAt = observation.observedAt;
|
|
@@ -3888,7 +3891,6 @@ var ManagedInstanceRegistry = class {
|
|
|
3888
3891
|
await this.writeRecordUnlocked(parsed);
|
|
3889
3892
|
continue;
|
|
3890
3893
|
}
|
|
3891
|
-
const previousObservationAt = parsed.lastProcessObservationAt;
|
|
3892
3894
|
parsed.lastSuccessfulProcessObservationAt = observation.observedAt;
|
|
3893
3895
|
parsed.lastProcessObservationAt = observation.observedAt;
|
|
3894
3896
|
parsed.lastProcessObservationError = void 0;
|
|
@@ -5507,10 +5509,10 @@ var WINDOWS_STUDIO_PROCESS_QUERY = [
|
|
|
5507
5509
|
function parseWindowsStudioProcesses(output) {
|
|
5508
5510
|
const parsed = JSON.parse(output);
|
|
5509
5511
|
const processes = Array.isArray(parsed) ? parsed : [parsed];
|
|
5510
|
-
if (!processes.every((value) => value !== null && typeof value === "object" && "Id" in value && typeof value.Id === "number" && Number.isSafeInteger(value.Id) && value.Id > 0 && "Name" in value && typeof value.Name === "string" && "Path" in value && typeof value.Path === "string" && "MainWindowTitle" in value && typeof value.MainWindowTitle === "string" && "StartTimeUtcFileTime" in value && typeof value.StartTimeUtcFileTime === "string" && /^[1-9]\d*$/u.test(value.StartTimeUtcFileTime))) {
|
|
5512
|
+
if (!processes.every((value) => value !== null && typeof value === "object" && "Id" in value && typeof value.Id === "number" && Number.isSafeInteger(value.Id) && value.Id > 0 && "Name" in value && typeof value.Name === "string" && "Path" in value && (typeof value.Path === "string" || value.Path === null) && "MainWindowTitle" in value && typeof value.MainWindowTitle === "string" && "StartTimeUtcFileTime" in value && typeof value.StartTimeUtcFileTime === "string" && /^[1-9]\d*$/u.test(value.StartTimeUtcFileTime))) {
|
|
5511
5513
|
throw new Error("Malformed Roblox Studio process enumeration result.");
|
|
5512
5514
|
}
|
|
5513
|
-
return processes;
|
|
5515
|
+
return processes.map(({ Path, ...processInfo }) => Path === null ? processInfo : { ...processInfo, Path });
|
|
5514
5516
|
}
|
|
5515
5517
|
async function observeStudioProcesses(timeoutMs = 15e3) {
|
|
5516
5518
|
const observedAt = Date.now();
|
|
@@ -6521,6 +6523,8 @@ var StudioInstanceManager = class {
|
|
|
6521
6523
|
if (record.closedAt !== void 0)
|
|
6522
6524
|
return;
|
|
6523
6525
|
const previousObservationAt = record.lastProcessObservationAt;
|
|
6526
|
+
if (previousObservationAt !== void 0 && observation.observedAt < previousObservationAt)
|
|
6527
|
+
return;
|
|
6524
6528
|
record.lastProcessObservationAt = observation.observedAt;
|
|
6525
6529
|
if (observation.status === "unknown") {
|
|
6526
6530
|
record.processObservationStatus = "unknown";
|
|
@@ -6854,7 +6858,7 @@ function serverInstructions(definitions) {
|
|
|
6854
6858
|
const has = (...toolNames) => toolNames.every((name) => names.has(name));
|
|
6855
6859
|
const instructions = ["Use canonical DataModel paths returned by tools."];
|
|
6856
6860
|
if (has("get_connected_instances")) {
|
|
6857
|
-
instructions.push("When more than one Studio process scope is connected, call get_connected_instances and pass either a top-level instance id or a multiplayer group's role-suffixed instance id as instance_id.");
|
|
6861
|
+
instructions.push("When more than one Studio process scope is connected, call get_connected_instances and pass either a top-level instance id or a multiplayer group's role-suffixed instance id as instance_id. A role-suffixed id selects that server or client as the default target; a conflicting single-peer target is rejected.");
|
|
6858
6862
|
}
|
|
6859
6863
|
if (has("get_request_status", "execute_luau", "set_properties")) {
|
|
6860
6864
|
instructions.push("Supply a unique operation_id to execute_luau or set_properties when retry safety matters. After a timeout, query get_request_status with that ID before retrying. Identical arguments reuse a retained outcome; changed arguments are rejected. Recovery and deduplication are bounded to the current server session and five-minute retention window; result payloads may be evicted earlier. Unknown status does not mean unexecuted, and cancellation cannot roll back mutations.", "Request stages are queued, dispatched, executing (plugin handler entered), and response_delivery (handler returned or admission rejected). executionOutcome is separate from waiter state and delivery outcome; handler observations do not prove user Luau instructions ran. A waiter timeout is not an execution deadline or rollback. Neither missing progress nor connection loss proves completion.");
|
|
@@ -6995,6 +6999,7 @@ var HTTP_BODY_LIMIT_BYTES = 50 * 1024 * 1024;
|
|
|
6995
6999
|
|
|
6996
7000
|
// ../core/dist/studio-transport.js
|
|
6997
7001
|
var HEARTBEAT_INTERVAL_MS = 1e4;
|
|
7002
|
+
var PONG_TIMEOUT_MS = 3e4;
|
|
6998
7003
|
var MAX_PENDING_ACKS = 128;
|
|
6999
7004
|
var STUDIO_PROTOCOL_VERSION = 1;
|
|
7000
7005
|
var MAX_ACTIVE_STUDIO_SOCKETS = 64;
|
|
@@ -7042,13 +7047,20 @@ var WebSocketStudioTransport = class {
|
|
|
7042
7047
|
settling: false,
|
|
7043
7048
|
statusPending: true,
|
|
7044
7049
|
heartbeatPending: false,
|
|
7050
|
+
lastSeenAt: Date.now(),
|
|
7045
7051
|
acknowledgements: /* @__PURE__ */ new Map(),
|
|
7046
7052
|
onClose: () => {
|
|
7047
7053
|
this.closeSocket(connection);
|
|
7048
7054
|
this.finishClose(connection);
|
|
7049
7055
|
},
|
|
7050
7056
|
onError: () => this.closeSocket(connection, 1011, "socket_error"),
|
|
7051
|
-
onMessage: (data, isBinary) => this.receive(connection, data, isBinary)
|
|
7057
|
+
onMessage: (data, isBinary) => this.receive(connection, data, isBinary),
|
|
7058
|
+
onPong: () => {
|
|
7059
|
+
if (!this.isCurrent(connection))
|
|
7060
|
+
return;
|
|
7061
|
+
connection.lastSeenAt = Date.now();
|
|
7062
|
+
this.queue.updatePeerActivity(transportPeerId);
|
|
7063
|
+
}
|
|
7052
7064
|
};
|
|
7053
7065
|
this.queue.setDeliveryActive(transportPeerId, claimOwner, true);
|
|
7054
7066
|
const replaced = this.sockets.get(transportPeerId);
|
|
@@ -7059,10 +7071,20 @@ var WebSocketStudioTransport = class {
|
|
|
7059
7071
|
socket.on("close", connection.onClose);
|
|
7060
7072
|
socket.on("error", connection.onError);
|
|
7061
7073
|
socket.on("message", connection.onMessage);
|
|
7074
|
+
socket.on("pong", connection.onPong);
|
|
7062
7075
|
connection.heartbeatTimer = setInterval(() => {
|
|
7063
7076
|
if (!this.isCurrent(connection))
|
|
7064
7077
|
return;
|
|
7065
|
-
|
|
7078
|
+
if (Date.now() - connection.lastSeenAt >= PONG_TIMEOUT_MS) {
|
|
7079
|
+
this.closeSocket(connection, 1001, "pong_timeout");
|
|
7080
|
+
return;
|
|
7081
|
+
}
|
|
7082
|
+
try {
|
|
7083
|
+
connection.socket.ping();
|
|
7084
|
+
} catch {
|
|
7085
|
+
this.closeSocket(connection, 1011, "ping_failed");
|
|
7086
|
+
return;
|
|
7087
|
+
}
|
|
7066
7088
|
connection.statusPending = true;
|
|
7067
7089
|
connection.heartbeatPending = true;
|
|
7068
7090
|
this.pump(connection);
|
|
@@ -7120,6 +7142,11 @@ var WebSocketStudioTransport = class {
|
|
|
7120
7142
|
this.closeSocket(connection, 1007, "invalid_json");
|
|
7121
7143
|
return;
|
|
7122
7144
|
}
|
|
7145
|
+
if (message !== null && typeof message === "object" && !Array.isArray(message) && "kind" in message && message.kind === "disconnect") {
|
|
7146
|
+
this.queue.unregisterPeer(connection.transportPeerId);
|
|
7147
|
+
this.closeSocket(connection, 1e3, "peer_unregistered");
|
|
7148
|
+
return;
|
|
7149
|
+
}
|
|
7123
7150
|
if (message === null || typeof message !== "object" || Array.isArray(message) || !("kind" in message) || message.kind !== "response" && message.kind !== "progress" || !("requestId" in message) || typeof message.requestId !== "string" || message.requestId.length === 0 || message.requestId.length > 1024) {
|
|
7124
7151
|
this.closeSocket(connection, 1008, "invalid_response");
|
|
7125
7152
|
return;
|
|
@@ -7260,6 +7287,7 @@ var WebSocketStudioTransport = class {
|
|
|
7260
7287
|
connection.closed = true;
|
|
7261
7288
|
clearInterval(connection.heartbeatTimer);
|
|
7262
7289
|
connection.socket.removeListener("message", connection.onMessage);
|
|
7290
|
+
connection.socket.removeListener("pong", connection.onPong);
|
|
7263
7291
|
connection.acknowledgements.clear();
|
|
7264
7292
|
if (this.sockets.get(connection.transportPeerId) === connection)
|
|
7265
7293
|
this.sockets.delete(connection.transportPeerId);
|
|
@@ -7822,9 +7850,9 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
7822
7850
|
});
|
|
7823
7851
|
return;
|
|
7824
7852
|
}
|
|
7825
|
-
const
|
|
7853
|
+
const isClientRole2 = role === "client" || /^client-[1-9]\d*$/.test(role);
|
|
7826
7854
|
const isProxiedPeer = transportPeerId !== peerId;
|
|
7827
|
-
if (isProxiedPeer && !
|
|
7855
|
+
if (isProxiedPeer && !isClientRole2 || !isProxiedPeer && isClientRole2 || !isClientRole2 && role !== "edit" && role !== "server") {
|
|
7828
7856
|
res.status(400).json({
|
|
7829
7857
|
success: false,
|
|
7830
7858
|
error: "invalid_peer_topology",
|
|
@@ -12884,6 +12912,10 @@ end
|
|
|
12884
12912
|
error("Unsupported device simulator operation: " .. tostring(opts.operation), 0)
|
|
12885
12913
|
`.trim();
|
|
12886
12914
|
}
|
|
12915
|
+
var isClientRole = (role) => /^client-\d+$/.test(role);
|
|
12916
|
+
var isRuntimeRole2 = (role) => role === "server" || isClientRole(role);
|
|
12917
|
+
var anyRole = () => true;
|
|
12918
|
+
var SCOPE_WIDE_TARGETS = /* @__PURE__ */ new Set(["all", "all-clients", "edit-and-clients"]);
|
|
12887
12919
|
var RobloxStudioTools = class {
|
|
12888
12920
|
client;
|
|
12889
12921
|
bridge;
|
|
@@ -13027,7 +13059,8 @@ var RobloxStudioTools = class {
|
|
|
13027
13059
|
const refresh = this.bridge.refreshTopologyForRouting(signal);
|
|
13028
13060
|
if (refresh)
|
|
13029
13061
|
await refresh;
|
|
13030
|
-
const
|
|
13062
|
+
const role = typeof target === "object" ? this._targetForInstanceId(instance_id, target) : target;
|
|
13063
|
+
const resolved = this.bridge.resolveTarget({ instance_id, target: role });
|
|
13031
13064
|
if (!resolved.ok)
|
|
13032
13065
|
throw new RoutingFailure(resolved.error);
|
|
13033
13066
|
if (resolved.mode !== "single") {
|
|
@@ -13039,26 +13072,66 @@ var RobloxStudioTools = class {
|
|
|
13039
13072
|
}
|
|
13040
13073
|
return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal, operationId);
|
|
13041
13074
|
}
|
|
13075
|
+
// A grouped runtime process ID, or its role-suffixed Instance ID, names exactly
|
|
13076
|
+
// one Peer. Solo playtest Peers share one Instance ID, so it names none of them.
|
|
13077
|
+
_peerSelectedByInstanceId(instance_id) {
|
|
13078
|
+
if (instance_id === void 0)
|
|
13079
|
+
return void 0;
|
|
13080
|
+
const selected = this.bridge.getPeersInScope(instance_id).filter((peer) => isRuntimeRole2(peer.role) && (instance_id === peer.instanceId || instance_id === `${peer.instanceId}-${peer.role}`));
|
|
13081
|
+
return selected.length === 1 ? selected[0] : void 0;
|
|
13082
|
+
}
|
|
13083
|
+
// Apply a Peer-naming instance_id to a tool's target after topology refresh: it
|
|
13084
|
+
// replaces the tool default, and a different single-Peer target is rejected
|
|
13085
|
+
// instead of silently acting on another Peer.
|
|
13086
|
+
_targetForInstanceId(instance_id, choice) {
|
|
13087
|
+
const requested = choice.requested || void 0;
|
|
13088
|
+
const selected = this._peerSelectedByInstanceId(instance_id);
|
|
13089
|
+
if (!selected || !choice.accepts(selected.role))
|
|
13090
|
+
return requested ?? choice.fallback;
|
|
13091
|
+
if (requested === void 0)
|
|
13092
|
+
return selected.role;
|
|
13093
|
+
if (requested !== selected.role && !SCOPE_WIDE_TARGETS.has(requested)) {
|
|
13094
|
+
throw new RoutingFailure({
|
|
13095
|
+
code: "ambiguous_target",
|
|
13096
|
+
message: `instance_id "${instance_id}" selects ${selected.role}, but target=${requested}. Omit target, pass target=${selected.role}, or pass the controller Instance ID with target=${requested}.`,
|
|
13097
|
+
data: this._routingErrorData()
|
|
13098
|
+
});
|
|
13099
|
+
}
|
|
13100
|
+
return requested;
|
|
13101
|
+
}
|
|
13042
13102
|
// Honor an explicitly selected client for viewport/input operations; otherwise
|
|
13043
13103
|
// prefer the first client in the scope, then retain the default Peer's Instance.
|
|
13044
13104
|
_resolveRuntime(instance_id) {
|
|
13045
13105
|
const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
|
|
13046
|
-
|
|
13106
|
+
let scopePeers;
|
|
13107
|
+
let defaultInstanceId;
|
|
13108
|
+
if (resolved.ok) {
|
|
13109
|
+
if (resolved.mode !== "single") {
|
|
13110
|
+
throw new RoutingFailure({
|
|
13111
|
+
code: "target_role_not_present_on_instance",
|
|
13112
|
+
message: "A single runtime target is required.",
|
|
13113
|
+
data: this._routingErrorData()
|
|
13114
|
+
});
|
|
13115
|
+
}
|
|
13116
|
+
scopePeers = this.bridge.getPeersInScope(resolved.targetInstanceId);
|
|
13117
|
+
defaultInstanceId = resolved.targetInstanceId;
|
|
13118
|
+
} else if (resolved.error.code === "target_role_required") {
|
|
13119
|
+
scopePeers = instance_id !== void 0 ? this.bridge.getPeersInScope(instance_id) : this.bridge.getPeers();
|
|
13120
|
+
} else {
|
|
13047
13121
|
throw new RoutingFailure(resolved.error);
|
|
13048
|
-
|
|
13122
|
+
}
|
|
13123
|
+
const named = this._peerSelectedByInstanceId(instance_id);
|
|
13124
|
+
const client = (named && isClientRole(named.role) ? named : void 0) ?? scopePeers.filter((peer) => isClientRole(peer.role)).sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
|
|
13125
|
+
if (client)
|
|
13126
|
+
return { instanceId: client.instanceId, clientRole: client.role };
|
|
13127
|
+
if (defaultInstanceId === void 0) {
|
|
13049
13128
|
throw new RoutingFailure({
|
|
13050
13129
|
code: "target_role_not_present_on_instance",
|
|
13051
|
-
message:
|
|
13130
|
+
message: `No edit or client viewport is connected in this scope. Connected roles: ${scopePeers.map((peer) => peer.role).join(", ")}.`,
|
|
13052
13131
|
data: this._routingErrorData()
|
|
13053
13132
|
});
|
|
13054
13133
|
}
|
|
13055
|
-
|
|
13056
|
-
const selectedClients = clients.filter((peer) => instance_id === peer.instanceId || instance_id === `${peer.instanceId}-${peer.role}`);
|
|
13057
|
-
const client = selectedClients.length === 1 ? selectedClients[0] : clients.sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
|
|
13058
|
-
return {
|
|
13059
|
-
instanceId: client?.instanceId ?? resolved.targetInstanceId,
|
|
13060
|
-
clientRole: client?.role
|
|
13061
|
-
};
|
|
13134
|
+
return { instanceId: defaultInstanceId };
|
|
13062
13135
|
}
|
|
13063
13136
|
// Resolve synchronously after the caller has refreshed and captured its topology.
|
|
13064
13137
|
_resolveInstanceIdOnly(instance_id) {
|
|
@@ -13126,7 +13199,7 @@ var RobloxStudioTools = class {
|
|
|
13126
13199
|
return compact;
|
|
13127
13200
|
}
|
|
13128
13201
|
_resolveDeviceSimulatorSingleTarget(target, instance_id, toolName) {
|
|
13129
|
-
const selectedTarget = target
|
|
13202
|
+
const selectedTarget = this._targetForInstanceId(instance_id, { requested: target, fallback: "edit", accepts: isClientRole });
|
|
13130
13203
|
if (selectedTarget === "server" || selectedTarget === "all" || selectedTarget === "all-clients") {
|
|
13131
13204
|
throw new Error(`${toolName} target must be "edit" or "client-N" (got: ${selectedTarget})`);
|
|
13132
13205
|
}
|
|
@@ -13137,7 +13210,7 @@ var RobloxStudioTools = class {
|
|
|
13137
13210
|
return { ...resolved, selectedTarget };
|
|
13138
13211
|
}
|
|
13139
13212
|
_resolveDeviceSimulatorSetTargets(target, instance_id) {
|
|
13140
|
-
const selectedTarget = target
|
|
13213
|
+
const selectedTarget = this._targetForInstanceId(instance_id, { requested: target, fallback: "edit", accepts: isClientRole });
|
|
13141
13214
|
if (selectedTarget === "all-clients") {
|
|
13142
13215
|
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
13143
13216
|
const roles = this._clientRolesForScope(instanceId);
|
|
@@ -13161,7 +13234,7 @@ var RobloxStudioTools = class {
|
|
|
13161
13234
|
return selectedInclude;
|
|
13162
13235
|
}
|
|
13163
13236
|
_resolveSimulationTargets(target, instance_id, toolName) {
|
|
13164
|
-
const selectedTarget = target
|
|
13237
|
+
const selectedTarget = this._targetForInstanceId(instance_id, { requested: target, fallback: "edit-and-clients", accepts: isClientRole });
|
|
13165
13238
|
if (selectedTarget === "server" || selectedTarget === "all") {
|
|
13166
13239
|
throw new Error(`${toolName} target must be "edit", "client-N", "all-clients", or "edit-and-clients" (got: ${selectedTarget})`);
|
|
13167
13240
|
}
|
|
@@ -13668,7 +13741,7 @@ var RobloxStudioTools = class {
|
|
|
13668
13741
|
if (!code) {
|
|
13669
13742
|
throw new Error("Code is required for execute_luau");
|
|
13670
13743
|
}
|
|
13671
|
-
const response = await this._callSingle("/api/execute-luau", { code }, target
|
|
13744
|
+
const response = await this._callSingle("/api/execute-luau", { code }, { requested: target, fallback: "edit", accepts: anyRole }, instance_id, void 0, void 0, operation_id);
|
|
13672
13745
|
return {
|
|
13673
13746
|
content: [
|
|
13674
13747
|
{
|
|
@@ -13696,11 +13769,10 @@ var RobloxStudioTools = class {
|
|
|
13696
13769
|
if (!code) {
|
|
13697
13770
|
throw new Error("Code is required for eval_client_runtime");
|
|
13698
13771
|
}
|
|
13699
|
-
|
|
13700
|
-
|
|
13701
|
-
throw new Error(`eval_client_runtime requires target=client-N (got: ${clientTarget})`);
|
|
13772
|
+
if (target && !target.startsWith("client-")) {
|
|
13773
|
+
throw new Error(`eval_client_runtime requires target=client-N (got: ${target})`);
|
|
13702
13774
|
}
|
|
13703
|
-
const response = await this._callSingle("/api/eval-runtime", { code },
|
|
13775
|
+
const response = await this._callSingle("/api/eval-runtime", { code }, { requested: target, fallback: "client-1", accepts: isClientRole }, instance_id);
|
|
13704
13776
|
return {
|
|
13705
13777
|
content: [
|
|
13706
13778
|
{
|
|
@@ -13717,7 +13789,7 @@ var RobloxStudioTools = class {
|
|
|
13717
13789
|
await refresh;
|
|
13718
13790
|
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
13719
13791
|
const clientRoles = this._clientRolesForScope(instanceId);
|
|
13720
|
-
const selectedTarget = target
|
|
13792
|
+
const selectedTarget = this._targetForInstanceId(instance_id, { requested: target, fallback: "client-1", accepts: isClientRole });
|
|
13721
13793
|
let targetRoles;
|
|
13722
13794
|
if (selectedTarget === "all-clients") {
|
|
13723
13795
|
targetRoles = clientRoles;
|
|
@@ -14323,7 +14395,6 @@ var RobloxStudioTools = class {
|
|
|
14323
14395
|
return this._textResult(result);
|
|
14324
14396
|
}
|
|
14325
14397
|
async captureScriptProfiler(target, request = {}, instance_id) {
|
|
14326
|
-
const targetRole = target ?? "server";
|
|
14327
14398
|
const data = { ...request };
|
|
14328
14399
|
const outputPath = data.output_path;
|
|
14329
14400
|
delete data.output_path;
|
|
@@ -14336,6 +14407,7 @@ var RobloxStudioTools = class {
|
|
|
14336
14407
|
const refresh = this.bridge.refreshTopologyForRouting();
|
|
14337
14408
|
if (refresh)
|
|
14338
14409
|
await refresh;
|
|
14410
|
+
const targetRole = this._targetForInstanceId(instance_id, { requested: target, fallback: "server", accepts: isRuntimeRole2 });
|
|
14339
14411
|
const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
|
|
14340
14412
|
if (!resolved.ok)
|
|
14341
14413
|
throw new RoutingFailure(resolved.error);
|
|
@@ -14366,7 +14438,6 @@ var RobloxStudioTools = class {
|
|
|
14366
14438
|
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
14367
14439
|
}
|
|
14368
14440
|
async captureMicroProfiler(target, request = {}, instance_id) {
|
|
14369
|
-
const targetRole = target ?? "server";
|
|
14370
14441
|
const data = { ...request };
|
|
14371
14442
|
const outputPath = data.output_path;
|
|
14372
14443
|
const summaryOutputPath = data.summary_output_path;
|
|
@@ -14399,6 +14470,7 @@ var RobloxStudioTools = class {
|
|
|
14399
14470
|
const refresh = this.bridge.refreshTopologyForRouting();
|
|
14400
14471
|
if (refresh)
|
|
14401
14472
|
await refresh;
|
|
14473
|
+
const targetRole = this._targetForInstanceId(instance_id, { requested: target, fallback: "server", accepts: isRuntimeRole2 });
|
|
14402
14474
|
const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
|
|
14403
14475
|
if (!resolved.ok)
|
|
14404
14476
|
throw new RoutingFailure(resolved.error);
|
|
@@ -14449,13 +14521,13 @@ var RobloxStudioTools = class {
|
|
|
14449
14521
|
if (!action || typeof action !== "string") {
|
|
14450
14522
|
throw new Error("breakpoints requires action=set|remove|clear|list");
|
|
14451
14523
|
}
|
|
14452
|
-
const targetRole = target ?? "edit";
|
|
14453
14524
|
const data = { ...request, action };
|
|
14454
14525
|
delete data.target;
|
|
14455
14526
|
delete data.instance_id;
|
|
14456
14527
|
const refresh = this.bridge.refreshTopologyForRouting();
|
|
14457
14528
|
if (refresh)
|
|
14458
14529
|
await refresh;
|
|
14530
|
+
const targetRole = this._targetForInstanceId(instance_id, { requested: target, fallback: "edit", accepts: isRuntimeRole2 });
|
|
14459
14531
|
const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
|
|
14460
14532
|
if (!resolved.ok)
|
|
14461
14533
|
throw new RoutingFailure(resolved.error);
|
|
@@ -15147,7 +15219,7 @@ var RobloxStudioTools = class {
|
|
|
15147
15219
|
});
|
|
15148
15220
|
}
|
|
15149
15221
|
if (action === "leave_client") {
|
|
15150
|
-
const body2 = this._parseTextResult(await this.multiplayerTestLeaveClient(target
|
|
15222
|
+
const body2 = this._parseTextResult(await this.multiplayerTestLeaveClient(target, timeout, instance_id));
|
|
15151
15223
|
return this._textResult(body2.success === true && body2.left === true ? {
|
|
15152
15224
|
success: true,
|
|
15153
15225
|
action,
|
|
@@ -15280,14 +15352,15 @@ var RobloxStudioTools = class {
|
|
|
15280
15352
|
}]
|
|
15281
15353
|
};
|
|
15282
15354
|
}
|
|
15283
|
-
async multiplayerTestLeaveClient(target
|
|
15284
|
-
if (!/^client-\d+$/.test(target)) {
|
|
15355
|
+
async multiplayerTestLeaveClient(target, timeout, instance_id) {
|
|
15356
|
+
if (target && !/^client-\d+$/.test(target)) {
|
|
15285
15357
|
throw new Error(`multiplayer_test_leave_client requires target=client-N (got: ${target})`);
|
|
15286
15358
|
}
|
|
15287
15359
|
const refresh = this.bridge.refreshTopologyForRouting();
|
|
15288
15360
|
if (refresh)
|
|
15289
15361
|
await refresh;
|
|
15290
|
-
const
|
|
15362
|
+
const role = this._targetForInstanceId(instance_id, { requested: target, fallback: "client-1", accepts: isClientRole });
|
|
15363
|
+
const clientTarget = this._resolveSingleTarget(role, instance_id);
|
|
15291
15364
|
const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(clientTarget.instanceId));
|
|
15292
15365
|
const scopeInstanceId = group?.controllerInstanceId ?? clientTarget.instanceId;
|
|
15293
15366
|
const response = await this._requestPeer("/api/multiplayer-test-leave-client", {}, clientTarget.targetPeerId);
|
|
@@ -15909,12 +15982,13 @@ var RobloxStudioTools = class {
|
|
|
15909
15982
|
if (refresh)
|
|
15910
15983
|
await refresh;
|
|
15911
15984
|
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
15985
|
+
const role = this._targetForInstanceId(instance_id, { requested: target, fallback: clientRole ?? "edit", accepts: isClientRole });
|
|
15912
15986
|
const response = await this._callSingle("/api/simulate-mouse-input", {
|
|
15913
15987
|
action,
|
|
15914
15988
|
x,
|
|
15915
15989
|
y,
|
|
15916
15990
|
button
|
|
15917
|
-
},
|
|
15991
|
+
}, role, instanceId);
|
|
15918
15992
|
return {
|
|
15919
15993
|
content: [{
|
|
15920
15994
|
type: "text",
|
|
@@ -15930,12 +16004,13 @@ var RobloxStudioTools = class {
|
|
|
15930
16004
|
if (refresh)
|
|
15931
16005
|
await refresh;
|
|
15932
16006
|
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
16007
|
+
const role = this._targetForInstanceId(instance_id, { requested: target, fallback: clientRole ?? "edit", accepts: isClientRole });
|
|
15933
16008
|
const response = await this._callSingle("/api/simulate-keyboard-input", {
|
|
15934
16009
|
keyCode,
|
|
15935
16010
|
action,
|
|
15936
16011
|
duration,
|
|
15937
16012
|
text
|
|
15938
|
-
},
|
|
16013
|
+
}, role, instanceId);
|
|
15939
16014
|
return {
|
|
15940
16015
|
content: [{
|
|
15941
16016
|
type: "text",
|
|
@@ -15963,13 +16038,13 @@ var RobloxStudioTools = class {
|
|
|
15963
16038
|
};
|
|
15964
16039
|
}
|
|
15965
16040
|
async getMemoryBreakdown(target, tags, instance_id) {
|
|
15966
|
-
const tgt = target ?? "all";
|
|
15967
16041
|
const data = {};
|
|
15968
16042
|
if (tags !== void 0)
|
|
15969
16043
|
data.tags = tags;
|
|
15970
16044
|
const refresh = this.bridge.refreshTopologyForRouting();
|
|
15971
16045
|
if (refresh)
|
|
15972
16046
|
await refresh;
|
|
16047
|
+
const tgt = this._targetForInstanceId(instance_id, { requested: target, fallback: "all", accepts: anyRole });
|
|
15973
16048
|
const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
|
|
15974
16049
|
if (!resolved.ok)
|
|
15975
16050
|
throw new RoutingFailure(resolved.error);
|
|
@@ -15995,7 +16070,6 @@ var RobloxStudioTools = class {
|
|
|
15995
16070
|
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
15996
16071
|
}
|
|
15997
16072
|
async getSceneAnalysis(mode, target, topN, raw, instance_id) {
|
|
15998
|
-
const tgt = target ?? "all";
|
|
15999
16073
|
const data = {};
|
|
16000
16074
|
if (mode !== void 0)
|
|
16001
16075
|
data.mode = mode;
|
|
@@ -16006,6 +16080,7 @@ var RobloxStudioTools = class {
|
|
|
16006
16080
|
const refresh = this.bridge.refreshTopologyForRouting();
|
|
16007
16081
|
if (refresh)
|
|
16008
16082
|
await refresh;
|
|
16083
|
+
const tgt = this._targetForInstanceId(instance_id, { requested: target, fallback: "all", accepts: anyRole });
|
|
16009
16084
|
const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
|
|
16010
16085
|
if (!resolved.ok)
|
|
16011
16086
|
throw new RoutingFailure(resolved.error);
|
|
@@ -16037,11 +16112,10 @@ var RobloxStudioTools = class {
|
|
|
16037
16112
|
if (!outputPath || typeof outputPath !== "string") {
|
|
16038
16113
|
throw new Error("output_path is required for export_rbxm");
|
|
16039
16114
|
}
|
|
16040
|
-
|
|
16041
|
-
|
|
16042
|
-
throw new Error(`export_rbxm target must be "edit" or "server" (got: ${tgt})`);
|
|
16115
|
+
if (target && target !== "edit" && target !== "server") {
|
|
16116
|
+
throw new Error(`export_rbxm target must be "edit" or "server" (got: ${target})`);
|
|
16043
16117
|
}
|
|
16044
|
-
const response = await this._callSingle("/api/export-rbxm", { instance_paths: instancePaths },
|
|
16118
|
+
const response = await this._callSingle("/api/export-rbxm", { instance_paths: instancePaths }, { requested: target, fallback: "edit", accepts: (role) => role === "server" }, instance_id);
|
|
16045
16119
|
if (response.error) {
|
|
16046
16120
|
return { content: [{ type: "text", text: JSON.stringify({ error: response.error }) }] };
|
|
16047
16121
|
}
|
|
@@ -16074,9 +16148,8 @@ var RobloxStudioTools = class {
|
|
|
16074
16148
|
if (!parentPath || typeof parentPath !== "string") {
|
|
16075
16149
|
throw new Error("parent_path is required for import_rbxm");
|
|
16076
16150
|
}
|
|
16077
|
-
|
|
16078
|
-
|
|
16079
|
-
throw new Error(`import_rbxm target must be "edit" or "server" (got: ${tgt})`);
|
|
16151
|
+
if (target && target !== "edit" && target !== "server") {
|
|
16152
|
+
throw new Error(`import_rbxm target must be "edit" or "server" (got: ${target})`);
|
|
16080
16153
|
}
|
|
16081
16154
|
const modes = ["path", "url", "base64"].filter((k) => source[k] !== void 0);
|
|
16082
16155
|
if (modes.length !== 1) {
|
|
@@ -16093,7 +16166,7 @@ var RobloxStudioTools = class {
|
|
|
16093
16166
|
base64: bytes.toString("base64"),
|
|
16094
16167
|
parent_path: parentPath,
|
|
16095
16168
|
source_label: sourceLabel
|
|
16096
|
-
},
|
|
16169
|
+
}, { requested: target, fallback: "edit", accepts: (role) => role === "server" }, instance_id, void 0, signal);
|
|
16097
16170
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
16098
16171
|
}
|
|
16099
16172
|
// Even native capture must wait while another call has fitted the simulator
|
|
@@ -17538,7 +17611,7 @@ var TOOL_DEFINITIONS = [
|
|
|
17538
17611
|
},
|
|
17539
17612
|
target: {
|
|
17540
17613
|
type: "string",
|
|
17541
|
-
description: "Client peer; defaults to client-1."
|
|
17614
|
+
description: "Client peer; defaults to the client named by instance_id, else client-1."
|
|
17542
17615
|
},
|
|
17543
17616
|
instance_id: {
|
|
17544
17617
|
type: "string",
|
package/package.json
CHANGED
|
@@ -64,8 +64,8 @@ applyRememberedServerUrl()
|
|
|
64
64
|
-- down their VM. Close the persistent WebStreamClient while BindToClose still
|
|
65
65
|
-- gives this server peer a live execution context; otherwise each play cycle
|
|
66
66
|
-- can retain one native WebSocket until Studio itself is restarted. The
|
|
67
|
-
-- transport
|
|
68
|
-
--
|
|
67
|
+
-- transport unregisters the server peer on that socket and waits at most two
|
|
68
|
+
-- seconds for the server to close it, so no HTTP request can hold up shutdown.
|
|
69
69
|
if startupRole == "server" then
|
|
70
70
|
game:BindToClose(StudioWebSocket.suspendForShutdown)
|
|
71
71
|
end
|
|
@@ -104,8 +104,20 @@ plugin.Unloading:Connect(function()
|
|
|
104
104
|
Communication.deactivateAll()
|
|
105
105
|
end)
|
|
106
106
|
UI.updateUIState()
|
|
107
|
-
|
|
107
|
+
-- Every DataModel shares Studio's five plugin HTTP request slots, and a
|
|
108
|
+
-- request has no time limit. Check for updates once, from the edit DataModel,
|
|
109
|
+
-- instead of again in every play server and client.
|
|
110
|
+
if startupRole == "edit" then
|
|
111
|
+
Communication.checkForUpdates()
|
|
112
|
+
end
|
|
108
113
|
task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton)
|
|
114
|
+
--* Runs one startup step so that its failure cannot skip the steps after it.
|
|
115
|
+
local function runStartupStep(description, step)
|
|
116
|
+
local ok, err = pcall(step)
|
|
117
|
+
if not ok then
|
|
118
|
+
warn(`[robloxstudio-mcp] {description} failed: {err}`)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
109
121
|
-- Auto-activate per Peer. Runtime plugin VMs can load before their first
|
|
110
122
|
-- Heartbeat; task.delay() would then wait behind the very multiplayer startup
|
|
111
123
|
-- that needs this Peer to register. Start runtime initialization immediately,
|
|
@@ -123,10 +135,13 @@ local function autoActivatePeer()
|
|
|
123
135
|
if role == "server" then
|
|
124
136
|
-- Yields until this playtest server knows which edit session (or
|
|
125
137
|
-- multiplayer group) it belongs to, so it registers under that identity.
|
|
126
|
-
|
|
138
|
+
-- On failure the server still registers, under its own identity.
|
|
139
|
+
runStartupStep("Playtest topology resolution", function()
|
|
140
|
+
return PluginSession.resolveRuntimeTopology()
|
|
141
|
+
end)
|
|
127
142
|
end
|
|
128
143
|
if role == "edit" or role == "server" then
|
|
129
|
-
|
|
144
|
+
runStartupStep(`Automatic {role} Peer activation`, function()
|
|
130
145
|
local conn = State.getActiveConnection()
|
|
131
146
|
if not conn.isActive then
|
|
132
147
|
if role == "server" then
|
|
@@ -155,19 +170,20 @@ local function autoActivatePeer()
|
|
|
155
170
|
Communication.activatePlugin()
|
|
156
171
|
end
|
|
157
172
|
end)
|
|
158
|
-
if not activationOk then
|
|
159
|
-
warn(`[robloxstudio-mcp] Automatic {role} Peer activation failed: {activationError}`)
|
|
160
|
-
end
|
|
161
173
|
end
|
|
162
174
|
if role == "server" then
|
|
163
|
-
|
|
175
|
+
runStartupStep("Client broker setup", function()
|
|
176
|
+
return ClientBroker.setupServerBroker()
|
|
177
|
+
end)
|
|
164
178
|
-- The play-server DM is the only one where StudioTestService:EndTest is
|
|
165
179
|
-- legal, so the stop-play monitor lives here. It consumes tokenized
|
|
166
180
|
-- stop requests from plugin settings and acknowledges EndTest results.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
181
|
+
runStartupStep("Stop-play monitor startup", function()
|
|
182
|
+
return StopPlayMonitor.startMonitor({
|
|
183
|
+
beforeEndTest = StudioWebSocket.suspendForShutdown,
|
|
184
|
+
afterEndTestFailure = StudioWebSocket.resumeAfterShutdownFailure,
|
|
185
|
+
})
|
|
186
|
+
end)
|
|
171
187
|
elseif role == "client" then
|
|
172
188
|
ClientBroker.setupClientBroker()
|
|
173
189
|
end
|
|
@@ -1959,9 +1975,9 @@ local function computeBridgeStamp()
|
|
|
1959
1975
|
for i = 1, #combined do
|
|
1960
1976
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1961
1977
|
end
|
|
1962
|
-
-- "3.2.
|
|
1978
|
+
-- "3.2.1" is replaced with the package version at package time
|
|
1963
1979
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1964
|
-
return `{tostring(h)}-3.2.
|
|
1980
|
+
return `{tostring(h)}-3.2.1`
|
|
1965
1981
|
end
|
|
1966
1982
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1967
1983
|
local function setSource(scriptInst, source)
|
|
@@ -10092,10 +10108,12 @@ local TOPOLOGY_TOKEN_ATTRIBUTE = "__MCPTopologyToken"
|
|
|
10092
10108
|
-- saved or published DataModel, so edit sessions remove them on load.
|
|
10093
10109
|
local LEGACY_TOPOLOGY_ATTRIBUTES = { TOPOLOGY_MODE_ATTRIBUTE, TOPOLOGY_INSTANCE_ID_ATTRIBUTE, TOPOLOGY_GROUP_ID_ATTRIBUTE, TOPOLOGY_TOKEN_ATTRIBUTE }
|
|
10094
10110
|
local SESSION_IDENTITY_NAME = "__MCPSessionIdentity"
|
|
10095
|
-
--
|
|
10096
|
-
-- is never saved with the place or
|
|
10097
|
-
|
|
10098
|
-
|
|
10111
|
+
-- JSON session state lives in StringValue children of the CoreGui identity
|
|
10112
|
+
-- value: CoreGui survives plugin reloads but is never saved with the place or
|
|
10113
|
+
-- copied into playtest DataModels. Not attributes: with NextGenerationReplication,
|
|
10114
|
+
-- playtest DataModels reject attribute strings longer than 50 characters.
|
|
10115
|
+
local PLAY_RECORD_NAME = "PlayRecord"
|
|
10116
|
+
local RUNTIME_TOPOLOGY_NAME = "RuntimeTopology"
|
|
10099
10117
|
-- MCP-started solo and Run playtests carry their topology in StudioTestService
|
|
10100
10118
|
-- test args, which never touch the DataModel.
|
|
10101
10119
|
local TEST_ARGS_TOPOLOGY_KEY = "__mcpTopology"
|
|
@@ -10216,18 +10234,54 @@ local function optionalString(value)
|
|
|
10216
10234
|
end
|
|
10217
10235
|
return if _condition then value else nil
|
|
10218
10236
|
end
|
|
10219
|
-
function
|
|
10220
|
-
local
|
|
10221
|
-
if not (
|
|
10237
|
+
local function readSessionState(name)
|
|
10238
|
+
local holder = sessionIdentity:FindFirstChild(name)
|
|
10239
|
+
if holder == nil or not holder:IsA("StringValue") then
|
|
10222
10240
|
return nil
|
|
10223
10241
|
end
|
|
10224
10242
|
local ok, decoded = pcall(function()
|
|
10225
|
-
return HttpService:JSONDecode(
|
|
10243
|
+
return HttpService:JSONDecode(holder.Value)
|
|
10226
10244
|
end)
|
|
10227
10245
|
if not ok or not (type(decoded) == "table") then
|
|
10228
10246
|
return nil
|
|
10229
10247
|
end
|
|
10230
|
-
|
|
10248
|
+
return decoded
|
|
10249
|
+
end
|
|
10250
|
+
--* Best effort: saved session state only lets a plugin reload resume where it left off.
|
|
10251
|
+
local function writeSessionState(name, state)
|
|
10252
|
+
local ok, err = pcall(function()
|
|
10253
|
+
local existing = sessionIdentity:FindFirstChild(name)
|
|
10254
|
+
if state == nil then
|
|
10255
|
+
local _result = existing
|
|
10256
|
+
if _result ~= nil then
|
|
10257
|
+
_result:Destroy()
|
|
10258
|
+
end
|
|
10259
|
+
return nil
|
|
10260
|
+
end
|
|
10261
|
+
local encoded = HttpService:JSONEncode(state)
|
|
10262
|
+
if existing ~= nil and existing:IsA("StringValue") then
|
|
10263
|
+
existing.Value = encoded
|
|
10264
|
+
return nil
|
|
10265
|
+
end
|
|
10266
|
+
local _result = existing
|
|
10267
|
+
if _result ~= nil then
|
|
10268
|
+
_result:Destroy()
|
|
10269
|
+
end
|
|
10270
|
+
local holder = Instance.new("StringValue")
|
|
10271
|
+
holder.Name = name
|
|
10272
|
+
holder.Value = encoded
|
|
10273
|
+
holder.Archivable = false
|
|
10274
|
+
holder.Parent = sessionIdentity
|
|
10275
|
+
end)
|
|
10276
|
+
if not ok then
|
|
10277
|
+
warn(`[robloxstudio-mcp] Could not save {name} session state: {err}`)
|
|
10278
|
+
end
|
|
10279
|
+
end
|
|
10280
|
+
function readPlayRecord()
|
|
10281
|
+
local data = readSessionState(PLAY_RECORD_NAME)
|
|
10282
|
+
if data == nil then
|
|
10283
|
+
return nil
|
|
10284
|
+
end
|
|
10231
10285
|
local phase = if data.phase == "armed" or data.phase == "active" then data.phase else nil
|
|
10232
10286
|
if phase == nil then
|
|
10233
10287
|
return nil
|
|
@@ -10243,16 +10297,7 @@ function readPlayRecord()
|
|
|
10243
10297
|
}
|
|
10244
10298
|
end
|
|
10245
10299
|
local function writePlayRecord(record)
|
|
10246
|
-
|
|
10247
|
-
sessionIdentity:SetAttribute(PLAY_RECORD_ATTRIBUTE, nil)
|
|
10248
|
-
return nil
|
|
10249
|
-
end
|
|
10250
|
-
local ok, encoded = pcall(function()
|
|
10251
|
-
return HttpService:JSONEncode(record)
|
|
10252
|
-
end)
|
|
10253
|
-
if ok and type(encoded) == "string" then
|
|
10254
|
-
sessionIdentity:SetAttribute(PLAY_RECORD_ATTRIBUTE, encoded)
|
|
10255
|
-
end
|
|
10300
|
+
writeSessionState(PLAY_RECORD_NAME, record)
|
|
10256
10301
|
end
|
|
10257
10302
|
local function createTicket(instanceId, groupId)
|
|
10258
10303
|
local now = PlaytestTickets.nowMs()
|
|
@@ -10544,17 +10589,10 @@ local function topologyFromTestArgs()
|
|
|
10544
10589
|
}
|
|
10545
10590
|
end
|
|
10546
10591
|
local function readRememberedRuntimeTopology()
|
|
10547
|
-
local
|
|
10548
|
-
if
|
|
10592
|
+
local data = readSessionState(RUNTIME_TOPOLOGY_NAME)
|
|
10593
|
+
if data == nil then
|
|
10549
10594
|
return nil
|
|
10550
10595
|
end
|
|
10551
|
-
local ok, decoded = pcall(function()
|
|
10552
|
-
return HttpService:JSONDecode(raw)
|
|
10553
|
-
end)
|
|
10554
|
-
if not ok or not (type(decoded) == "table") then
|
|
10555
|
-
return nil
|
|
10556
|
-
end
|
|
10557
|
-
local data = decoded
|
|
10558
10596
|
return {
|
|
10559
10597
|
instanceId = optionalString(data.instanceId),
|
|
10560
10598
|
groupId = optionalString(data.groupId),
|
|
@@ -10564,12 +10602,7 @@ local function readRememberedRuntimeTopology()
|
|
|
10564
10602
|
end
|
|
10565
10603
|
local function adoptRuntimeTopology(topology)
|
|
10566
10604
|
runtimeTopology = topology
|
|
10567
|
-
|
|
10568
|
-
return HttpService:JSONEncode(topology)
|
|
10569
|
-
end)
|
|
10570
|
-
if ok and type(encoded) == "string" then
|
|
10571
|
-
sessionIdentity:SetAttribute(RUNTIME_TOPOLOGY_ATTRIBUTE, encoded)
|
|
10572
|
-
end
|
|
10605
|
+
writeSessionState(RUNTIME_TOPOLOGY_NAME, topology)
|
|
10573
10606
|
end
|
|
10574
10607
|
--[[
|
|
10575
10608
|
*
|
|
@@ -11496,7 +11529,7 @@ return {
|
|
|
11496
11529
|
<Properties>
|
|
11497
11530
|
<string name="Name">State</string>
|
|
11498
11531
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
11499
|
-
local CURRENT_VERSION = "3.2.
|
|
11532
|
+
local CURRENT_VERSION = "3.2.1"
|
|
11500
11533
|
local PLUGIN_VARIANT = "main"
|
|
11501
11534
|
local BASE_PORT = 58741
|
|
11502
11535
|
local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
|
|
@@ -11943,6 +11976,10 @@ local MAX_PENDING_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
|
11943
11976
|
local RESERVED_ERROR_BYTES = 4096
|
|
11944
11977
|
-- The bridge admits 128 UTF-16 code units; each can require three UTF-8 bytes.
|
|
11945
11978
|
local MAX_REQUEST_ID_BYTES = 128 * 3
|
|
11979
|
+
-- Upper bound on BindToClose waiting for the server to close the socket after
|
|
11980
|
+
-- the unregister frame; it normally closes within milliseconds.
|
|
11981
|
+
local SHUTDOWN_UNREGISTER_WAIT_SECONDS = 2
|
|
11982
|
+
local SHUTDOWN_UNREGISTER_POLL_SECONDS = 0.05
|
|
11946
11983
|
local options
|
|
11947
11984
|
local active = false
|
|
11948
11985
|
local shutdownSuspended = false
|
|
@@ -12985,8 +13022,13 @@ local function refresh()
|
|
|
12985
13022
|
refreshRequested = true
|
|
12986
13023
|
flushRefresh()
|
|
12987
13024
|
end
|
|
12988
|
-
-- EndTest may tear down this VM without an Unloading callback
|
|
12989
|
-
--
|
|
13025
|
+
-- EndTest may tear down this VM without an Unloading callback, and BindToClose
|
|
13026
|
+
-- waits for this function. It unregisters on the socket itself: Studio runs at
|
|
13027
|
+
-- most five plugin HTTP requests at a time across the whole process, with no
|
|
13028
|
+
-- time limit, so a /disconnect queued behind five that never finish held
|
|
13029
|
+
-- shutdown until Studio's 30 s deadline. Closing right after Send can drop the
|
|
13030
|
+
-- frame, so it waits briefly for the server to close the socket first. A
|
|
13031
|
+
-- failed EndTest can resume and registers again.
|
|
12990
13032
|
local function suspendForShutdown()
|
|
12991
13033
|
local currentOptions = options
|
|
12992
13034
|
if not active or currentOptions == nil then
|
|
@@ -12998,9 +13040,34 @@ local function suspendForShutdown()
|
|
|
12998
13040
|
cancelTransportWork()
|
|
12999
13041
|
refreshRequested = false
|
|
13000
13042
|
reconnectAttempt = 0
|
|
13043
|
+
local client = socketClient
|
|
13044
|
+
local unregistered = false
|
|
13045
|
+
if client ~= nil and socketOpen then
|
|
13046
|
+
local serverClosed = false
|
|
13047
|
+
local closedConnection = client.Closed:Connect(function()
|
|
13048
|
+
serverClosed = true
|
|
13049
|
+
end)
|
|
13050
|
+
local sent = pcall(function()
|
|
13051
|
+
return client:Send(HttpService:JSONEncode({
|
|
13052
|
+
kind = "disconnect",
|
|
13053
|
+
}))
|
|
13054
|
+
end)
|
|
13055
|
+
unregistered = sent
|
|
13056
|
+
local deadline = os.clock() + SHUTDOWN_UNREGISTER_WAIT_SECONDS
|
|
13057
|
+
while sent and not serverClosed and os.clock() < deadline do
|
|
13058
|
+
task.wait(SHUTDOWN_UNREGISTER_POLL_SECONDS)
|
|
13059
|
+
end
|
|
13060
|
+
closedConnection:Disconnect()
|
|
13061
|
+
end
|
|
13001
13062
|
closeCurrentSocket()
|
|
13002
13063
|
cachedReady = nil
|
|
13003
|
-
|
|
13064
|
+
-- Without an open socket, unregister over HTTP on another thread rather
|
|
13065
|
+
-- than wait; the server also drops a peer whose socket stays closed.
|
|
13066
|
+
if not unregistered then
|
|
13067
|
+
task.spawn(function()
|
|
13068
|
+
return disconnectSession(currentOptions)
|
|
13069
|
+
end)
|
|
13070
|
+
end
|
|
13004
13071
|
end
|
|
13005
13072
|
local function resumeAfterShutdownFailure()
|
|
13006
13073
|
if not shutdownSuspended or options == nil then
|