mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-20 17:41:37 +00:00
When a session is disconnected, the apiContext long-polling for messages continues until resolved/rejected. This means that even after a session is disconnected, a message can be received and handled. This leads to bad behavior, as the session was already cleaned up and the message cannot be handled correctly. The instance map was cleaned up upon disconnect, so it will warn about unapplied changes to unknown instances. (Like #512) It's very easy to repro: Connect a session, disconnect it, then save a change. https://github.com/rojo-rbx/rojo/assets/40185666/846a7269-7043-4727-9f9c-b3ac55a18a3a ----------- This PR fixes that neatly by tracking all active requests in a map, and cancelling their promises when we disconnect.
267 lines
6.5 KiB
Lua
267 lines
6.5 KiB
Lua
local Packages = script.Parent.Parent.Packages
|
|
local Http = require(Packages.Http)
|
|
local Log = require(Packages.Log)
|
|
local Promise = require(Packages.Promise)
|
|
|
|
local Config = require(script.Parent.Config)
|
|
local Types = require(script.Parent.Types)
|
|
local Version = require(script.Parent.Version)
|
|
|
|
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
|
|
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
|
|
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
|
|
|
|
--[[
|
|
Returns a promise that will never resolve nor reject.
|
|
]]
|
|
local function hangingPromise()
|
|
return Promise.new(function() end)
|
|
end
|
|
|
|
local function rejectFailedRequests(response)
|
|
if response.code >= 400 then
|
|
local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body)
|
|
|
|
return Promise.reject(message)
|
|
end
|
|
|
|
return response
|
|
end
|
|
|
|
local function rejectWrongProtocolVersion(infoResponseBody)
|
|
if infoResponseBody.protocolVersion ~= Config.protocolVersion then
|
|
local message = (
|
|
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
|
|
"\nMake sure you have matching versions of both the Rojo plugin and server!" ..
|
|
"\n\nYour client is version %s, with protocol version %s. It expects server version %s." ..
|
|
"\nYour server is version %s, with protocol version %s." ..
|
|
"\n\nGo to https://github.com/rojo-rbx/rojo for more details."
|
|
):format(
|
|
Version.display(Config.version), Config.protocolVersion,
|
|
Config.expectedServerVersionString,
|
|
infoResponseBody.serverVersion, infoResponseBody.protocolVersion
|
|
)
|
|
|
|
return Promise.reject(message)
|
|
end
|
|
|
|
return Promise.resolve(infoResponseBody)
|
|
end
|
|
|
|
local function rejectWrongPlaceId(infoResponseBody)
|
|
if infoResponseBody.expectedPlaceIds ~= nil then
|
|
local foundId = false
|
|
|
|
for _, id in ipairs(infoResponseBody.expectedPlaceIds) do
|
|
if id == game.PlaceId then
|
|
foundId = true
|
|
break
|
|
end
|
|
end
|
|
|
|
if not foundId then
|
|
local idList = {}
|
|
for _, id in ipairs(infoResponseBody.expectedPlaceIds) do
|
|
table.insert(idList, "- " .. tostring(id))
|
|
end
|
|
|
|
local message = (
|
|
"Found a Rojo server, but its project is set to only be used with a specific list of places." ..
|
|
"\nYour place ID is %s, but needs to be one of these:" ..
|
|
"\n%s" ..
|
|
"\n\nTo change this list, edit 'servePlaceIds' in your .project.json file."
|
|
):format(
|
|
tostring(game.PlaceId),
|
|
table.concat(idList, "\n")
|
|
)
|
|
|
|
return Promise.reject(message)
|
|
end
|
|
end
|
|
|
|
return Promise.resolve(infoResponseBody)
|
|
end
|
|
|
|
local ApiContext = {}
|
|
ApiContext.__index = ApiContext
|
|
|
|
function ApiContext.new(baseUrl)
|
|
assert(type(baseUrl) == "string", "baseUrl must be a string")
|
|
|
|
local self = {
|
|
__baseUrl = baseUrl,
|
|
__sessionId = nil,
|
|
__messageCursor = -1,
|
|
__connected = true,
|
|
__activeRequests = {},
|
|
}
|
|
|
|
return setmetatable(self, ApiContext)
|
|
end
|
|
|
|
function ApiContext:__fmtDebug(output)
|
|
output:writeLine("ApiContext {{")
|
|
output:indent()
|
|
|
|
output:writeLine("Connected: {}", self.__connected)
|
|
output:writeLine("Base URL: {}", self.__baseUrl)
|
|
output:writeLine("Session ID: {}", self.__sessionId)
|
|
output:writeLine("Message Cursor: {}", self.__messageCursor)
|
|
|
|
output:unindent()
|
|
output:write("}")
|
|
end
|
|
|
|
function ApiContext:disconnect()
|
|
self.__connected = false
|
|
for request in self.__activeRequests do
|
|
Log.trace("Cancelling request {}", request)
|
|
request:cancel()
|
|
end
|
|
self.__activeRequests = {}
|
|
end
|
|
|
|
function ApiContext:setMessageCursor(index)
|
|
self.__messageCursor = index
|
|
end
|
|
|
|
function ApiContext:connect()
|
|
local url = ("%s/api/rojo"):format(self.__baseUrl)
|
|
|
|
return Http.get(url)
|
|
:andThen(rejectFailedRequests)
|
|
:andThen(Http.Response.json)
|
|
:andThen(rejectWrongProtocolVersion)
|
|
:andThen(function(body)
|
|
assert(validateApiInfo(body))
|
|
|
|
return body
|
|
end)
|
|
:andThen(rejectWrongPlaceId)
|
|
:andThen(function(body)
|
|
self.__sessionId = body.sessionId
|
|
|
|
return body
|
|
end)
|
|
end
|
|
|
|
function ApiContext:read(ids)
|
|
local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ","))
|
|
|
|
return Http.get(url)
|
|
:andThen(rejectFailedRequests)
|
|
:andThen(Http.Response.json)
|
|
:andThen(function(body)
|
|
if body.sessionId ~= self.__sessionId then
|
|
return Promise.reject("Server changed ID")
|
|
end
|
|
|
|
assert(validateApiRead(body))
|
|
|
|
return body
|
|
end)
|
|
end
|
|
|
|
function ApiContext:write(patch)
|
|
local url = ("%s/api/write"):format(self.__baseUrl)
|
|
|
|
local updated = {}
|
|
for _, update in ipairs(patch.updated) do
|
|
local fixedUpdate = {
|
|
id = update.id,
|
|
changedName = update.changedName,
|
|
}
|
|
|
|
if next(update.changedProperties) ~= nil then
|
|
fixedUpdate.changedProperties = update.changedProperties
|
|
end
|
|
|
|
table.insert(updated, fixedUpdate)
|
|
end
|
|
|
|
-- Only add the 'added' field if the table is non-empty, or else Roblox's
|
|
-- JSON implementation will turn the table into an array instead of an
|
|
-- object, causing API validation to fail.
|
|
local added
|
|
if next(patch.added) ~= nil then
|
|
added = patch.added
|
|
end
|
|
|
|
local body = {
|
|
sessionId = self.__sessionId,
|
|
removed = patch.removed,
|
|
updated = updated,
|
|
added = added,
|
|
}
|
|
|
|
body = Http.jsonEncode(body)
|
|
|
|
return Http.post(url, body)
|
|
:andThen(rejectFailedRequests)
|
|
:andThen(Http.Response.json)
|
|
:andThen(function(body)
|
|
Log.info("Write response: {:?}", body)
|
|
|
|
return body
|
|
end)
|
|
end
|
|
|
|
function ApiContext:retrieveMessages()
|
|
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
|
|
|
|
local function sendRequest()
|
|
local request = Http.get(url)
|
|
:catch(function(err)
|
|
if err.type == Http.Error.Kind.Timeout then
|
|
if self.__connected then
|
|
return sendRequest()
|
|
else
|
|
return hangingPromise()
|
|
end
|
|
end
|
|
|
|
return Promise.reject(err)
|
|
end)
|
|
|
|
Log.trace("Tracking request {}", request)
|
|
self.__activeRequests[request] = true
|
|
|
|
return request:finally(function(...)
|
|
Log.trace("Cleaning up request {}", request)
|
|
self.__activeRequests[request] = nil
|
|
return ...
|
|
end)
|
|
end
|
|
|
|
return sendRequest()
|
|
:andThen(rejectFailedRequests)
|
|
:andThen(Http.Response.json)
|
|
:andThen(function(body)
|
|
if body.sessionId ~= self.__sessionId then
|
|
return Promise.reject("Server changed ID")
|
|
end
|
|
|
|
assert(validateApiSubscribe(body))
|
|
|
|
self:setMessageCursor(body.messageCursor)
|
|
|
|
return body.messages
|
|
end)
|
|
end
|
|
|
|
function ApiContext:open(id)
|
|
local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
|
|
|
|
return Http.post(url, "")
|
|
:andThen(rejectFailedRequests)
|
|
:andThen(Http.Response.json)
|
|
:andThen(function(body)
|
|
if body.sessionId ~= self.__sessionId then
|
|
return Promise.reject("Server changed ID")
|
|
end
|
|
|
|
return nil
|
|
end)
|
|
end
|
|
|
|
return ApiContext
|