@el4cteo/rbx-studio-mcp 0.8.4 → 0.8.6
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/README.md +20 -3
- package/dist/bridge/rpc.js +1 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +75 -0
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/errors.js +5 -4
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/format.js +92 -25
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/notices.js +29 -0
- package/dist/lib/notices.js.map +1 -0
- package/dist/lib/protocol.js.map +1 -1
- package/dist/lib/sync.js +1141 -0
- package/dist/lib/sync.js.map +1 -0
- package/dist/lib/syncplan.js +338 -0
- package/dist/lib/syncplan.js.map +1 -0
- package/dist/lib/tool.js +9 -2
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/discover.js +6 -1
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +5 -0
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +2 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +28 -3
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/screenshot.js +7 -3
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +110 -28
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/sync.js +169 -0
- package/dist/tools/sync.js.map +1 -0
- package/package.json +4 -4
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Dispatch.luau +131 -90
- package/plugin/src/ExecRuntime.luau +190 -168
- package/plugin/src/LogBuffer.luau +38 -9
- package/plugin/src/Paths.luau +42 -0
- package/plugin/src/Phrase.luau +41 -0
- package/plugin/src/ScriptEdit.luau +94 -8
- package/plugin/src/Serialize.luau +16 -1
- package/plugin/src/Transport.luau +5 -2
- package/plugin/src/Undo.luau +74 -10
- package/plugin/src/handlers/Capture.luau +818 -809
- package/plugin/src/handlers/Debug.luau +19 -16
- package/plugin/src/handlers/Discover.luau +31 -1
- package/plugin/src/handlers/Perf.luau +100 -21
- package/plugin/src/handlers/Scripts.luau +274 -90
- package/plugin/src/handlers/Sync.luau +968 -0
- package/plugin/src/init.server.luau +33 -2
- package/scripts/build.mjs +14 -0
- package/scripts/sync-fake.mjs +191 -0
- package/scripts/test-live-sync-scale.mjs +150 -0
- package/scripts/test-live-sync.mjs +232 -0
- package/scripts/test-live-tools.mjs +6 -1
- package/scripts/test-plugin.mjs +16 -0
- package/scripts/test-results.mjs +41 -0
- package/scripts/test-sync-more.mjs +228 -0
- package/scripts/test-sync.mjs +245 -0
- package/dist/tools/spatial.js +0 -135
- package/dist/tools/spatial.js.map +0 -1
- package/dist/tools/upload.js +0 -294
- package/dist/tools/upload.js.map +0 -1
package/plugin/src/Dispatch.luau
CHANGED
|
@@ -1,90 +1,131 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Handler registry and the single error boundary around every command.
|
|
4
|
-
|
|
5
|
-
Handlers raise failures with `Dispatch.fail(code, message, hint)`. The hint is
|
|
6
|
-
forwarded verbatim to the agent, so it should say what to do next ("call find
|
|
7
|
-
first to get a valid path"), not just what went wrong.
|
|
8
|
-
]]
|
|
9
|
-
|
|
10
|
-
local Dispatch = {}
|
|
11
|
-
|
|
12
|
-
export type Error = {
|
|
13
|
-
code: string,
|
|
14
|
-
message: string,
|
|
15
|
-
hint: string?,
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export type Handler = (params: { [string]: any }) -> any
|
|
19
|
-
|
|
20
|
-
local handlers: { [string]: Handler } = {}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Handler registry and the single error boundary around every command.
|
|
4
|
+
|
|
5
|
+
Handlers raise failures with `Dispatch.fail(code, message, hint)`. The hint is
|
|
6
|
+
forwarded verbatim to the agent, so it should say what to do next ("call find
|
|
7
|
+
first to get a valid path"), not just what went wrong.
|
|
8
|
+
]]
|
|
9
|
+
|
|
10
|
+
local Dispatch = {}
|
|
11
|
+
|
|
12
|
+
export type Error = {
|
|
13
|
+
code: string,
|
|
14
|
+
message: string,
|
|
15
|
+
hint: string?,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Handler = (params: { [string]: any }) -> any
|
|
19
|
+
|
|
20
|
+
local handlers: { [string]: Handler } = {}
|
|
21
|
+
|
|
22
|
+
--[[
|
|
23
|
+
When the server stops waiting for each running command, keyed by the
|
|
24
|
+
command's thread.
|
|
25
|
+
|
|
26
|
+
The server drops a timed-out command it has not delivered yet, but one
|
|
27
|
+
already delivered can still be sitting behind the mutation lock, or behind
|
|
28
|
+
a slow editor write. Starting it then does work nobody will hear about --
|
|
29
|
+
and the agent, told "timed out", may be retrying it at that moment. So the
|
|
30
|
+
deadline travels with the command and is checked where work would begin.
|
|
31
|
+
|
|
32
|
+
Both clocks are this machine's, so there is no skew to allow for. Weak keys,
|
|
33
|
+
so a thread that dies mid-command does not leave its entry behind.
|
|
34
|
+
]]
|
|
35
|
+
local deadlines: { [thread]: number } = setmetatable({}, { __mode = "k" }) :: any
|
|
36
|
+
|
|
37
|
+
function Dispatch.deadline(): number?
|
|
38
|
+
return deadlines[coroutine.running()]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
--[[
|
|
42
|
+
Refuses to go on once the command's deadline has passed. Pass the deadline
|
|
43
|
+
explicitly from code that runs on another thread -- an engine callback, or
|
|
44
|
+
a spawned task -- which the table above cannot see.
|
|
45
|
+
]]
|
|
46
|
+
function Dispatch.checkDeadline(explicit: number?)
|
|
47
|
+
local deadline = explicit or Dispatch.deadline()
|
|
48
|
+
if deadline and DateTime.now().UnixTimestampMillis >= deadline then
|
|
49
|
+
Dispatch.fail(
|
|
50
|
+
"DEADLINE_EXPIRED",
|
|
51
|
+
"The request timed out before Studio started this step, so it was not done.",
|
|
52
|
+
"Earlier steps of the same request may have applied. Check the current state before retrying."
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
function Dispatch.register(op: string, handler: Handler)
|
|
58
|
+
handlers[op] = handler
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
--[[
|
|
62
|
+
Registers every entry of `map` under the prefix, e.g. `registerAll("script",
|
|
63
|
+
{ read = fn })` exposes "script.read".
|
|
64
|
+
]]
|
|
65
|
+
function Dispatch.registerAll(prefix: string, map: { [string]: Handler })
|
|
66
|
+
for name, handler in map do
|
|
67
|
+
handlers[prefix .. "." .. name] = handler
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
--[[
|
|
72
|
+
Raises a structured failure. Always call this rather than `error("...")` so
|
|
73
|
+
the agent receives a code it can branch on and a hint it can act on.
|
|
74
|
+
]]
|
|
75
|
+
function Dispatch.fail(code: string, message: string, hint: string?): never
|
|
76
|
+
error({ code = code, message = message, hint = hint }, 0)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
export type Result = {
|
|
80
|
+
id: string,
|
|
81
|
+
ok: boolean,
|
|
82
|
+
data: any?,
|
|
83
|
+
error: Error?,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
--[[
|
|
87
|
+
Runs one command and returns the frame to POST back. Never throws: an
|
|
88
|
+
unhandled error inside a handler becomes a HANDLER_ERROR result, because a
|
|
89
|
+
crashed dispatch would leave the server's promise hanging until it times out.
|
|
90
|
+
]]
|
|
91
|
+
function Dispatch.invoke(id: string, op: string, params: { [string]: any }?, deadlineMs: number?): Result
|
|
92
|
+
local handler = handlers[op]
|
|
93
|
+
if not handler then
|
|
94
|
+
return {
|
|
95
|
+
id = id,
|
|
96
|
+
ok = false,
|
|
97
|
+
error = {
|
|
98
|
+
code = "UNKNOWN_OP",
|
|
99
|
+
message = string.format('This Studio plugin has no handler for "%s".', op),
|
|
100
|
+
hint = "The plugin is older than the MCP server. Update the Studio MCP "
|
|
101
|
+
.. "plugin to the version matching your server.",
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
local thread = coroutine.running()
|
|
107
|
+
deadlines[thread] = deadlineMs
|
|
108
|
+
local ok, result = pcall(function()
|
|
109
|
+
Dispatch.checkDeadline()
|
|
110
|
+
return handler(params or {})
|
|
111
|
+
end)
|
|
112
|
+
deadlines[thread] = nil
|
|
113
|
+
if ok then
|
|
114
|
+
return { id = id, ok = true, data = result }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if typeof(result) == "table" and (result :: any).code then
|
|
118
|
+
return { id = id, ok = false, error = result :: Error }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
id = id,
|
|
123
|
+
ok = false,
|
|
124
|
+
error = {
|
|
125
|
+
code = "HANDLER_ERROR",
|
|
126
|
+
message = string.format('"%s" failed: %s', op, tostring(result)),
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
return Dispatch
|
|
@@ -1,168 +1,190 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
-- Shared bounded execution results in the VM that calls run.
|
|
3
|
-
local LogService = game:GetService("LogService")
|
|
4
|
-
local LogBuffer = require(script.Parent.LogBuffer)
|
|
5
|
-
local Serialize = require(script.Parent.Serialize)
|
|
6
|
-
-- A chunk that prints in a loop would otherwise return a response no context
|
|
7
|
-
-- window can hold.
|
|
8
|
-
local MAX_OUTPUT_LINES = 200
|
|
9
|
-
local MAX_RETURN_VALUES = 10
|
|
10
|
-
|
|
11
|
-
-- Returned tables are walked rather than counted, but not without limit: a
|
|
12
|
-
-- chunk that returns a whole config tree, or one with a cycle in it, must not be
|
|
13
|
-
-- able to produce a response nothing can read.
|
|
14
|
-
local MAX_TABLE_ENTRIES = 50
|
|
15
|
-
local MAX_TABLE_DEPTH = 4
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
local
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
local
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
-- Shared bounded execution results in the VM that calls run.
|
|
3
|
+
local LogService = game:GetService("LogService")
|
|
4
|
+
local LogBuffer = require(script.Parent.LogBuffer)
|
|
5
|
+
local Serialize = require(script.Parent.Serialize)
|
|
6
|
+
-- A chunk that prints in a loop would otherwise return a response no context
|
|
7
|
+
-- window can hold.
|
|
8
|
+
local MAX_OUTPUT_LINES = 200
|
|
9
|
+
local MAX_RETURN_VALUES = 10
|
|
10
|
+
|
|
11
|
+
-- Returned tables are walked rather than counted, but not without limit: a
|
|
12
|
+
-- chunk that returns a whole config tree, or one with a cycle in it, must not be
|
|
13
|
+
-- able to produce a response nothing can read.
|
|
14
|
+
local MAX_TABLE_ENTRIES = 50
|
|
15
|
+
local MAX_TABLE_DEPTH = 4
|
|
16
|
+
type Budget = { nodes: number, chars: number }
|
|
17
|
+
|
|
18
|
+
local function describe(value: any, depth: number?, seen: { [any]: boolean }?, shared: Budget?): any
|
|
19
|
+
local budget = shared or {nodes = 300, chars = 10000}
|
|
20
|
+
budget.nodes -= 1
|
|
21
|
+
budget.chars -= 30
|
|
22
|
+
if budget.nodes < 0 or budget.chars < 100 then return "<budget exceeded>" end
|
|
23
|
+
local level = depth or 0
|
|
24
|
+
local visited = seen or {}
|
|
25
|
+
local kind = typeof(value)
|
|
26
|
+
|
|
27
|
+
if kind == "function" or kind == "thread" then
|
|
28
|
+
return string.format("<%s>", kind)
|
|
29
|
+
end
|
|
30
|
+
if kind ~= "table" then
|
|
31
|
+
local result = Serialize.value(value)
|
|
32
|
+
if typeof(result) == "string" then
|
|
33
|
+
local cap = math.min(2000, math.floor(budget.chars / 6))
|
|
34
|
+
budget.chars -= math.min(#result, cap) * 6
|
|
35
|
+
return if #result > cap then string.sub(result, 1, cap) .. "<truncated>" else result
|
|
36
|
+
end
|
|
37
|
+
return result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
local source = value :: { [any]: any }
|
|
41
|
+
-- Named separately from the depth cap, because they mean opposite things to
|
|
42
|
+
-- a reader. The depth cap says "there is more below this"; a cycle says
|
|
43
|
+
-- "this is the table you are already inside". Reporting the second as the
|
|
44
|
+
-- first printed `t.self.self.self` as three distinct nested tables, which
|
|
45
|
+
-- reads as real structure that does not exist.
|
|
46
|
+
if visited[source] then
|
|
47
|
+
return "<circular reference>"
|
|
48
|
+
end
|
|
49
|
+
if level >= MAX_TABLE_DEPTH then
|
|
50
|
+
return "<nested table>"
|
|
51
|
+
end
|
|
52
|
+
visited[source] = true
|
|
53
|
+
|
|
54
|
+
local count = 0
|
|
55
|
+
local isArray = true
|
|
56
|
+
for key in source do
|
|
57
|
+
if typeof(key) ~= "number" or key < 1 or key > #source or key % 1 ~= 0 then isArray = false end
|
|
58
|
+
count += 1
|
|
59
|
+
if count > MAX_TABLE_ENTRIES then break end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
-- Arrays keep their shape so they read as lists on the other side; anything
|
|
63
|
+
-- with non-sequential keys becomes a map with stringified keys.
|
|
64
|
+
if isArray and (count == #source or count > MAX_TABLE_ENTRIES) then
|
|
65
|
+
local list: { any } = {}
|
|
66
|
+
for index, item in ipairs(source) do
|
|
67
|
+
if index > MAX_TABLE_ENTRIES or budget.nodes <= 0 or budget.chars < 100 then
|
|
68
|
+
table.insert(list, "<more entries>")
|
|
69
|
+
break
|
|
70
|
+
end
|
|
71
|
+
table.insert(list, describe(item, level + 1, visited, budget))
|
|
72
|
+
end
|
|
73
|
+
-- Cleared on the way out so the same table appearing twice side by side
|
|
74
|
+
-- still renders twice; only a table containing itself is a cycle.
|
|
75
|
+
visited[source] = nil
|
|
76
|
+
return list
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
local map: { [string]: any } = {}
|
|
80
|
+
local shown = 0
|
|
81
|
+
for key, item in source do
|
|
82
|
+
shown += 1
|
|
83
|
+
if shown > MAX_TABLE_ENTRIES or budget.nodes <= 0 or budget.chars < 100 then
|
|
84
|
+
map["..."] = "<more entries>"
|
|
85
|
+
break
|
|
86
|
+
end
|
|
87
|
+
local name = string.sub(tostring(key), 1, 100)
|
|
88
|
+
budget.chars -= #name * 6
|
|
89
|
+
map[name] = describe(item, level + 1, visited, budget)
|
|
90
|
+
end
|
|
91
|
+
visited[source] = nil
|
|
92
|
+
return map
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
local Runtime = {}
|
|
96
|
+
Runtime.describe = describe
|
|
97
|
+
function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
|
|
98
|
+
-- Captured rather than inferred: a chunk's prints are usually the point, and
|
|
99
|
+
-- reading them back from the log afterwards would also pick up whatever else
|
|
100
|
+
-- the place logged in the meantime.
|
|
101
|
+
local output: { { [string]: any } } = {}
|
|
102
|
+
local outputChars, outputDropped = 0, 0
|
|
103
|
+
local connection = LogService.MessageOut:Connect(function(message, messageType, context)
|
|
104
|
+
if #output < MAX_OUTPUT_LINES and outputChars < 8000 then
|
|
105
|
+
-- A structured log's context rides on the line, as it does in `console`.
|
|
106
|
+
local encoded = LogBuffer.encodeContext(context)
|
|
107
|
+
if encoded then
|
|
108
|
+
message ..= " " .. encoded
|
|
109
|
+
end
|
|
110
|
+
if #message > math.min(2000, 8000 - outputChars) then message = string.sub(message, 1, math.min(2000, 8000 - outputChars)) .. "<truncated>" end
|
|
111
|
+
outputChars += #message
|
|
112
|
+
table.insert(output, {
|
|
113
|
+
message = message,
|
|
114
|
+
level = if messageType == Enum.MessageType.MessageError
|
|
115
|
+
then "error"
|
|
116
|
+
elseif messageType == Enum.MessageType.MessageWarning then "warning"
|
|
117
|
+
elseif messageType == Enum.MessageType.MessageInfo then "info"
|
|
118
|
+
else "print",
|
|
119
|
+
})
|
|
120
|
+
else outputDropped += 1 end
|
|
121
|
+
end)
|
|
122
|
+
|
|
123
|
+
local started = os.clock()
|
|
124
|
+
local results: any = nil
|
|
125
|
+
if timeout then
|
|
126
|
+
local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
|
|
127
|
+
local deadline = started + timeout
|
|
128
|
+
while results == nil and os.clock() < deadline do task.wait() end
|
|
129
|
+
if results == nil then
|
|
130
|
+
task.cancel(worker)
|
|
131
|
+
results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
results = table.pack(pcall(chunk :: () -> ...any))
|
|
135
|
+
end
|
|
136
|
+
local elapsed = os.clock() - started
|
|
137
|
+
|
|
138
|
+
--[[
|
|
139
|
+
MessageOut is deferred: `print` returns before the event fires, so
|
|
140
|
+
disconnecting straight after the chunk finished captured nothing at all.
|
|
141
|
+
A short settle picks the lines up.
|
|
142
|
+
|
|
143
|
+
It is bounded and stops as soon as the flow stops, rather than waiting a
|
|
144
|
+
fixed period, so a chunk that logged nothing costs almost no time and one
|
|
145
|
+
that logged plenty is not truncated.
|
|
146
|
+
]]
|
|
147
|
+
local settleDeadline = os.clock() + 0.5
|
|
148
|
+
local seen = #output
|
|
149
|
+
local quietFrames = 0
|
|
150
|
+
while os.clock() < settleDeadline and quietFrames < 3 do
|
|
151
|
+
task.wait()
|
|
152
|
+
if #output == seen then
|
|
153
|
+
quietFrames += 1
|
|
154
|
+
else
|
|
155
|
+
seen = #output
|
|
156
|
+
quietFrames = 0
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
connection:Disconnect()
|
|
161
|
+
|
|
162
|
+
if not results[1] then
|
|
163
|
+
return {
|
|
164
|
+
ok = false,
|
|
165
|
+
error = string.sub(tostring(results[2]), 1, 2000),
|
|
166
|
+
output = output,
|
|
167
|
+
milliseconds = math.floor(elapsed * 1000 + 0.5),
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
local returned: { any } = {}
|
|
172
|
+
local budget: Budget = {nodes = 300, chars = 6000}
|
|
173
|
+
for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
|
|
174
|
+
-- A nil return is written out rather than dropped. `table.insert` of nil
|
|
175
|
+
-- does nothing, so `return a, nil, b` came back as two values and every
|
|
176
|
+
-- one after the nil shifted into the wrong position.
|
|
177
|
+
local value = results[index]
|
|
178
|
+
table.insert(returned, if value == nil then "nil" else describe(value, nil, nil, budget))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
ok = true,
|
|
183
|
+
returned = returned,
|
|
184
|
+
outputDropped = if outputDropped > 0 then outputDropped else nil,
|
|
185
|
+
returnsDropped = if results.n > MAX_RETURN_VALUES + 1 then results.n - MAX_RETURN_VALUES - 1 else nil,
|
|
186
|
+
output = output,
|
|
187
|
+
milliseconds = math.floor(elapsed * 1000 + 0.5),
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
return Runtime
|