@chrrxs/robloxstudio-mcp 3.1.1 → 3.1.2

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.
@@ -16,7 +16,7 @@ local cleanupEditBridgeArtifacts = _EvalBridges.cleanupEditBridgeArtifacts
16
16
  local ensureRuntimeBridgeInstalled = _EvalBridges.ensureRuntimeBridgeInstalled
17
17
  local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
18
18
  local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
19
- local StudioEventStream = TS.import(script, script, "modules", "StudioEventStream")
19
+ local StudioWebSocket = TS.import(script, script, "modules", "StudioWebSocket")
20
20
  local BreakpointHandlers = TS.import(script, script, "modules", "handlers", "BreakpointHandlers")
21
21
  local RenderMonitor = TS.import(script, script, "modules", "RenderMonitor")
22
22
  -- Track render-loop liveness so input/screenshot tools can report "window
@@ -54,11 +54,11 @@ applyRememberedServerUrl()
54
54
  -- Play DataModels do not reliably fire Plugin.Unloading before Studio tears
55
55
  -- down their VM. Close the persistent WebStreamClient while BindToClose still
56
56
  -- gives this server peer a live execution context; otherwise each play cycle
57
- -- can retain one native event stream until Studio itself is restarted. The
58
- -- transport releases that native stream before yielding to unregister the
57
+ -- can retain one native WebSocket until Studio itself is restarted. The
58
+ -- transport releases that native socket before yielding to unregister the
59
59
  -- logical server peer.
60
60
  if startupRole == "server" then
61
- game:BindToClose(StudioEventStream.suspendForShutdown)
61
+ game:BindToClose(StudioWebSocket.suspendForShutdown)
62
62
  end
63
63
  UI.init(plugin)
64
64
  local elements = UI.getElements()
@@ -148,8 +148,8 @@ local function autoActivatePeer()
148
148
  -- legal, so the stop-play monitor lives here. It consumes tokenized
149
149
  -- stop requests from plugin settings and acknowledges EndTest results.
150
150
  StopPlayMonitor.startMonitor({
151
- beforeEndTest = StudioEventStream.suspendForShutdown,
152
- afterEndTestFailure = StudioEventStream.resumeAfterShutdownFailure,
151
+ beforeEndTest = StudioWebSocket.suspendForShutdown,
152
+ afterEndTestFailure = StudioWebSocket.resumeAfterShutdownFailure,
153
153
  })
154
154
  elseif role == "client" then
155
155
  ClientBroker.setupClientBroker()
@@ -952,7 +952,7 @@ local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRu
952
952
  local ClientBroker = TS.import(script, script.Parent, "ClientBroker")
953
953
  local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
954
954
  local PluginSession = TS.import(script, script.Parent, "PluginSession")
955
- local StudioEventStream = TS.import(script, script.Parent, "StudioEventStream")
955
+ local StudioWebSocket = TS.import(script, script.Parent, "StudioWebSocket")
956
956
  local assignedRole
957
957
  local lastReadyPlaceKey
958
958
  local initialRole = PluginSession.getRole()
@@ -1028,7 +1028,7 @@ local function getConnectionStatus()
1028
1028
  end
1029
1029
  return "connecting"
1030
1030
  end
1031
- local function dispatchStreamRequest(request, context)
1031
+ local function dispatchSocketRequest(request, context)
1032
1032
  if request.peerId ~= PluginSession.peerId then
1033
1033
  return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data)
1034
1034
  end
@@ -1123,7 +1123,7 @@ local function ensureIdentityWatchers()
1123
1123
  end)
1124
1124
  if signalOk and signal then
1125
1125
  nameChangeConn = signal:Connect(function()
1126
- return StudioEventStream.refresh()
1126
+ return StudioWebSocket.refresh()
1127
1127
  end)
1128
1128
  end
1129
1129
  end
@@ -1135,7 +1135,7 @@ local function ensureIdentityWatchers()
1135
1135
  placeIdChangeConn = signal:Connect(function()
1136
1136
  PluginSession.invalidatePlaceName()
1137
1137
  lastReadyPlaceKey = PluginSession.getPlaceKey()
1138
- StudioEventStream.refresh()
1138
+ StudioWebSocket.refresh()
1139
1139
  end)
1140
1140
  end
1141
1141
  end
@@ -1176,9 +1176,9 @@ local function activatePlugin()
1176
1176
  ClientBroker.setServerUrl(conn.serverUrl)
1177
1177
  lastReadyPlaceKey = PluginSession.getPlaceKey()
1178
1178
  UI.updateUIState()
1179
- StudioEventStream.start({
1179
+ StudioWebSocket.start({
1180
1180
  serverUrl = conn.serverUrl,
1181
- dispatchRequest = dispatchStreamRequest,
1181
+ dispatchRequest = dispatchSocketRequest,
1182
1182
  onStatus = handleStatus,
1183
1183
  onHeartbeat = handleHeartbeat,
1184
1184
  onReady = handleReady,
@@ -1195,7 +1195,7 @@ local function activatePlugin()
1195
1195
  if lastReadyPlaceKey ~= nil and currentPlaceKey ~= lastReadyPlaceKey then
1196
1196
  lastReadyPlaceKey = currentPlaceKey
1197
1197
  PluginSession.invalidatePlaceName()
1198
- StudioEventStream.refresh()
1198
+ StudioWebSocket.refresh()
1199
1199
  end
1200
1200
  end)
1201
1201
  end
@@ -1213,7 +1213,7 @@ function deactivatePlugin()
1213
1213
  conn.lastHttpOk = false
1214
1214
  conn.lastMcpOk = false
1215
1215
  conn.mcpWaitStartTime = nil
1216
- StudioEventStream.stop()
1216
+ StudioWebSocket.stop()
1217
1217
  disconnectIdentityWatchers()
1218
1218
  if initialRole == "server" then
1219
1219
  ClientBroker.disconnectAllProxies()
@@ -1472,9 +1472,9 @@ local function computeBridgeStamp()
1472
1472
  for i = 1, #combined do
1473
1473
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1474
1474
  end
1475
- -- "3.1.1" is replaced with the package version at package time
1475
+ -- "3.1.2" is replaced with the package version at package time
1476
1476
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1477
- return `{tostring(h)}-3.1.1`
1477
+ return `{tostring(h)}-3.1.2`
1478
1478
  end
1479
1479
  local BRIDGE_STAMP = computeBridgeStamp()
1480
1480
  local function setSource(scriptInst, source)
@@ -3682,16 +3682,33 @@ end
3682
3682
  -- camera's own field of view so any subject fills a similar share of frame.
3683
3683
  local function focusViewport(requestData)
3684
3684
  local instancePath = requestData.path
3685
- if not (instancePath ~= "" and instancePath) then
3686
- return {
3687
- error = "path is required (the instance to frame)",
3688
- }
3689
- end
3690
- local instance = getInstanceByPath(instancePath)
3691
- if not instance then
3692
- return {
3693
- error = `Instance not found: {instancePath}`,
3694
- }
3685
+ local instance
3686
+ if instancePath == nil then
3687
+ local selection = Selection:Get()
3688
+ if #selection == 0 then
3689
+ return {
3690
+ error = "No objects selected. Select a BasePart or Model, or provide path.",
3691
+ }
3692
+ end
3693
+ if #selection ~= 1 then
3694
+ return {
3695
+ error = "Select exactly one BasePart or Model to frame, or provide path.",
3696
+ }
3697
+ end
3698
+ instance = selection[1]
3699
+ else
3700
+ if not (type(instancePath) == "string") or instancePath == "" then
3701
+ return {
3702
+ error = "path must be a non-empty instance path when provided",
3703
+ }
3704
+ end
3705
+ local resolved = getInstanceByPath(instancePath)
3706
+ if not resolved then
3707
+ return {
3708
+ error = `Instance not found: {instancePath}`,
3709
+ }
3710
+ end
3711
+ instance = resolved
3695
3712
  end
3696
3713
  local workspace = game:GetService("Workspace")
3697
3714
  local camera = workspace.CurrentCamera
@@ -3722,6 +3739,7 @@ local function focusViewport(requestData)
3722
3739
  error = "angleY must be between -89 and 89",
3723
3740
  }
3724
3741
  end
3742
+ local originalCameraType = camera.CameraType
3725
3743
  local ok, err = pcall(function()
3726
3744
  local boundsCF
3727
3745
  local boundsSize
@@ -3765,6 +3783,14 @@ local function focusViewport(requestData)
3765
3783
  end
3766
3784
  camera.Focus = CFrame.new(center)
3767
3785
  end)
3786
+ local restored, restoreError = pcall(function()
3787
+ camera.CameraType = originalCameraType
3788
+ end)
3789
+ if not restored then
3790
+ return {
3791
+ error = `{if ok then "focus completed" else `focus failed: {tostring(err)}`}; failed to restore camera type: {tostring(restoreError)}`,
3792
+ }
3793
+ end
3768
3794
  if not ok then
3769
3795
  return {
3770
3796
  error = `focus failed: {tostring(err)}`,
@@ -5569,6 +5595,8 @@ local convertPropertyValue = _binding.convertPropertyValue
5569
5595
  local _binding_1 = Recording
5570
5596
  local beginRecording = _binding_1.beginRecording
5571
5597
  local finishRecording = _binding_1.finishRecording
5598
+ -- Native StringValue rejects UTF-8 strings of 200000 bytes or more.
5599
+ local STRING_VALUE_MAX_BYTES = 199999
5572
5600
  local function setProperties(requestData)
5573
5601
  local instancePath = requestData.instancePath
5574
5602
  local properties = requestData.properties
@@ -5616,15 +5644,28 @@ local function setProperties(requestData)
5616
5644
  table.insert(results, _arg0)
5617
5645
  else
5618
5646
  failureCount += 1
5619
- local _arg0 = {
5647
+ local failure = {
5620
5648
  property = propName,
5621
5649
  success = false,
5622
5650
  error = tostring(err),
5623
5651
  }
5624
- table.insert(results, _arg0)
5652
+ if instance:IsA("StringValue") and propName == "Value" and type(propValue) == "string" then
5653
+ local _object = table.clone(failure)
5654
+ setmetatable(_object, nil)
5655
+ _object.details = {
5656
+ stage = "property_write",
5657
+ bytes = #propValue,
5658
+ limitBytes = STRING_VALUE_MAX_BYTES,
5659
+ }
5660
+ table.insert(results, _object)
5661
+ else
5662
+ table.insert(results, failure)
5663
+ end
5625
5664
  end
5626
5665
  end
5627
- finishRecording(recordingId, successCount > 0)
5666
+ -- This tool is not a rollback transaction. Cancel can undo earlier unrecorded
5667
+ -- execute_luau edits as well, even when every assignment in this call failed.
5668
+ finishRecording(recordingId, true)
5628
5669
  return {
5629
5670
  instancePath = instancePath,
5630
5671
  summary = {
@@ -8794,13 +8835,34 @@ local ServerStorage = _services.ServerStorage
8794
8835
  local State = TS.import(script, script.Parent, "State")
8795
8836
  local PeerRole = TS.import(script, script.Parent, "PeerRole")
8796
8837
  local TopologyId = TS.import(script, script.Parent, "TopologyId")
8838
+ local CoreGui = game:GetService("CoreGui")
8797
8839
  local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
8798
8840
  local TOPOLOGY_MODE_ATTRIBUTE = "__MCPTopologyMode"
8799
8841
  local TOPOLOGY_INSTANCE_ID_ATTRIBUTE = "__MCPTopologyInstanceId"
8800
8842
  local TOPOLOGY_GROUP_ID_ATTRIBUTE = "__MCPTopologyGroupId"
8801
8843
  local TOPOLOGY_TOKEN_ATTRIBUTE = "__MCPTopologyToken"
8844
+ local SESSION_IDENTITY_NAME = "__MCPSessionIdentity"
8802
8845
  local peerId = TopologyId.createPeerId()
8803
- local processInstanceId = TopologyId.currentProcessInstanceId()
8846
+ local isEditSession = PeerRole.detect() == "edit"
8847
+ local existingSessionIdentity = CoreGui:FindFirstChild(SESSION_IDENTITY_NAME)
8848
+ local sessionInstanceId
8849
+ local createdSessionIdentity = false
8850
+ if existingSessionIdentity ~= nil and existingSessionIdentity:IsA("StringValue") and not existingSessionIdentity.Archivable and (string.match(existingSessionIdentity.Value, "^instance:[0-9a-z][0-9a-z][0-9a-z]%-[0-9a-z][0-9a-z][0-9a-z]$")) ~= nil then
8851
+ sessionInstanceId = existingSessionIdentity.Value
8852
+ else
8853
+ local _result = existingSessionIdentity
8854
+ if _result ~= nil then
8855
+ _result:Destroy()
8856
+ end
8857
+ sessionInstanceId = TopologyId.createInstanceId()
8858
+ local sessionIdentity = Instance.new("StringValue")
8859
+ sessionIdentity.Name = SESSION_IDENTITY_NAME
8860
+ sessionIdentity.Value = sessionInstanceId
8861
+ sessionIdentity.Archivable = false
8862
+ sessionIdentity.Parent = CoreGui
8863
+ createdSessionIdentity = true
8864
+ end
8865
+ local inheritedInstanceId
8804
8866
  local cachedPlaceName
8805
8867
  local cachedPlaceNamePlaceId
8806
8868
  local function getMarkerMode()
@@ -8808,13 +8870,17 @@ local function getMarkerMode()
8808
8870
  return if mode == "shared" or mode == "multiplayer" then mode else nil
8809
8871
  end
8810
8872
  local function getInstanceId()
8811
- if getMarkerMode() == "shared" then
8873
+ if inheritedInstanceId ~= nil then
8874
+ return inheritedInstanceId
8875
+ end
8876
+ if not isEditSession and getMarkerMode() == "shared" then
8812
8877
  local sharedInstanceId = ReplicatedStorage:GetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE)
8813
8878
  if type(sharedInstanceId) == "string" and sharedInstanceId ~= "" then
8879
+ inheritedInstanceId = sharedInstanceId
8814
8880
  return sharedInstanceId
8815
8881
  end
8816
8882
  end
8817
- return processInstanceId
8883
+ return sessionInstanceId
8818
8884
  end
8819
8885
  local function getMultiplayerGroupId()
8820
8886
  if getMarkerMode() ~= "multiplayer" then
@@ -8847,7 +8913,7 @@ local function setTopologyMarker(mode, instanceId, groupId)
8847
8913
  return token
8848
8914
  end
8849
8915
  local function prepareSharedTopology()
8850
- return setTopologyMarker("shared", processInstanceId, nil)
8916
+ return setTopologyMarker("shared", sessionInstanceId, nil)
8851
8917
  end
8852
8918
  local function prepareMultiplayerTopology(groupId)
8853
8919
  return setTopologyMarker("multiplayer", nil, groupId)
@@ -8856,6 +8922,10 @@ local function clearTopologyMarker(token)
8856
8922
  if ReplicatedStorage:GetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE) ~= token then
8857
8923
  return nil
8858
8924
  end
8925
+ if isEditSession then
8926
+ prepareSharedTopology()
8927
+ return nil
8928
+ end
8859
8929
  ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
8860
8930
  ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, nil)
8861
8931
  ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, nil)
@@ -8916,6 +8986,15 @@ local function createReadyPayload(readyPeerId, role, instanceId, multiplayerGrou
8916
8986
  timestamp = tick(),
8917
8987
  }
8918
8988
  end
8989
+ -- CoreGui survives plugin reloads without saving or replicating this identity.
8990
+ -- Preserve active test markers on reload; fresh edits replace saved place markers.
8991
+ if isEditSession then
8992
+ if createdSessionIdentity or getMarkerMode() == nil then
8993
+ prepareSharedTopology()
8994
+ end
8995
+ else
8996
+ getInstanceId()
8997
+ end
8919
8998
  return {
8920
8999
  peerId = peerId,
8921
9000
  getInstanceId = getInstanceId,
@@ -9736,7 +9815,7 @@ return {
9736
9815
  <Properties>
9737
9816
  <string name="Name">State</string>
9738
9817
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9739
- local CURRENT_VERSION = "3.1.1"
9818
+ local CURRENT_VERSION = "3.1.2"
9740
9819
  local PLUGIN_VARIANT = "main"
9741
9820
  local BASE_PORT = 58741
9742
9821
  local function createConnection(port)
@@ -10001,39 +10080,55 @@ return {
10001
10080
  </Item>
10002
10081
  <Item class="ModuleScript" referent="36">
10003
10082
  <Properties>
10004
- <string name="Name">StudioEventStream</string>
10083
+ <string name="Name">StudioWebSocket</string>
10005
10084
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10006
10085
  local TS = require(script.Parent.Parent.include.RuntimeLib)
10007
10086
  local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
10008
10087
  local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
10009
10088
  local PluginSession = TS.import(script, script.Parent, "PluginSession")
10010
- local INITIAL_RECONNECT_DELAY_SECONDS = 0.5
10011
- local MAX_RECONNECT_DELAY_SECONDS = 5
10012
- local INITIAL_RESPONSE_RETRY_DELAY_SECONDS = 0.5
10013
- local MAX_RESPONSE_RETRY_DELAY_SECONDS = 5
10014
- local MAX_TERMINAL_RESPONSES = 256
10015
- local STREAM_SILENCE_TIMEOUT_SECONDS = 20
10016
- -- The bridge accepts request bodies up to 50 MiB; leave room for its envelope.
10017
- local MAX_STREAM_FRAME_BYTES = 64 * 1024 * 1024
10089
+ local PROTOCOL_VERSION = 1
10090
+ local INITIAL_RETRY_DELAY_SECONDS = 0.5
10091
+ local MAX_RETRY_DELAY_SECONDS = 5
10092
+ local SOCKET_SILENCE_TIMEOUT_SECONDS = 20
10093
+ local RESPONSE_RETENTION_SECONDS = 5 * 60
10094
+ local RESPONSE_ACK_TIMEOUT_SECONDS = 120
10095
+ local MAX_TERMINAL_RESPONSES = 32768
10096
+ local MAX_ACTIVE_RESPONSES = 128
10097
+ local MAX_ADMISSION_REJECTIONS = 128
10098
+ local MAX_FRAME_BYTES = 64 * 1024 * 1024
10099
+ local MAX_PENDING_RESPONSE_BYTES = 64 * 1024 * 1024
10100
+ local RESERVED_ERROR_BYTES = 4096
10101
+ -- The bridge admits 128 UTF-16 code units; each can require three UTF-8 bytes.
10102
+ local MAX_REQUEST_ID_BYTES = 128 * 3
10018
10103
  local options
10019
10104
  local active = false
10020
10105
  local shutdownSuspended = false
10021
10106
  local generation = 0
10022
10107
  local reconnectAttempt = 0
10023
- local streamClient
10024
- local streamConnections = {}
10108
+ local socketClient
10109
+ local socketOpen = false
10110
+ local socketConnections = {}
10025
10111
  local lastValidEventAt = 0
10112
+ local cachedReady
10113
+ local readyRefreshPending = false
10114
+ local pendingResponseBytes = 0
10115
+ local inFlightRequestBytes = 0
10116
+ local pendingRejectionCount = 0
10026
10117
  local inFlightRequests = {}
10027
10118
  local pendingResponses = {}
10028
10119
  local terminalResponseIds = {}
10029
10120
  local terminalResponseOrder = {}
10121
+ local terminalHead = 0
10122
+ local terminalCount = 0
10030
10123
  local readyFailureLogKeys = {}
10031
- local streamLineParts = {}
10032
- local streamDataLines = {}
10033
- local streamFrameBytes = 0
10034
- local streamLineHasContent = false
10035
- local streamDiscardingFrame = false
10036
- local streamSkipLf = false
10124
+ local function validRequestId(value)
10125
+ local _value = value
10126
+ local _condition = type(_value) == "string"
10127
+ if _condition then
10128
+ _condition = #value > 0 and #value <= MAX_REQUEST_ID_BYTES
10129
+ end
10130
+ return _condition
10131
+ end
10037
10132
  local function decodeMessage(payload)
10038
10133
  local decodeOk, decoded = pcall(function()
10039
10134
  return HttpService:JSONDecode(payload)
@@ -10078,13 +10173,21 @@ local function decodeMessage(payload)
10078
10173
  _object[_left_2] = if type(_pluginVariant) == "string" then envelope.pluginVariant else nil
10079
10174
  return _object
10080
10175
  end
10081
- if envelope.kind == "cancel" then
10082
- local _requestId = envelope.requestId
10083
- local _condition = not (type(_requestId) == "string")
10084
- if not _condition then
10085
- _condition = (envelope.reason ~= "timeout" and envelope.reason ~= "aborted" and envelope.reason ~= "connection_closed")
10176
+ if not validRequestId(envelope.requestId) then
10177
+ return nil
10178
+ end
10179
+ if envelope.kind == "ack" then
10180
+ if envelope.disposition ~= "accepted" and envelope.disposition ~= "already_settled" and envelope.disposition ~= "unknown" then
10181
+ return nil
10086
10182
  end
10087
- if _condition then
10183
+ return {
10184
+ kind = "ack",
10185
+ requestId = envelope.requestId,
10186
+ disposition = envelope.disposition,
10187
+ }
10188
+ end
10189
+ if envelope.kind == "cancel" then
10190
+ if envelope.reason ~= "timeout" and envelope.reason ~= "aborted" and envelope.reason ~= "connection_closed" then
10088
10191
  return nil
10089
10192
  end
10090
10193
  return {
@@ -10094,23 +10197,19 @@ local function decodeMessage(payload)
10094
10197
  }
10095
10198
  end
10096
10199
  if envelope.kind == "request" then
10097
- local _requestId = envelope.requestId
10098
- local _condition = not (type(_requestId) == "string")
10200
+ local _peerId = envelope.peerId
10201
+ local _condition = not (type(_peerId) == "string")
10099
10202
  if not _condition then
10100
- local _peerId = envelope.peerId
10101
- _condition = not (type(_peerId) == "string")
10203
+ local _target = envelope.target
10204
+ _condition = not (type(_target) == "string")
10102
10205
  if not _condition then
10103
- local _target = envelope.target
10104
- _condition = not (type(_target) == "string")
10206
+ local _endpoint = envelope.endpoint
10207
+ _condition = not (type(_endpoint) == "string")
10105
10208
  if not _condition then
10106
- local _endpoint = envelope.endpoint
10107
- _condition = not (type(_endpoint) == "string")
10209
+ local _remainingMs = envelope.remainingMs
10210
+ _condition = not (type(_remainingMs) == "number")
10108
10211
  if not _condition then
10109
- local _remainingMs = envelope.remainingMs
10110
- _condition = not (type(_remainingMs) == "number")
10111
- if not _condition then
10112
- _condition = envelope.remainingMs < 0
10113
- end
10212
+ _condition = envelope.remainingMs < 0 or envelope.remainingMs ~= envelope.remainingMs or envelope.remainingMs == math.huge
10114
10213
  end
10115
10214
  end
10116
10215
  end
@@ -10118,144 +10217,35 @@ local function decodeMessage(payload)
10118
10217
  if _condition then
10119
10218
  return nil
10120
10219
  end
10121
- local data
10122
- local _data = envelope.data
10123
- if type(_data) == "table" then
10124
- data = envelope.data
10125
- end
10126
- return {
10220
+ local _object = {
10127
10221
  kind = "request",
10128
10222
  requestId = envelope.requestId,
10129
10223
  peerId = envelope.peerId,
10130
10224
  target = envelope.target,
10131
10225
  endpoint = envelope.endpoint,
10132
- data = data,
10133
10226
  remainingMs = envelope.remainingMs,
10134
10227
  }
10228
+ local _left = "data"
10229
+ local _data = envelope.data
10230
+ _object[_left] = if type(_data) == "table" then envelope.data else nil
10231
+ return _object
10135
10232
  end
10136
10233
  return nil
10137
10234
  end
10138
- local function resetStreamFrame()
10139
- streamLineParts = {}
10140
- streamDataLines = {}
10141
- streamFrameBytes = 0
10142
- streamLineHasContent = false
10143
- streamDiscardingFrame = false
10144
- end
10145
- local function finishStreamLine(events)
10146
- if not streamLineHasContent then
10147
- if not streamDiscardingFrame and #streamDataLines > 0 then
10148
- local event = decodeMessage(table.concat(streamDataLines, "\n"))
10149
- if event ~= nil then
10150
- table.insert(events, event)
10151
- end
10152
- end
10153
- resetStreamFrame()
10154
- return nil
10155
- end
10156
- if not streamDiscardingFrame then
10157
- local line = table.concat(streamLineParts, "")
10158
- if string.sub(line, 1, 5) == "data:" then
10159
- local start = if string.sub(line, 6, 6) == " " then 7 else 6
10160
- local _streamDataLines = streamDataLines
10161
- local _arg0 = string.sub(line, start)
10162
- table.insert(_streamDataLines, _arg0)
10163
- elseif line == "data" then
10164
- table.insert(streamDataLines, "")
10165
- end
10235
+ local function closeCurrentSocket()
10236
+ local current = socketClient
10237
+ socketClient = nil
10238
+ socketOpen = false
10239
+ for _, connection in socketConnections do
10240
+ connection:Disconnect()
10166
10241
  end
10167
- streamLineParts = {}
10168
- streamLineHasContent = false
10169
- end
10170
- -- MessageReceived may coalesce frames (notably server/client fanout) or split
10171
- -- them across callbacks. Callback boundaries are not SSE event boundaries.
10172
- local function decodeMessages(message)
10173
- local events = {}
10174
- local messageBytes = #message
10175
- if messageBytes == 0 then
10176
- return events
10177
- end
10178
- -- Some Studio versions deliver the decoded SSE data or a complete data line
10179
- -- without delimiters. Only recognize those forms with no unfinished frame;
10180
- -- once framing starts, callback boundaries never terminate partial data.
10181
- if streamFrameBytes == 0 and not streamDiscardingFrame and messageBytes <= MAX_STREAM_FRAME_BYTES then
10182
- local first = string.sub(message, 1, 1)
10183
- if first == "{" or first == " " or first == "\t" or first == "\r" or first == "\n" then
10184
- local event = decodeMessage(message)
10185
- if event ~= nil then
10186
- streamSkipLf = false
10187
- table.insert(events, event)
10188
- return events
10189
- end
10190
- if first == "{" then
10191
- return events
10192
- end
10193
- end
10194
- if string.sub(message, 1, 5) == "data:" and (string.find(message, "[\r\n]")) == nil then
10195
- local event = decodeMessage(string.sub(message, 6))
10196
- if event ~= nil then
10197
- streamSkipLf = false
10198
- table.insert(events, event)
10199
- return events
10200
- end
10242
+ socketConnections = {}
10243
+ for _, entry in pendingResponses do
10244
+ if entry.ackTimer ~= nil then
10245
+ task.cancel(entry.ackTimer)
10201
10246
  end
10247
+ entry.ackTimer = nil
10202
10248
  end
10203
- local offset = 1
10204
- while offset <= messageBytes do
10205
- if streamSkipLf then
10206
- streamSkipLf = false
10207
- local _message = message
10208
- local _offset = offset
10209
- local _offset_1 = offset
10210
- if string.sub(_message, _offset, _offset_1) == "\n" then
10211
- offset += 1
10212
- continue
10213
- end
10214
- end
10215
- local _message = message
10216
- local _offset = offset
10217
- local newline = string.find(_message, "[\r\n]", _offset)
10218
- local lineEnd = if newline == nil then messageBytes + 1 else newline
10219
- local partBytes = lineEnd - offset
10220
- if partBytes > 0 then
10221
- streamLineHasContent = true
10222
- end
10223
- if not streamDiscardingFrame then
10224
- streamFrameBytes += partBytes + (if newline == nil then 0 else 1)
10225
- if streamFrameBytes > MAX_STREAM_FRAME_BYTES then
10226
- -- Keep scanning delimiters but retain no oversized frame bytes.
10227
- streamDiscardingFrame = true
10228
- streamLineParts = {}
10229
- streamDataLines = {}
10230
- elseif partBytes > 0 then
10231
- -- Join only on a line boundary, not every callback (large scripts
10232
- -- can arrive in many fragments).
10233
- local _streamLineParts = streamLineParts
10234
- local _message_1 = message
10235
- local _offset_1 = offset
10236
- local _arg1 = lineEnd - 1
10237
- local _arg0 = string.sub(_message_1, _offset_1, _arg1)
10238
- table.insert(_streamLineParts, _arg0)
10239
- end
10240
- end
10241
- if newline == nil then
10242
- break
10243
- end
10244
- finishStreamLine(events)
10245
- streamSkipLf = string.sub(message, newline, newline) == "\r"
10246
- offset = newline + 1
10247
- end
10248
- return events
10249
- end
10250
- local function closeCurrentStream()
10251
- local current = streamClient
10252
- streamClient = nil
10253
- resetStreamFrame()
10254
- streamSkipLf = false
10255
- for _, connection in streamConnections do
10256
- connection:Disconnect()
10257
- end
10258
- streamConnections = {}
10259
10249
  if current ~= nil then
10260
10250
  pcall(function()
10261
10251
  return current:Close()
@@ -10276,42 +10266,39 @@ local function disconnectSession(currentOptions)
10276
10266
  })
10277
10267
  end)
10278
10268
  end
10279
- local function responseRetryDelay(attempt)
10280
- return math.min(INITIAL_RESPONSE_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RESPONSE_RETRY_DELAY_SECONDS)
10269
+ local function retryDelay(attempt)
10270
+ return math.min(INITIAL_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RETRY_DELAY_SECONDS)
10281
10271
  end
10282
- local function parseResponseDisposition(success, body)
10283
- local decodeOk, decoded = pcall(function()
10284
- return HttpService:JSONDecode(body)
10285
- end)
10286
- if not decodeOk or not (type(decoded) == "table") then
10287
- return nil
10288
- end
10289
- local acknowledgement = decoded
10290
- local disposition = acknowledgement.disposition
10291
- if disposition == "accepted" or disposition == "already_settled" or disposition == "unknown" then
10292
- return disposition
10293
- end
10294
- if success and acknowledgement.success == true and disposition == nil then
10295
- return "accepted"
10272
+ local function pruneTerminalResponses()
10273
+ local now = os.clock()
10274
+ while terminalCount > 0 do
10275
+ local oldest = terminalResponseOrder[terminalHead + 1]
10276
+ if oldest == nil or oldest.expiresAt > now then
10277
+ break
10278
+ end
10279
+ local _requestId = oldest.requestId
10280
+ terminalResponseIds[_requestId] = nil
10281
+ terminalResponseOrder[terminalHead + 1] = nil
10282
+ terminalHead = (terminalHead + 1) % MAX_TERMINAL_RESPONSES
10283
+ terminalCount -= 1
10296
10284
  end
10297
- return nil
10298
10285
  end
10299
10286
  local function rememberTerminalResponse(requestId)
10287
+ pruneTerminalResponses()
10300
10288
  local _requestId = requestId
10301
10289
  if terminalResponseIds[_requestId] ~= nil then
10302
10290
  return nil
10303
10291
  end
10292
+ -- Admission reserves this tombstone before any side effects can execute.
10293
+ terminalResponseOrder[(terminalHead + terminalCount) % MAX_TERMINAL_RESPONSES + 1] = {
10294
+ requestId = requestId,
10295
+ expiresAt = os.clock() + RESPONSE_RETENTION_SECONDS,
10296
+ }
10297
+ terminalCount += 1
10304
10298
  local _requestId_1 = requestId
10305
10299
  terminalResponseIds[_requestId_1] = true
10306
- local _requestId_2 = requestId
10307
- table.insert(terminalResponseOrder, _requestId_2)
10308
- while #terminalResponseOrder > MAX_TERMINAL_RESPONSES do
10309
- local oldest = table.remove(terminalResponseOrder, 1)
10310
- if oldest ~= nil then
10311
- terminalResponseIds[oldest] = nil
10312
- end
10313
- end
10314
10300
  end
10301
+ local scheduleReconnect
10315
10302
  local function settleResponse(requestId, entry, disposition)
10316
10303
  local _requestId = requestId
10317
10304
  if pendingResponses[_requestId] ~= entry then
@@ -10319,66 +10306,145 @@ local function settleResponse(requestId, entry, disposition)
10319
10306
  end
10320
10307
  local _requestId_1 = requestId
10321
10308
  pendingResponses[_requestId_1] = nil
10309
+ if entry.admissionRejection then
10310
+ pendingRejectionCount -= 1
10311
+ else
10312
+ pendingResponseBytes -= #entry.body
10313
+ end
10314
+ if entry.ackTimer ~= nil then
10315
+ task.cancel(entry.ackTimer)
10316
+ end
10317
+ if entry.expiryTimer ~= nil then
10318
+ task.cancel(entry.expiryTimer)
10319
+ end
10320
+ entry.ackTimer = nil
10321
+ entry.expiryTimer = nil
10322
+ -- Release a queued native frame before making its capacity available again.
10323
+ if disposition == "expired" and socketOpen and entry.sentGeneration == generation then
10324
+ scheduleReconnect(generation, `WebSocket response {requestId} expired without acknowledgement`)
10325
+ end
10326
+ entry.body = ""
10322
10327
  rememberTerminalResponse(requestId)
10323
- if disposition == "unknown" then
10324
- warn(`[robloxstudio-mcp] Server no longer recognizes response {requestId}; dropping stored result`)
10328
+ if disposition == "unknown" or disposition == "expired" then
10329
+ warn(`[robloxstudio-mcp] Response {requestId} outcome unknown ({disposition}); stored result released, mutation must not be replayed`)
10325
10330
  end
10326
10331
  end
10327
- local function postPendingResponse(requestId, entry)
10328
- local currentOptions = options
10329
- local _condition = not active or currentOptions == nil
10332
+ local function sendProgress(requestId, progress)
10333
+ local client = socketClient
10334
+ local _condition = not active or not socketOpen or client == nil
10330
10335
  if not _condition then
10331
- local _requestId = requestId
10332
- _condition = pendingResponses[_requestId] ~= entry
10336
+ local _result = options
10337
+ if _result ~= nil then
10338
+ _result = _result.serverUrl
10339
+ end
10340
+ _condition = _result ~= progress.serverUrl
10333
10341
  if not _condition then
10334
- _condition = entry.posting
10342
+ _condition = progress.phase == nil
10335
10343
  end
10336
10344
  end
10337
10345
  if _condition then
10338
10346
  return nil
10339
10347
  end
10340
- entry.posting = true
10341
- entry.retryToken += 1
10342
- task.spawn(function()
10343
- local _condition_1 = not active or options ~= currentOptions
10344
- if not _condition_1 then
10345
- local _requestId = requestId
10346
- _condition_1 = pendingResponses[_requestId] ~= entry
10348
+ if progress.sentGeneration == generation and progress.sentPhase == progress.phase then
10349
+ return nil
10350
+ end
10351
+ local expectedGeneration = generation
10352
+ progress.sentGeneration = expectedGeneration
10353
+ progress.sentPhase = progress.phase
10354
+ local sent = pcall(function()
10355
+ return client:Send(HttpService:JSONEncode({
10356
+ kind = "progress",
10357
+ requestId = requestId,
10358
+ phase = progress.phase,
10359
+ outcome = progress.outcome,
10360
+ }))
10361
+ end)
10362
+ if not sent then
10363
+ scheduleReconnect(expectedGeneration, "WebSocket progress send failed; current observation retained for reconnect")
10364
+ end
10365
+ end
10366
+ local function completeProgress(requestId, progress, outcome)
10367
+ progress.phase = "response_delivery"
10368
+ progress.outcome = outcome
10369
+ sendProgress(requestId, progress)
10370
+ end
10371
+ local function handlerOutcome(response)
10372
+ local _response = response
10373
+ if not (type(_response) == "table") then
10374
+ return "success"
10375
+ end
10376
+ local result = response
10377
+ if result.error ~= nil or result.success == false or result.ok == false then
10378
+ return "error"
10379
+ end
10380
+ local _summary = result.summary
10381
+ if type(_summary) == "table" then
10382
+ local summary = result.summary
10383
+ local _failed = summary.failed
10384
+ local _condition = type(_failed) == "number"
10385
+ if _condition then
10386
+ _condition = summary.failed > 0
10347
10387
  end
10348
- if _condition_1 then
10349
- entry.posting = false
10350
- return nil
10388
+ if _condition then
10389
+ return "error"
10351
10390
  end
10352
- local responseUrl = `{currentOptions.serverUrl}/response`
10353
- local requestOk, requestResult = pcall(function()
10354
- return HttpService:RequestAsync({
10355
- Url = responseUrl,
10356
- Method = "POST",
10357
- Headers = {
10358
- ["Content-Type"] = "application/json",
10359
- },
10360
- Body = entry.body,
10361
- })
10362
- end)
10363
- local _requestId = requestId
10364
- if pendingResponses[_requestId] ~= entry then
10365
- return nil
10391
+ end
10392
+ return "success"
10393
+ end
10394
+ local function sendPendingResponse(requestId, entry)
10395
+ sendProgress(requestId, entry.progress)
10396
+ local client = socketClient
10397
+ local _condition = not active or not socketOpen or client == nil
10398
+ if not _condition then
10399
+ local _result = options
10400
+ if _result ~= nil then
10401
+ _result = _result.serverUrl
10366
10402
  end
10367
- entry.posting = false
10368
- local failure
10369
- if not requestOk then
10370
- failure = HttpDiagnostics.formatRequestFailure(responseUrl, false, requestResult)
10371
- else
10372
- local disposition = parseResponseDisposition(requestResult.Success, requestResult.Body)
10373
- if disposition ~= nil then
10374
- settleResponse(requestId, entry, disposition)
10375
- return nil
10376
- end
10377
- failure = if requestResult.Success then "Invalid /response acknowledgement" else HttpDiagnostics.formatRequestFailure(responseUrl, true, requestResult)
10403
+ _condition = _result ~= entry.serverUrl
10404
+ if not _condition then
10405
+ local _requestId = requestId
10406
+ _condition = pendingResponses[_requestId] ~= entry
10378
10407
  end
10379
- warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {failure}`)
10380
- entry.retryAttempt += 1
10381
- local _condition_2 = not active
10408
+ end
10409
+ if _condition then
10410
+ return nil
10411
+ end
10412
+ if os.clock() >= entry.expiresAt then
10413
+ settleResponse(requestId, entry, "expired")
10414
+ return nil
10415
+ end
10416
+ if entry.sentGeneration == generation then
10417
+ return nil
10418
+ end
10419
+ local expectedGeneration = generation
10420
+ entry.sentGeneration = expectedGeneration
10421
+ local sendOk, sendError = pcall(function()
10422
+ return client:Send(entry.body)
10423
+ end)
10424
+ if not sendOk then
10425
+ warn(`[robloxstudio-mcp] WebSocket response send failed for {requestId} ({#entry.body} bytes, stage=socket_send): {tostring(sendError)}`)
10426
+ scheduleReconnect(expectedGeneration, "WebSocket response send failed; retained for reconnect")
10427
+ return nil
10428
+ end
10429
+ local _requestId = requestId
10430
+ local _condition_1 = pendingResponses[_requestId] ~= entry
10431
+ if not _condition_1 then
10432
+ _condition_1 = generation ~= expectedGeneration or socketClient ~= client
10433
+ end
10434
+ if _condition_1 then
10435
+ return nil
10436
+ end
10437
+ -- Send only queues bytes. A heartbeat does not prove that this upload drained.
10438
+ -- Give large transfers a generous window, growing after reconnect; never put
10439
+ -- a second copy on the same socket, even when an acknowledgement is lost.
10440
+ entry.sendAttempt += 1
10441
+ local ackDelay = RESPONSE_ACK_TIMEOUT_SECONDS * math.pow(2, math.min(entry.sendAttempt - 1, 2))
10442
+ if os.clock() + ackDelay >= entry.expiresAt then
10443
+ return nil
10444
+ end
10445
+ entry.ackTimer = task.delay(ackDelay, function()
10446
+ entry.ackTimer = nil
10447
+ local _condition_2 = generation ~= expectedGeneration
10382
10448
  if not _condition_2 then
10383
10449
  local _requestId_1 = requestId
10384
10450
  _condition_2 = pendingResponses[_requestId_1] ~= entry
@@ -10386,62 +10452,145 @@ local function postPendingResponse(requestId, entry)
10386
10452
  if _condition_2 then
10387
10453
  return nil
10388
10454
  end
10389
- entry.retryToken += 1
10390
- local retryToken = entry.retryToken
10391
- local delay = responseRetryDelay(entry.retryAttempt)
10392
- task.delay(delay, function()
10393
- local _condition_3 = not active
10394
- if not _condition_3 then
10395
- local _requestId_1 = requestId
10396
- _condition_3 = pendingResponses[_requestId_1] ~= entry
10397
- if not _condition_3 then
10398
- _condition_3 = entry.retryToken ~= retryToken
10399
- end
10400
- end
10401
- if _condition_3 then
10402
- return nil
10403
- end
10404
- postPendingResponse(requestId, entry)
10405
- end)
10455
+ scheduleReconnect(expectedGeneration, `WebSocket response {requestId} acknowledgement timed out; retained for reconnect`)
10406
10456
  end)
10407
10457
  end
10408
10458
  local function resumePendingResponses()
10459
+ for requestId, entry in inFlightRequests do
10460
+ sendProgress(requestId, entry.progress)
10461
+ end
10409
10462
  for requestId, entry in pendingResponses do
10410
- postPendingResponse(requestId, entry)
10463
+ sendPendingResponse(requestId, entry)
10411
10464
  end
10412
10465
  end
10413
- local function encodeResponse(requestId, response)
10466
+ local function encodeError(requestId, detail, executionOutcome)
10414
10467
  local encodeOk, encoded = pcall(function()
10415
10468
  return HttpService:JSONEncode({
10469
+ kind = "response",
10416
10470
  requestId = requestId,
10417
- response = response,
10471
+ executionOutcome = executionOutcome,
10472
+ error = `Request {requestId}: {detail}`,
10418
10473
  })
10419
10474
  end)
10420
- if encodeOk then
10475
+ if encodeOk and #encoded <= RESERVED_ERROR_BYTES then
10421
10476
  return encoded
10422
10477
  end
10423
- warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
10424
10478
  return HttpService:JSONEncode({
10479
+ kind = "response",
10425
10480
  requestId = requestId,
10426
- error = `Plugin response serialization failed: {tostring(encoded)}`,
10481
+ executionOutcome = executionOutcome,
10482
+ error = `Request {requestId}: Plugin error could not be encoded within its reserved response capacity (stage=error_encode); handler outcome={executionOutcome}`,
10427
10483
  })
10428
10484
  end
10485
+ local function encodeResponse(requestId, response, executionOutcome)
10486
+ local encodeOk, encoded = pcall(function()
10487
+ return HttpService:JSONEncode({
10488
+ kind = "response",
10489
+ requestId = requestId,
10490
+ response = response,
10491
+ executionOutcome = executionOutcome,
10492
+ })
10493
+ end)
10494
+ if not encodeOk then
10495
+ return encodeError(requestId, `Plugin response serialization failed (stage=response_encode): {string.sub(tostring(encoded), 1, 512)}`, executionOutcome)
10496
+ end
10497
+ if #encoded > MAX_FRAME_BYTES then
10498
+ return encodeError(requestId, `Plugin response exceeds WebSocket frame limit (stage=response_encode, bytes={#encoded}, maxBytes={MAX_FRAME_BYTES}); execution completed but result cannot be delivered`, executionOutcome)
10499
+ end
10500
+ return encoded
10501
+ end
10502
+ local function retainResponse(requestId, body, serverUrl, progress, admissionRejection)
10503
+ if admissionRejection == nil then
10504
+ admissionRejection = false
10505
+ end
10506
+ -- Admission errors have their own bounded 128 x 4KiB reserve so a full-size
10507
+ -- result never consumes the capacity required to retain a rejection.
10508
+ -- ▼ ReadonlyMap.size ▼
10509
+ local _size = 0
10510
+ for _ in inFlightRequests do
10511
+ _size += 1
10512
+ end
10513
+ -- ▲ ReadonlyMap.size ▲
10514
+ -- ▼ ReadonlyMap.size ▼
10515
+ local _size_1 = 0
10516
+ for _ in pendingResponses do
10517
+ _size_1 += 1
10518
+ end
10519
+ -- ▲ ReadonlyMap.size ▲
10520
+ local reservedBytes = (_size + _size_1 - pendingRejectionCount) * RESERVED_ERROR_BYTES
10521
+ if not admissionRejection and pendingResponseBytes + #body + reservedBytes > MAX_PENDING_RESPONSE_BYTES then
10522
+ body = encodeError(requestId, `Plugin response exceeds retained-result capacity (stage=response_retention, bytes={#body}, retainedBytes={pendingResponseBytes}, maxBytes={MAX_PENDING_RESPONSE_BYTES}); execution completed but result cannot be retained`, progress.outcome or "unknown")
10523
+ end
10524
+ local entry = {
10525
+ body = body,
10526
+ serverUrl = serverUrl,
10527
+ progress = progress,
10528
+ expiresAt = os.clock() + RESPONSE_RETENTION_SECONDS,
10529
+ admissionRejection = admissionRejection,
10530
+ sendAttempt = 0,
10531
+ }
10532
+ local _requestId = requestId
10533
+ pendingResponses[_requestId] = entry
10534
+ if admissionRejection then
10535
+ pendingRejectionCount += 1
10536
+ else
10537
+ pendingResponseBytes += #body
10538
+ end
10539
+ entry.expiryTimer = task.delay(RESPONSE_RETENTION_SECONDS, function()
10540
+ entry.expiryTimer = nil
10541
+ local _requestId_1 = requestId
10542
+ if pendingResponses[_requestId_1] == entry then
10543
+ settleResponse(requestId, entry, "expired")
10544
+ end
10545
+ end)
10546
+ sendPendingResponse(requestId, entry)
10547
+ end
10548
+ local function rejectAdmission(requestId, detail)
10549
+ local currentOptions = options
10550
+ if currentOptions == nil then
10551
+ return nil
10552
+ end
10553
+ local _condition = pendingRejectionCount >= MAX_ADMISSION_REJECTIONS
10554
+ if not _condition then
10555
+ local _exp = terminalCount
10556
+ -- ▼ ReadonlyMap.size ▼
10557
+ local _size = 0
10558
+ for _ in inFlightRequests do
10559
+ _size += 1
10560
+ end
10561
+ -- ▲ ReadonlyMap.size ▲
10562
+ local _exp_1 = _exp + _size
10563
+ -- ▼ ReadonlyMap.size ▼
10564
+ local _size_1 = 0
10565
+ for _ in pendingResponses do
10566
+ _size_1 += 1
10567
+ end
10568
+ -- ▲ ReadonlyMap.size ▲
10569
+ _condition = _exp_1 + _size_1 >= MAX_TERMINAL_RESPONSES
10570
+ end
10571
+ if _condition then
10572
+ -- No execution and no unretained "result". Reconnect drains existing
10573
+ -- outcomes so their acknowledgements can release rejection reservations.
10574
+ scheduleReconnect(generation, "Plugin admission rejection reserve exhausted; execution not started")
10575
+ return nil
10576
+ end
10577
+ local progress = {
10578
+ serverUrl = currentOptions.serverUrl,
10579
+ }
10580
+ completeProgress(requestId, progress, "not_executed")
10581
+ retainResponse(requestId, encodeError(requestId, detail, "not_executed"), currentOptions.serverUrl, progress, true)
10582
+ end
10429
10583
  local function cancelRequest(event)
10430
10584
  local _requestId = event.requestId
10431
10585
  local inFlight = inFlightRequests[_requestId]
10432
10586
  if inFlight ~= nil then
10433
10587
  inFlight.cancelled = true
10434
10588
  end
10435
- local _requestId_1 = event.requestId
10436
- local pending = pendingResponses[_requestId_1]
10437
- if pending ~= nil then
10438
- pending.retryToken += 1
10439
- local _requestId_2 = event.requestId
10440
- pendingResponses[_requestId_2] = nil
10441
- end
10442
- rememberTerminalResponse(event.requestId)
10589
+ -- Cancellation only changes execution context. Completed results still need
10590
+ -- recording, including results produced by already-running side effects.
10443
10591
  end
10444
- local function dispatchRequest(request)
10592
+ local function dispatchRequest(request, requestBytes)
10593
+ pruneTerminalResponses()
10445
10594
  local _requestId = request.requestId
10446
10595
  local _condition = terminalResponseIds[_requestId] ~= nil
10447
10596
  if not _condition then
@@ -10459,8 +10608,28 @@ local function dispatchRequest(request)
10459
10608
  if not active or dispatchOptions == nil then
10460
10609
  return nil
10461
10610
  end
10611
+ -- ▼ ReadonlyMap.size ▼
10612
+ local _size = 0
10613
+ for _ in inFlightRequests do
10614
+ _size += 1
10615
+ end
10616
+ -- ▲ ReadonlyMap.size ▲
10617
+ -- ▼ ReadonlyMap.size ▼
10618
+ local _size_1 = 0
10619
+ for _ in pendingResponses do
10620
+ _size_1 += 1
10621
+ end
10622
+ -- ▲ ReadonlyMap.size ▲
10623
+ local activeCount = _size + _size_1 - pendingRejectionCount
10624
+ if activeCount >= MAX_ACTIVE_RESPONSES or terminalCount + activeCount + MAX_ADMISSION_REJECTIONS >= MAX_TERMINAL_RESPONSES or pendingResponseBytes + (activeCount + 1) * RESERVED_ERROR_BYTES > MAX_PENDING_RESPONSE_BYTES or inFlightRequestBytes + requestBytes > MAX_FRAME_BYTES then
10625
+ rejectAdmission(request.requestId, `Plugin request admission capacity exceeded (stage=request_admission, bytes={requestBytes}, inFlightBytes={inFlightRequestBytes}, activeResponses={activeCount}, maxActiveResponses={MAX_ACTIVE_RESPONSES}, terminalIds={terminalCount}, maxTerminalIds={MAX_TERMINAL_RESPONSES}, retainedBytes={pendingResponseBytes}, maxBytes={MAX_PENDING_RESPONSE_BYTES}); execution not started`)
10626
+ return nil
10627
+ end
10462
10628
  local inFlight = {
10463
10629
  cancelled = false,
10630
+ progress = {
10631
+ serverUrl = dispatchOptions.serverUrl,
10632
+ },
10464
10633
  }
10465
10634
  local context = {
10466
10635
  requestId = request.requestId,
@@ -10471,50 +10640,31 @@ local function dispatchRequest(request)
10471
10640
  }
10472
10641
  local _requestId_1 = request.requestId
10473
10642
  inFlightRequests[_requestId_1] = inFlight
10643
+ inFlightRequestBytes += requestBytes
10474
10644
  task.spawn(function()
10475
- local _condition_1 = inFlight.cancelled
10476
- if not _condition_1 then
10645
+ if inFlight.cancelled or not active or os.clock() >= context.deadlineAt then
10646
+ completeProgress(request.requestId, inFlight.progress, "not_executed")
10477
10647
  local _requestId_2 = request.requestId
10478
- _condition_1 = terminalResponseIds[_requestId_2] ~= nil
10479
- end
10480
- if _condition_1 then
10481
- local _requestId_2 = request.requestId
10482
- if inFlightRequests[_requestId_2] == inFlight then
10483
- local _requestId_3 = request.requestId
10484
- inFlightRequests[_requestId_3] = nil
10485
- end
10648
+ inFlightRequests[_requestId_2] = nil
10649
+ inFlightRequestBytes -= requestBytes
10650
+ retainResponse(request.requestId, encodeError(request.requestId, "Request cancelled or admission deadline expired before execution started", "not_executed"), dispatchOptions.serverUrl, inFlight.progress)
10486
10651
  return nil
10487
10652
  end
10653
+ -- This observes handler entry, including broker dispatch, not a user Luau
10654
+ -- instruction. The waiter's deadline/cancellation is not a rollback.
10655
+ inFlight.progress.phase = "executing"
10656
+ sendProgress(request.requestId, inFlight.progress)
10488
10657
  local dispatchOk, response = pcall(function()
10489
10658
  return dispatchOptions.dispatchRequest(request, context)
10490
10659
  end)
10491
- local _condition_2 = inFlight.cancelled
10492
- if not _condition_2 then
10493
- local _requestId_2 = request.requestId
10494
- _condition_2 = terminalResponseIds[_requestId_2] ~= nil
10495
- end
10496
- if _condition_2 then
10497
- local _requestId_2 = request.requestId
10498
- if inFlightRequests[_requestId_2] == inFlight then
10499
- local _requestId_3 = request.requestId
10500
- inFlightRequests[_requestId_3] = nil
10501
- end
10502
- return nil
10503
- end
10504
- local responseData = if dispatchOk then response else {
10505
- error = tostring(response),
10506
- }
10507
- local entry = {
10508
- body = encodeResponse(request.requestId, responseData),
10509
- retryAttempt = 0,
10510
- posting = false,
10511
- retryToken = 0,
10512
- }
10660
+ local executionOutcome = if dispatchOk then handlerOutcome(response) else "error"
10661
+ -- Report handler return before JSON encoding or result retention can fail.
10662
+ completeProgress(request.requestId, inFlight.progress, executionOutcome)
10663
+ local body = if dispatchOk then encodeResponse(request.requestId, response, executionOutcome) else encodeError(request.requestId, `Plugin request execution failed: {string.sub(tostring(response), 1, 512)}`, executionOutcome)
10513
10664
  local _requestId_2 = request.requestId
10514
- pendingResponses[_requestId_2] = entry
10515
- local _requestId_3 = request.requestId
10516
- inFlightRequests[_requestId_3] = nil
10517
- postPendingResponse(request.requestId, entry)
10665
+ inFlightRequests[_requestId_2] = nil
10666
+ inFlightRequestBytes -= requestBytes
10667
+ retainResponse(request.requestId, body, dispatchOptions.serverUrl, inFlight.progress)
10518
10668
  end)
10519
10669
  end
10520
10670
  local function invokeCallback(name, callback)
@@ -10526,24 +10676,13 @@ end
10526
10676
  local function reportTransport(update)
10527
10677
  local currentOptions = options
10528
10678
  if active and currentOptions ~= nil then
10529
- invokeCallback("event stream transport", function()
10679
+ invokeCallback("WebSocket transport", function()
10530
10680
  return currentOptions.onTransportUpdate(update)
10531
10681
  end)
10532
10682
  end
10533
10683
  end
10534
- local function reconnectDelay(attempt)
10535
- return math.min(INITIAL_RECONNECT_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RECONNECT_DELAY_SECONDS)
10536
- end
10537
10684
  local connect
10538
- local function connectAfter(delaySeconds, expectedGeneration)
10539
- task.delay(delaySeconds, function()
10540
- if not active or generation ~= expectedGeneration then
10541
- return nil
10542
- end
10543
- connect(expectedGeneration)
10544
- end)
10545
- end
10546
- local function scheduleReconnect(expectedGeneration, detail, duplicate)
10685
+ function scheduleReconnect(expectedGeneration, detail, duplicate)
10547
10686
  if duplicate == nil then
10548
10687
  duplicate = false
10549
10688
  end
@@ -10551,27 +10690,31 @@ local function scheduleReconnect(expectedGeneration, detail, duplicate)
10551
10690
  return nil
10552
10691
  end
10553
10692
  generation += 1
10554
- closeCurrentStream()
10693
+ closeCurrentSocket()
10555
10694
  reconnectAttempt += 1
10556
- local delay = if duplicate then 1 else reconnectDelay(reconnectAttempt)
10695
+ local delay = if duplicate then 1 else retryDelay(reconnectAttempt)
10557
10696
  reportTransport({
10558
10697
  state = if duplicate then "waiting-duplicate" else "retrying",
10559
10698
  attempt = reconnectAttempt,
10560
10699
  retryDelay = delay,
10561
10700
  detail = detail,
10562
10701
  })
10563
- connectAfter(delay, generation)
10702
+ local nextGeneration = generation
10703
+ task.delay(delay, function()
10704
+ if active and generation == nextGeneration then
10705
+ connect(nextGeneration)
10706
+ end
10707
+ end)
10564
10708
  end
10565
10709
  local function watchForSilence(expectedGeneration, expectedClient)
10566
10710
  local elapsed = tick() - lastValidEventAt
10567
- local delay = math.max(STREAM_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1)
10568
- task.delay(delay, function()
10569
- if not active or generation ~= expectedGeneration or streamClient ~= expectedClient then
10711
+ task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
10712
+ if not active or generation ~= expectedGeneration or socketClient ~= expectedClient then
10570
10713
  return nil
10571
10714
  end
10572
10715
  local silentFor = tick() - lastValidEventAt
10573
- if silentFor >= STREAM_SILENCE_TIMEOUT_SECONDS then
10574
- scheduleReconnect(expectedGeneration, `Event stream silent for {math.floor(silentFor)} seconds`)
10716
+ if silentFor >= SOCKET_SILENCE_TIMEOUT_SECONDS then
10717
+ scheduleReconnect(expectedGeneration, `WebSocket silent for {math.floor(silentFor)} seconds`)
10575
10718
  return nil
10576
10719
  end
10577
10720
  watchForSilence(expectedGeneration, expectedClient)
@@ -10600,14 +10743,21 @@ local function parseReadyResponse(body)
10600
10743
  local _instanceId = value.instanceId
10601
10744
  _condition = not (type(_instanceId) == "string")
10602
10745
  if not _condition then
10603
- _condition = value.instanceId == ""
10746
+ _condition = value.instanceId == "" or value.protocolVersion ~= PROTOCOL_VERSION
10604
10747
  if not _condition then
10605
- local _condition_1 = value.multiplayerGroupId ~= nil
10606
- if _condition_1 then
10607
- local _multiplayerGroupId = value.multiplayerGroupId
10608
- _condition_1 = not (type(_multiplayerGroupId) == "string")
10748
+ local _transportToken = value.transportToken
10749
+ _condition = not (type(_transportToken) == "string")
10750
+ if not _condition then
10751
+ _condition = value.transportToken == ""
10752
+ if not _condition then
10753
+ local _condition_1 = value.multiplayerGroupId ~= nil
10754
+ if _condition_1 then
10755
+ local _multiplayerGroupId = value.multiplayerGroupId
10756
+ _condition_1 = not (type(_multiplayerGroupId) == "string")
10757
+ end
10758
+ _condition = _condition_1
10759
+ end
10609
10760
  end
10610
- _condition = _condition_1
10611
10761
  end
10612
10762
  end
10613
10763
  end
@@ -10624,9 +10774,64 @@ local function parseReadyResponse(body)
10624
10774
  peerId = value.peerId,
10625
10775
  instanceId = value.instanceId,
10626
10776
  multiplayerGroupId = value.multiplayerGroupId,
10777
+ protocolVersion = PROTOCOL_VERSION,
10778
+ transportToken = value.transportToken,
10627
10779
  }
10628
10780
  end
10629
- local refresh
10781
+ local function registerReady(currentOptions, expectedGeneration, reconnectOnFailure)
10782
+ local instanceId = PluginSession.getInstanceId()
10783
+ local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
10784
+ local transportRole = PluginSession.getRole()
10785
+ local readyUrl = `{currentOptions.serverUrl}/ready`
10786
+ local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
10787
+ if not active or generation ~= expectedGeneration or options ~= currentOptions then
10788
+ return nil
10789
+ end
10790
+ local readyOk, readyResult = pcall(function()
10791
+ return HttpService:RequestAsync({
10792
+ Url = readyUrl,
10793
+ Method = "POST",
10794
+ Headers = {
10795
+ ["Content-Type"] = "application/json",
10796
+ },
10797
+ Body = HttpService:JSONEncode(readyPayload),
10798
+ })
10799
+ end)
10800
+ if not active or generation ~= expectedGeneration or options ~= currentOptions then
10801
+ return nil
10802
+ end
10803
+ local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
10804
+ if not readyOk or not readyResult.Success then
10805
+ local detail = if readyOk then HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult) else HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
10806
+ if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10807
+ readyFailureLogKeys[readyLogKey] = true
10808
+ warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{transportRole}: {detail}`)
10809
+ end
10810
+ if reconnectOnFailure then
10811
+ scheduleReconnect(expectedGeneration, detail, readyOk and readyResult.StatusCode == 409)
10812
+ end
10813
+ return nil
10814
+ end
10815
+ local readyData = parseReadyResponse(readyResult.Body)
10816
+ if readyData == nil or readyData.peerId ~= PluginSession.peerId or readyData.instanceId ~= instanceId or readyData.multiplayerGroupId ~= multiplayerGroupId then
10817
+ if reconnectOnFailure then
10818
+ scheduleReconnect(expectedGeneration, "Invalid /ready response: expected Peer topology and WebSocket protocol credentials")
10819
+ end
10820
+ return nil
10821
+ end
10822
+ if readyFailureLogKeys[readyLogKey] ~= nil then
10823
+ readyFailureLogKeys[readyLogKey] = nil
10824
+ print(`[robloxstudio-mcp] /ready connected for {instanceId}/{readyData.assignedRole} via {currentOptions.serverUrl}`)
10825
+ end
10826
+ cachedReady = readyData
10827
+ invokeCallback("WebSocket ready", function()
10828
+ return currentOptions.onReady(readyData)
10829
+ end)
10830
+ return readyData
10831
+ end
10832
+ local function credentialsRejected(statusCode)
10833
+ return statusCode == 401 or statusCode == 403 or statusCode == 404
10834
+ end
10630
10835
  function connect(expectedGeneration)
10631
10836
  local currentOptions = options
10632
10837
  if not active or generation ~= expectedGeneration or currentOptions == nil then
@@ -10638,69 +10843,21 @@ function connect(expectedGeneration)
10638
10843
  retryDelay = 0,
10639
10844
  })
10640
10845
  task.spawn(function()
10641
- local instanceId = PluginSession.getInstanceId()
10642
- local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
10643
- local readyUrl = `{currentOptions.serverUrl}/ready`
10644
- local transportRole = PluginSession.getRole()
10645
- local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
10646
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
10647
- return nil
10648
- end
10649
- local readyOk, readyResult = pcall(function()
10650
- return HttpService:RequestAsync({
10651
- Url = readyUrl,
10652
- Method = "POST",
10653
- Headers = {
10654
- ["Content-Type"] = "application/json",
10655
- },
10656
- Body = HttpService:JSONEncode(readyPayload),
10657
- })
10658
- end)
10659
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
10660
- return nil
10661
- end
10662
- local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
10663
- if not readyOk then
10664
- local detail = HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
10665
- if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10666
- readyFailureLogKeys[readyLogKey] = true
10667
- warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{transportRole}: {detail}`)
10668
- end
10669
- scheduleReconnect(expectedGeneration, detail)
10670
- return nil
10671
- end
10672
- if not readyResult.Success then
10673
- local detail = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
10674
- if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10675
- readyFailureLogKeys[readyLogKey] = true
10676
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{transportRole}: {detail}`)
10677
- end
10678
- scheduleReconnect(expectedGeneration, detail, readyResult.StatusCode == 409)
10846
+ local readyData = cachedReady or registerReady(currentOptions, expectedGeneration, true)
10847
+ if readyData == nil or not active or generation ~= expectedGeneration or options ~= currentOptions then
10679
10848
  return nil
10680
10849
  end
10681
- local readyData = parseReadyResponse(readyResult.Body)
10682
- if readyData == nil or readyData.peerId ~= PluginSession.peerId or readyData.instanceId ~= instanceId or readyData.multiplayerGroupId ~= multiplayerGroupId then
10683
- scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the registered Peer topology")
10684
- return nil
10685
- end
10686
- if readyFailureLogKeys[readyLogKey] ~= nil then
10687
- readyFailureLogKeys[readyLogKey] = nil
10688
- print(`[robloxstudio-mcp] /ready connected for {instanceId}/{readyData.assignedRole} via {currentOptions.serverUrl}`)
10689
- end
10690
- invokeCallback("event stream ready", function()
10691
- return currentOptions.onReady(readyData)
10692
- end)
10850
+ local socketBaseUrl = string.gsub(currentOptions.serverUrl, "^http", "ws")
10693
10851
  local createOk, createdClient = pcall(function()
10694
- return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.SSE, {
10695
- Url = `{currentOptions.serverUrl}/events?peerId={PluginSession.peerId}`,
10696
- Method = "GET",
10852
+ return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
10853
+ Url = `{socketBaseUrl}/studio?peerId={HttpService:UrlEncode(PluginSession.peerId)}&protocolVersion={PROTOCOL_VERSION}`,
10697
10854
  Headers = {
10698
- Accept = "text/event-stream",
10855
+ ["X-Studio-Token"] = readyData.transportToken,
10699
10856
  },
10700
10857
  })
10701
10858
  end)
10702
10859
  if not createOk then
10703
- scheduleReconnect(expectedGeneration, `Failed to create event stream: {tostring(createdClient)}`)
10860
+ scheduleReconnect(expectedGeneration, `Failed to create WebSocket: {tostring(createdClient)}`)
10704
10861
  return nil
10705
10862
  end
10706
10863
  if not active or generation ~= expectedGeneration or options ~= currentOptions then
@@ -10709,16 +10866,20 @@ function connect(expectedGeneration)
10709
10866
  end)
10710
10867
  return nil
10711
10868
  end
10712
- streamClient = createdClient
10713
- streamConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
10714
- if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10869
+ socketClient = createdClient
10870
+ socketConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
10871
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
10715
10872
  return nil
10716
10873
  end
10717
- lastValidEventAt = tick()
10718
- if statusCode < 200 or statusCode >= 300 then
10719
- scheduleReconnect(expectedGeneration, `Event stream opened with HTTP {statusCode}`)
10874
+ if statusCode ~= 101 and (statusCode < 200 or statusCode >= 300) then
10875
+ if credentialsRejected(statusCode) then
10876
+ cachedReady = nil
10877
+ end
10878
+ scheduleReconnect(expectedGeneration, `WebSocket opened with HTTP {statusCode}`)
10720
10879
  return nil
10721
10880
  end
10881
+ socketOpen = true
10882
+ lastValidEventAt = tick()
10722
10883
  reconnectAttempt = 0
10723
10884
  reportTransport({
10724
10885
  state = "open",
@@ -10727,48 +10888,61 @@ function connect(expectedGeneration)
10727
10888
  })
10728
10889
  resumePendingResponses()
10729
10890
  end), createdClient.MessageReceived:Connect(function(message)
10730
- if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10891
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
10731
10892
  return nil
10732
10893
  end
10733
- for _, event in decodeMessages(message) do
10734
- -- Status callbacks can refresh/stop the stream. The remainder of
10735
- -- that callback belongs to the old connection, not its successor.
10736
- if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10737
- return nil
10738
- end
10739
- lastValidEventAt = tick()
10740
- if event.kind == "heartbeat" then
10741
- invokeCallback("event stream heartbeat", function()
10742
- return currentOptions.onHeartbeat(event.timestamp)
10743
- end)
10744
- continue
10745
- end
10746
- if event.kind == "cancel" then
10747
- cancelRequest(event)
10748
- continue
10749
- end
10750
- if event.kind == "request" then
10751
- dispatchRequest(event)
10752
- continue
10753
- end
10754
- invokeCallback("event stream status", function()
10894
+ local _message = message
10895
+ if not (type(_message) == "string") then
10896
+ scheduleReconnect(expectedGeneration, "Unsupported binary WebSocket frame (stage=request_receive); text JSON required")
10897
+ return nil
10898
+ end
10899
+ if #message > MAX_FRAME_BYTES then
10900
+ local detail = `WebSocket request frame exceeds limit (stage=request_receive, bytes={#message}, maxBytes={MAX_FRAME_BYTES}); execution not started`
10901
+ warn(`[robloxstudio-mcp] {detail}`)
10902
+ scheduleReconnect(expectedGeneration, detail)
10903
+ return nil
10904
+ end
10905
+ local event = decodeMessage(message)
10906
+ if event == nil then
10907
+ return nil
10908
+ end
10909
+ lastValidEventAt = tick()
10910
+ if event.kind == "ack" then
10911
+ local _requestId = event.requestId
10912
+ local pending = pendingResponses[_requestId]
10913
+ if pending ~= nil and pending.serverUrl == currentOptions.serverUrl then
10914
+ settleResponse(event.requestId, pending, event.disposition)
10915
+ end
10916
+ elseif event.kind == "heartbeat" then
10917
+ invokeCallback("WebSocket heartbeat", function()
10918
+ return currentOptions.onHeartbeat(event.timestamp)
10919
+ end)
10920
+ elseif event.kind == "cancel" then
10921
+ cancelRequest(event)
10922
+ elseif event.kind == "request" then
10923
+ dispatchRequest(event, #message)
10924
+ else
10925
+ invokeCallback("WebSocket status", function()
10755
10926
  return currentOptions.onStatus(event)
10756
10927
  end)
10757
10928
  if not event.knownPeer then
10758
- refresh()
10929
+ cachedReady = nil
10930
+ scheduleReconnect(expectedGeneration, "WebSocket session is no longer registered")
10759
10931
  end
10760
10932
  end
10761
10933
  end), createdClient.Error:Connect(function(statusCode, message)
10762
- if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10934
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
10763
10935
  return nil
10764
10936
  end
10765
- local detail = if statusCode == 404 then `Event stream session is not registered: {message}` else `Event stream error {statusCode}: {message}`
10766
- scheduleReconnect(expectedGeneration, detail)
10937
+ if credentialsRejected(statusCode) then
10938
+ cachedReady = nil
10939
+ end
10940
+ scheduleReconnect(expectedGeneration, `WebSocket error {statusCode}: {message}`)
10767
10941
  end), createdClient.Closed:Connect(function()
10768
- if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10942
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
10769
10943
  return nil
10770
10944
  end
10771
- scheduleReconnect(expectedGeneration, "Event stream closed")
10945
+ scheduleReconnect(expectedGeneration, "WebSocket closed")
10772
10946
  end) }
10773
10947
  lastValidEventAt = tick()
10774
10948
  watchForSilence(expectedGeneration, createdClient)
@@ -10786,19 +10960,22 @@ local function start(newOptions)
10786
10960
  generation += 1
10787
10961
  connect(generation)
10788
10962
  end
10789
- function refresh()
10790
- if not active or options == nil then
10963
+ local function refresh()
10964
+ local currentOptions = options
10965
+ if not active or currentOptions == nil or readyRefreshPending then
10791
10966
  return nil
10792
10967
  end
10793
- generation += 1
10794
- closeCurrentStream()
10795
- reconnectAttempt = 0
10796
- connect(generation)
10968
+ -- Metadata changes must not close a healthy socket or make its reconnect
10969
+ -- depend on RequestAsync quota. Proxy registration remains independent.
10970
+ readyRefreshPending = true
10971
+ local expectedGeneration = generation
10972
+ task.spawn(function()
10973
+ registerReady(currentOptions, expectedGeneration, false)
10974
+ readyRefreshPending = false
10975
+ end)
10797
10976
  end
10798
- -- EndTest can tear down a play DataModel without giving Plugin.Unloading a
10799
- -- usable execution window. Release the native WebStreamClient before the
10800
- -- yielding unregister request, but retain local request state and connection
10801
- -- options so a failed EndTest can register again and reconnect.
10977
+ -- EndTest may tear down this VM without an Unloading callback. Release the
10978
+ -- native socket before yielding to unregister; a failed EndTest can resume.
10802
10979
  local function suspendForShutdown()
10803
10980
  local currentOptions = options
10804
10981
  if not active or currentOptions == nil then
@@ -10808,7 +10985,8 @@ local function suspendForShutdown()
10808
10985
  shutdownSuspended = true
10809
10986
  generation += 1
10810
10987
  reconnectAttempt = 0
10811
- closeCurrentStream()
10988
+ closeCurrentSocket()
10989
+ cachedReady = nil
10812
10990
  disconnectSession(currentOptions)
10813
10991
  end
10814
10992
  local function resumeAfterShutdownFailure()
@@ -10832,9 +11010,8 @@ function stop()
10832
11010
  for _, inFlight in inFlightRequests do
10833
11011
  inFlight.cancelled = true
10834
11012
  end
10835
- table.clear(inFlightRequests)
10836
- table.clear(pendingResponses)
10837
- closeCurrentStream()
11013
+ closeCurrentSocket()
11014
+ cachedReady = nil
10838
11015
  table.clear(readyFailureLogKeys)
10839
11016
  options = nil
10840
11017
  reconnectAttempt = 0
@@ -10890,16 +11067,9 @@ end
10890
11067
  local function createInstanceId()
10891
11068
  return formatId("instance", randomValue())
10892
11069
  end
10893
- local function currentProcessInstanceId()
10894
- -- Roblox exposes process uptime through os.clock(). Quantizing the recovered
10895
- -- launch wall-clock to 10 ms gives every VM in one Studio process the same ID.
10896
- local launchTick = math.round((DateTime.now().UnixTimestampMillis - os.clock() * 1000) / 10)
10897
- return formatId("instance", launchTick)
10898
- end
10899
11070
  return {
10900
11071
  createPeerId = createPeerId,
10901
11072
  createInstanceId = createInstanceId,
10902
- currentProcessInstanceId = currentProcessInstanceId,
10903
11073
  }
10904
11074
  ]]></string>
10905
11075
  </Properties>