Report the result of a connection attempt to ConnectAsync's caller (AI-assisted) (#1)

This commit is contained in:
Peter 2026-10-02 03:57:35 -03:00 • committed by GitHub
parent 509d6004f9
commit 135efba5d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 238 additions and 9 deletions

View file

@ -743,7 +743,25 @@ function App:useRunningConnectionInfo()
self.setPort(port) self.setPort(port)
end end
function App:startSession(host: string?, port: string?) function App:startSession(host: string?, port: string?, onSettled: ((boolean, string?) -> ())?)
-- A caller that waits on this session is answered exactly once, and only
-- about the session it started.
local report = onSettled
local function settle(success: boolean, message: string?)
if report == nil then
return
end
local reportSettled = report
report = nil
reportSettled(success, message)
end
if self.serveSession ~= nil then
settle(false, "A sync session is already running")
return
end
local claimedLock, priorOwner = self:claimSyncLock() local claimedLock, priorOwner = self:claimSyncLock()
if not claimedLock then if not claimedLock then
local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner)) local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner))
@ -759,6 +777,8 @@ function App:startSession(host: string?, port: string?)
toolbarIcon = Assets.Images.PluginButtonWarning, toolbarIcon = Assets.Images.PluginButtonWarning,
}) })
settle(false, msg)
return return
end end
@ -891,6 +911,14 @@ function App:startSession(host: string?, port: string?)
self.headlessAPI:_updateProperty("Address", nil) self.headlessAPI:_updateProperty("Address", nil)
self.headlessAPI:_updateProperty("ProjectName", nil) self.headlessAPI:_updateProperty("ProjectName", nil)
end end
-- Settled last so that a caller waiting on this session sees the API
-- properties already describing it.
if status == ServeSession.Status.Connected then
settle(true)
elseif status == ServeSession.Status.Disconnected then
settle(false, if details ~= nil then tostring(details) else "Disconnected from session")
end
end) end)
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo) serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)

View file

@ -17,6 +17,10 @@ local apiPermissionAllowlist = {
RequestAccess = true, RequestAccess = true,
} }
-- Callers with no plugin of their own in the traceback are attributed to this
-- source: the command bar, and any chunk a plugin compiles at runtime.
local COMMAND_BAR_SOURCE = "RobloxStudio_CommandBar"
export type CallerInfo = { export type CallerInfo = {
Source: string, Source: string,
Type: "Local" | "Cloud" | "Studio", Type: "Local" | "Cloud" | "Studio",
@ -31,12 +35,58 @@ export type CallerInfo = {
local API = {} local API = {}
-- Permissions are stored as a list of records rather than a map because
-- source strings contain '.' and cannot be used as JSON keys.
local function loadPermissions(): { [string]: { [string]: boolean } }
local permissions = {}
local stored = Settings:get("apiPermissions")
if type(stored) ~= "table" then
return permissions
end
for _, record in stored do
if type(record) ~= "table" or type(record.source) ~= "string" or type(record.apis) ~= "table" then
continue
end
local apis = {}
for _, api in record.apis do
if type(api) ~= "string" then
continue
end
apis[api] = true
end
permissions[record.source] = apis
end
return permissions
end
local function savePermissions(permissions: { [string]: { [string]: boolean } })
local stored = {}
for source, apis in permissions do
local list = {}
for api in apis do
table.insert(list, api)
end
table.sort(list)
table.insert(stored, { source = source, apis = list })
end
table.sort(stored, function(a, b)
return a.source < b.source
end)
Settings:set("apiPermissions", stored)
end
function API.new(app) function API.new(app)
local Rojo = {} local Rojo = {}
Rojo._rateLimit = {} Rojo._rateLimit = {}
Rojo._sourceToPlugin = {} Rojo._sourceToPlugin = {}
Rojo._permissions = Settings:get("apiPermissions") or {} Rojo._permissions = loadPermissions()
Rojo._activePermissionRequests = {} Rojo._activePermissionRequests = {}
Rojo._changedEvent = Instance.new("BindableEvent") Rojo._changedEvent = Instance.new("BindableEvent")
Rojo._apiDescriptions = {} Rojo._apiDescriptions = {}
@ -97,7 +147,7 @@ function API.new(app)
return cloudPlugin return cloudPlugin
end end
return "RobloxStudio_CommandBar" return COMMAND_BAR_SOURCE
end end
function Rojo:_getCallerName() function Rojo:_getCallerName()
@ -273,7 +323,7 @@ function API.new(app)
-- Update stored permissions -- Update stored permissions
Rojo._permissions[source] = sourcePermissions Rojo._permissions[source] = sourcePermissions
Settings:set("apiPermissions", Rojo._permissions) savePermissions(Rojo._permissions)
-- Share changes -- Share changes
Rojo._permissionsChangedEvent:Fire(source, sourcePermissions) Rojo._permissionsChangedEvent:Fire(source, sourcePermissions)
@ -284,7 +334,7 @@ function API.new(app)
Log.info(string.format("Denying access to Rojo APIs for '%s'", name)) Log.info(string.format("Denying access to Rojo APIs for '%s'", name))
-- Update stored permissions -- Update stored permissions
Settings:set("apiPermissions", Rojo._permissions) savePermissions(Rojo._permissions)
-- Share changes -- Share changes
Rojo._permissionsChangedEvent:Fire(source, nil) Rojo._permissionsChangedEvent:Fire(source, nil)
@ -386,17 +436,34 @@ function API.new(app)
Rojo._apiDescriptions.ConnectAsync = { Rojo._apiDescriptions.ConnectAsync = {
Type = "Method", Type = "Method",
Description = "Connects to a Rojo server", Description = "Connects to a Rojo server and returns whether the session was established",
} }
function Rojo:ConnectAsync(host: string?, port: string?) function Rojo:ConnectAsync(host: string?, port: string?): (boolean, string?)
assert(type(host) == "string" or host == nil, "Host must be type `string?`") assert(type(host) == "string" or host == nil, "Host must be type `string?`")
assert(type(port) == "string" or port == nil, "Port must be type `string?`") assert(type(port) == "string" or port == nil, "Port must be type `string?`")
if Rojo:_checkRateLimit("ConnectAsync") then if Rojo:_checkRateLimit("ConnectAsync") then
return return false, "Rojo:ConnectAsync is being rate limited"
end end
app:startSession(host, port) -- The session can settle before startSession returns, as it does when one
-- is already running, so the thread yields only while still waiting.
local thread = coroutine.running()
local settled, success, message = false, false, nil :: string?
app:startSession(host, port, function(attemptSuccess: boolean, attemptMessage: string?)
settled, success, message = true, attemptSuccess, attemptMessage
if coroutine.status(thread) == "suspended" then
task.spawn(thread)
end
end)
while not settled do
coroutine.yield()
end
return success, message
end end
Rojo._apiDescriptions.DisconnectAsync = { Rojo._apiDescriptions.DisconnectAsync = {

View file

@ -0,0 +1,134 @@
return function()
local HeadlessAPI = require(script.Parent.HeadlessAPI)
local Settings = require(script.Parent.Settings)
-- The API only starts and stops sessions, so a stub app is enough here.
-- onStartSession decides how the session this stub was asked for settles.
local function newStubApp()
local stub = { settleCount = 0 }
function stub:startSession(host, port, onSettled)
self.startedWith = { host = host, port = port }
self.settle = function(success, message)
self.settleCount += 1
onSettled(success, message)
end
if self.onStartSession then
self.onStartSession()
end
end
function stub:endSession() end
return stub
end
-- A caller's source is read off the traceback, and the test runner is itself
-- a plugin, so it is stubbed to name the caller each test is about.
local function newApiAsSource(source: string)
local api, readOnlyApi = HeadlessAPI.new(newStubApp())
function api:_getCallerSource()
return source
end
return api, readOnlyApi
end
it("should tell the caller when a session connects", function()
local app = newStubApp()
local api = HeadlessAPI.new(app)
app.onStartSession = function()
app.settle(true)
end
local success, message = api:ConnectAsync("localhost", "34872")
expect(success).to.equal(true)
expect(message).to.equal(nil)
expect(app.startedWith.host).to.equal("localhost")
expect(app.startedWith.port).to.equal("34872")
end)
it("should tell the caller when a session fails before it starts", function()
local app = newStubApp()
local api = HeadlessAPI.new(app)
-- A held sync lock settles inside startSession, before the caller yields.
app.onStartSession = function()
app.settle(false, "Could not sync because user 'someone' is already syncing")
end
local success, message = api:ConnectAsync()
expect(success).to.equal(false)
expect(message).to.equal("Could not sync because user 'someone' is already syncing")
end)
it("should wait for a session that settles later", function()
local app = newStubApp()
local api = HeadlessAPI.new(app)
app.onStartSession = function()
task.delay(0.05, function()
app.settle(false, "Connection refused")
end)
end
local success, message = api:ConnectAsync()
expect(success).to.equal(false)
expect(message).to.equal("Connection refused")
end)
it("should answer a caller once, even if its session settles again", function()
local app = newStubApp()
local api = HeadlessAPI.new(app)
app.onStartSession = function()
app.settle(true)
end
expect(api:ConnectAsync()).to.equal(true)
expect(app.settleCount).to.equal(1)
-- The session reporting again, as it does when it later disconnects, must
-- not reach a caller that already has its answer.
app.settle(false, "Disconnected from session")
expect(app.settleCount).to.equal(2)
expect(api:ConnectAsync()).to.equal(true)
end)
it("should refuse callers that have not been granted access", function()
local _, readOnlyApi = newApiAsSource("user_Impostor.rbxmx")
expect(function()
return readOnlyApi.ConnectAsync
end).to.throw()
expect(readOnlyApi.Version).to.be.ok()
end)
it("should store permissions without putting a source in a table key", function()
local priorPermissions = Settings:get("apiPermissions")
local api = newApiAsSource("user_Companion.rbxmx")
api:_setPermissions("user_Companion.rbxmx", "Companion", { "ConnectAsync" })
local stored = Settings:get("apiPermissions")
expect(#stored).to.equal(1)
expect(stored[1].source).to.equal("user_Companion.rbxmx")
expect(stored[1].apis[1]).to.equal("ConnectAsync")
local _, readOnlyApi = newApiAsSource("user_Companion.rbxmx")
expect(readOnlyApi.ConnectAsync).to.be.a("function")
api:_removePermissions("user_Companion.rbxmx", "Companion")
expect(#Settings:get("apiPermissions")).to.equal(0)
Settings:set("apiPermissions", priorPermissions)
end)
end