run_command: implement support for async functions.

Also update //for to make use of this new functionality.

//subdivide is still on the todo list.

The new functionality works by adding the new property `async = false` to command definition tables registered via wea_core.register_command()`. When `true`, if and only if the command's MAIN FUNC ONLY returns no values at all then it will consider an async operation to be in progress.

This delays `run_command` from emitting the `post-execute` event on `wea_core`.

Additionally, all async commands have a callback function injected as the first argument to their main `func` (ref main cmd definition table). This callback function -- if no arguments are returned by the main `func` -- must be called once the async operation is complete with same args you would normally return from `func` -- that is `success, result_message`. These will then be handled as normal and sent to the player as appropriate, as well as finally emitting the `post-execute` event.

BUG: There is a potential issue in this implementation, in that if i.e. `minetest.after()` is used to delay async execution then this will break out of the `xpcall()` protection in place to prevent crashes. To this end, if you implement an async function you need to be very careful, and do a manual `wea_core.safe_function()` call yourself!
This commit is contained in:
Starbeamrainbowlabs 2024-10-17 01:54:32 +01:00
parent a06136812f
commit 1ae48f3a52
Signed by: sbrl
GPG key ID: 1BE5172E637709C2
3 changed files with 84 additions and 27 deletions

View file

@ -15,7 +15,7 @@ local wea_c = worldeditadditions_core
-- ?Basename support for values
-- ?Comma deliniation support for values
local function step(params)
local function step(params, __callback)
-- Initialize additional params on first call
if not params.first then
params.i = 1 -- Iteration number
@ -37,12 +37,9 @@ local function step(params)
if params.i <= #params.values then
-- If we haven't run out of values call function again
minetest.after(0, step, params)
minetest.after(0, step, params, __callback)
else
wea_c.notify.ok(params.player_name, "For "..
table.concat(params.values,", ")..
", /"..params.cmd_name.." completed in " ..
wea_c.format.human_time(params.time))
__callback(true, "//for completed mapping values ["..table.concat(params.values, ", ").."] over /"..params.cmd_name.." "..tostring(params.args).." in "..wea_c.format.human_time(params.time))
end
end
@ -50,6 +47,7 @@ worldeditadditions_core.register_command("for", {
params = "<value1> <value2> <value3>... do //<command> <arg> %% <arg>",
description = "Executes a chat command for each value before \" do \" replacing any instances of \"%%\" with those values. The forward slashes at the beginning of the chat command must be the same as if you were executing it normally.",
privs = { worldedit = true },
async = true,
parse = function(params_text)
if not params_text:match("%sdo%s") then
return false, "Error: \"do\" argument is not present."
@ -68,7 +66,9 @@ worldeditadditions_core.register_command("for", {
return true, values, command, args
end,
func = function(name, values, command, args)
func = function(__callback, name, values, command, args)
print("DEBUG://for __callback", wea_c.inspect(__callback), "name", name)
local cmd = minetest.registered_chatcommands[command]
if not cmd then
return false, "Error: "..command.." isn't a valid command."
@ -77,13 +77,17 @@ worldeditadditions_core.register_command("for", {
return false, "Your privileges are insufficient to run /\""..command.."\"."
end
step({
player_name = name,
cmd_name = command,
values = values,
cmd = cmd,
args = args
})
}, __callback)
-- Returning nothing + async = true means we're going async and we'll get back to `run_command` later. Until then, I'm going to have to put you on hold :P
-- * cue hold music *
-- ref https://youtu.be/3to4vaWl2dY
end
})

View file

@ -15,12 +15,20 @@ end
--- Registers a new WorldEditAdditions chat command.
--
-- **Async commands:** Set `async = true` in the definition table you pass to this function. THEN, to indicate you are doing an async thing return NO VALUES AT ALL from your main `func` that is passed the parsed arguments. When `async = true`, your main `func` will be passed a callback function as the 1st argument BEFORE all other arguments.
--
-- Call this function when you are done with all async work with the same variables you would return: success: bool, result_message: string.
--
-- **IMPORTANT:** You MUST NOT return `success, result_message` from the main function AND call the callback function in a single call of a command!
-- An example of this in action can be seen in the implementation of `//for`.
-- @param cmdname string The name of the command to register.
-- @param options table A table of options for the command:
-- - `params` (string) A textual description of the parameters the command takes.
-- - `description` (string) A description of the command.
-- - `privs` (`{someprivilege=true, ....}`) The privileges required to use the command.
-- - `require_pos` (number) The number of positions required for the command.
-- - `async=false` (bool) Whether this function is async. See the note in the description of this function for more information.
-- - `parse` (function) A function that parses the raw param_text into proper input arguments to be passed to `nodes_needed` and `func`.
-- - `nodes_needed` (function) A function that returns the number of nodes the command could potential change given the parsed input arguments.
-- - `func` (function) The function to execute when the command is run.
@ -56,6 +64,7 @@ local function register_command(cmdname, options)
---
-- 2: Normalisation
---
if options.async == nil then options.async = false end
if not options.privs then options.privs = {} end
if not options.require_pos then options.require_pos = 0 end
if not options.nodes_needed then options.nodes_needed = function() return 0 end end

View file

@ -7,6 +7,26 @@ local safe_region = dofile(weac.modpath.."/core/safe_region.lua")
local human_size = weac.format.human_size
local safe_function = weac.safe_function
--- Handles the success bool and result message string that a command's main `func` returns.
-- @param success bool Whether the command executed successfully or not.
-- @param result_message string The message (as a string) to send to the player.
local function handle_success_resultmsg(player_name, cmdname, success, result_message)
if success then
if not result_message then
result_message = "//" .. tostring(cmdname) .. " successful"
end
weac.notify.ok(player_name, result_message)
else
if not result_message then
result_message =
"An unspecified (likely user) error was returned by the command. It is a bug that a specific error message is not returned here. It is not necessarily a bug that an error was thrown: your command invocation could have contained invalid syntax, for example."
end
weac.notify.error(player_name, "[//" .. tostring(cmdname) .. "] " .. result_message)
end
return success, result_message
end
--- Actually runs the command in question. [HIDDEN]
-- Unfortunately needed to keep the codebase clena because Lua sucks.
-- @internal
@ -17,29 +37,52 @@ local safe_function = weac.safe_function
-- @returns nil
local function run_command_stage2(player_name, func, parse_result, tbl_event)
weac:emit("pre-execute", tbl_event)
local success_safefn, retvals = safe_function(func, { player_name, weac.table.unpack(parse_result) }, player_name, "The function crashed during execution.", tbl_event.cmdname)
-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#
local args = {}
-- If we're async, add the callback function as the 1st argument before everything else
if tbl_event.cmddef.async then
table.insert(args, function(success, result_message)
success, result_message = handle_success_resultmsg(player_name, tbl_event.cmdname, success, result_message)
tbl_event.success = success
tbl_event.result = result_message
weac:emit("post-execute", tbl_event)
end)
end
-- Add player_name, unpack(args_from_parse_func).... afterwards
table.insert(args, player_name)
for _,value in ipairs(parse_result) do
table.insert(args, value)
end
-- Run the cmd itself and catch errors
local success_safefn, retvals = safe_function(func, args, player_name, "The function crashed during execution.", tbl_event.cmdname)
if not success_safefn then return false end
if #retvals ~= 2 then
weac.notify.error(player_name, "[//"..tostring(tbl_event.cmdname).."] The main execution function for this chat command returned "..tostring(#retvals).." arguments instead of the expected 2 (success, message), so it is unclear whether it succeeded or not. This is a bug!")
-- BELOW: We handle the IMMEDIATE RETURN VALUE. For async commands this requires special handling as the actual exit of async commands is above.
-- If a function is async (pass `async = true` in the table passed to weac.register_command()`), then if there are no return values then we assume it was successful.
if #retvals ~= 2 and not tbl_event.cmddef.async then
weac.notify.error(player_name, "[//"..tostring(tbl_event.cmdname).."] This command is not async and the main execution function for it returned "..tostring(#retvals).." arguments instead of the expected 2 (success, message), so it is unclear whether it succeeded or not. This is a bug!")
end
if #retvals == 2 then
local success, result_message = retvals[1], retvals[2]
success, result_message = handle_success_resultmsg(player_name, tbl_event.cmdname, success, result_message)
tbl_event.success = success
tbl_event.result = result_message
end
local success, result_message = retvals[1], retvals[2]
print("DEBUG:run_command_stage2 SUCCESS", success, "RESULT_MESSAGE", result_message)
if success then
if not result_message then
result_message = "//"..tostring(tbl_event.cmdname).." successful"
end
weac.notify.ok(player_name, result_message)
else
if not result_messasge then
result_message = "An unspecified (likely user) error was returned by the command. It is a bug that a specific error message is not returned here. It is not necessarily a bug that an error was thrown: your command invocation could have contained invalid syntax, for example."
end
weac.notify.error(player_name, "[//"..tostring(tbl_event.cmdname).."] "..result_message)
-- This is outside the above before we need to fire post-execute even if `#retvals ~= 2` or something else happened
--
-- Don't fire the post-execute event if async = true unless we were explicitly told its fine. If async = false then just go right ahead anyway
if not tbl_event.cmddef.async or (tbl_event.cmddef.async and success) then
weac:emit("post-execute", tbl_event)
end
tbl_event.success = success
tbl_event.result = result_message
weac:emit("post-execute", tbl_event)
end
@ -93,7 +136,8 @@ end
-- - `paramargs` (table): The parsed arguments returned by the parsing function. Available in `post-parse` and later.
-- - `potential_changes` (number): The number of potential nodes that could be changed as a result of running the command. `post-nodesneeded` and later: remember not all commands have an associated `nodesneeded` function.
-- - `success` (boolean): Whether the command executed successfully or not. Available only in `post-execute`.
-- - `result` (any): The `result` value returned by the command function. Value depends on the command executed. Available only in `post-execute`.
-- - `result` (any): The `result` value returned by the command function. Value depends on the command executed. Available only in `post-execute`. SHOULD be a string but don't count on it.
--
-- @param cmdname string The name of the command to run.
-- @param options table The table of options associated with the command. See worldeditadditions_core.register_command for more information.
-- @param player_name string The name of the player to execute the command for.