Last active
August 29, 2024 17:43
-
-
Save MCJack123/1678fb2c240052f1480b07e9053d4537 to your computer and use it in GitHub Desktop.
Taskmaster: A simple and highly flexible task runner/coroutine manager for ComputerCraft
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- Taskmaster: A simple and highly flexible task runner/coroutine manager for ComputerCraft | |
-- Supports adding/removing tasks, early exits for tasks, event white/blacklists, automatic | |
-- terminal redirection, task pausing, promises, and more. | |
-- Made by JackMacWindows | |
-- Licensed under CC0 in the public domain | |
--[[ | |
Examples: | |
- Run three functions in parallel, and wait for any to exit. | |
require("taskmaster")( | |
func1, func2, func3 | |
):waitForAny() | |
- Run three functions in parallel, and wait for all to exit. | |
require("taskmaster")( | |
func1, func2, func3 | |
):waitForAll() | |
- Builder-style creation of three event listeners for keyboard events. | |
require("taskmaster")() | |
:eventListener("key", function(ev, key) print("Key:", keys.getName(key)) end) | |
:eventListener("key_up", function(ev, key) print("Key up:", keys.getName(key)) end) | |
:eventListener("char", function(ev, char) print("Character:", char) end) | |
:run() | |
- Create a loop with two background tasks (which don't receive user interaction events) and one foreground task. | |
The foreground task may exit itself if a specific character is pressed. | |
local loop = require("taskmaster")() | |
loop:setEventBlacklist {"key", "key_up", "char", "paste", "mouse_click", "mouse_up", "mouse_scroll", "mouse_drag"} | |
loop:addTask(bgFunc) | |
loop:addTimer(2, pollingFunction) | |
local function fgFunc(task) | |
while true do | |
local event, p1 = os.pullEvent() | |
if event == "char" and p1 == "q" then | |
task:remove() | |
end | |
end | |
end | |
local task = loop:addTask(fgFunc) | |
task:setEventBlacklist {} | |
task:setPriority(10) | |
loop:run() | |
- Fetch a remote JSON resource in parallel using promises. | |
local loop = require("taskmaster")() | |
local function main() | |
loop.Promise.fetch("https://httpbin.org/headers") | |
:next(function(handle) return handle.json() end) | |
:next(function(data) print(data.headers["User-Agent"]) end) | |
:catch(printError) | |
end | |
loop:task(main):run() | |
]] | |
local expect = require "cc.expect" | |
---@class Task | |
---@field master Taskmaster The event loop for the task | |
local Task = {} | |
local Task_mt = {__name = "Task", __index = Task} | |
--- Pauses the task, preventing it from running. This will yield if the task calls this method on itself. | |
function Task:pause() | |
self.paused = true | |
if self.master.currentTask == self then coroutine.yield() end | |
end | |
--- Unpauses the task if it was previously paused by @{Task.pause}. | |
function Task:unpause() | |
self.paused = false | |
end | |
--- Removes the task from the run loop, as if it returned. This will yield if the task calls this method on itself. | |
function Task:remove() | |
self.master.dead[#self.master.dead+1] = self | |
self.paused = true | |
if self.master.currentTask == self then coroutine.yield() end | |
end | |
--- Sets the priority of the task. This determines the order tasks are run in. | |
---@param priority number The priority of the task (0 is the default) | |
function Task:setPriority(priority) | |
expect(1, priority, "number") | |
self.priority = priority | |
self.master.shouldSort = true | |
end | |
--- Sets a blacklist for events to send to this task. | |
---@param list? string[] A list of events to not send to this task | |
function Task:setEventBlacklist(list) | |
if expect(1, list, "table", "nil") then | |
self.blacklist = {} | |
for _, v in ipairs(list) do self.blacklist[v] = true end | |
else self.blacklist = nil end | |
end | |
--- Sets a whitelist for events to send to this task. | |
---@param list? string[] A list of events to send to this task (others are discarded) | |
function Task:setEventWhitelist(list) | |
if expect(1, list, "table", "nil") then | |
self.whitelist = {} | |
for _, v in ipairs(list) do self.whitelist[v] = true end | |
else self.whitelist = nil end | |
end | |
--- Sets an error handler for a task. | |
---@param errh? fun(err: any, task: Task) A function to call if the task throws an error | |
function Task:setErrorHandler(errh) | |
self.errh = expect(1, errh, "function", "nil") | |
end | |
---@class Promise | |
---@field private task Task | |
---@field private resolve fun(...: any)|nil | |
---@field private reject fun(err: any)|nil | |
---@field private final fun()|nil | |
local Promise = {} | |
local Promise_mt = {__name = "Promise", __index = Promise} | |
--- Creates a new Promise on the selected run loop. | |
---@param loop Taskmaster The loop to create the promise on | |
---@param fn fun(resolve: fun(...: any), reject: fun(err: any)) The main function for the promise | |
---@return Promise promise The new promise | |
function Promise:new(loop, fn) | |
expect(1, loop, "table") | |
expect(2, fn, "function") | |
local obj = setmetatable({}, Promise_mt) | |
obj.task = loop:addTask(function() | |
local ok, err = pcall(fn, | |
function(...) if obj.resolve then return obj.resolve(...) end end, | |
function(err) | |
while obj do | |
if obj.reject then return obj.reject(err) end | |
obj = obj.next_promise | |
end | |
end | |
) | |
if not ok and obj.reject then obj.reject(err) end | |
end) | |
return obj | |
end | |
--- Creates a new Promise that resolves once all of the listed promises resolve. | |
---@param loop Taskmaster The loop to create the promise on | |
---@param list Promise[] The promises to wait for | |
---@return Promise promise The new promise | |
function Promise:all(loop, list) | |
expect(1, loop, "table") | |
expect(2, list, "table") | |
return Promise:new(loop, function(resolve, reject) | |
local count = 0 | |
for _, v in ipairs(list) do | |
v:next(function(...) | |
count = count + 1 | |
if count == #list then resolve(...) end | |
end, reject) | |
end | |
end) | |
end | |
--- Creates a new Promise that resolves once any of the listed promises resolve, or rejects if all promises reject. | |
---@param loop Taskmaster The loop to create the promise on | |
---@param list Promise[] The promises to wait for | |
---@return Promise promise The new promise | |
function Promise:any(loop, list) | |
expect(1, loop, "table") | |
expect(2, list, "table") | |
return Promise:new(loop, function(resolve, reject) | |
local count = 0 | |
for _, v in ipairs(list) do | |
v:next(resolve, function(err) | |
count = count + 1 | |
if count == #list then reject(err) end | |
end) | |
end | |
end) | |
end | |
--- Creates a new Promise that resolves once any of the listed promises resolve. | |
---@param loop Taskmaster The loop to create the promise on | |
---@param list Promise[] The promises to wait for | |
---@return Promise promise The new promise | |
function Promise:race(loop, list) | |
expect(1, loop, "table") | |
expect(2, list, "table") | |
return Promise:new(loop, function(resolve, reject) | |
for _, v in ipairs(list) do v:next(resolve, reject) end | |
end) | |
end | |
--- Creates a new Promise that immediately resolves to a value. | |
---@param loop Taskmaster The loop to create the promise on | |
---@param val any The value to resolve to | |
---@return Promise promise The new promise | |
function Promise:_resolve(loop, val) | |
expect(1, loop, "table") | |
local obj = setmetatable({}, Promise_mt) | |
obj.task = loop:addTask(function() | |
if obj.resolve then obj.resolve(val) end | |
end) | |
return obj | |
end | |
--- Creates a new Promise that immediately rejects with an error. | |
---@param loop Taskmaster The loop to create the promise on | |
---@param err any The value to resolve to | |
---@return Promise promise The new promise | |
function Promise:_reject(loop, err) | |
expect(1, loop, "table") | |
local obj = setmetatable({}, Promise_mt) | |
obj.task = loop:addTask(function() | |
if obj.reject then obj.reject(err) end | |
end) | |
return obj | |
end | |
--- Adds a function to call when the promise resolves. | |
---@param fn fun(...: any): Promise|nil The function to call | |
---@param err? fun(err: any) A function to catch errors | |
---@return Promise next The next promise in the chain | |
function Promise:next(fn, err) | |
expect(1, fn, "function") | |
expect(2, err, "function", "nil") | |
self.resolve = function(...) | |
self.resolve = nil | |
local res = fn(...) | |
if self.next_promise then | |
if type(res) == "table" and getmetatable(res) == Promise_mt then | |
for k, v in pairs(self.next_promise) do res[k] = v end | |
self.next_promise = res | |
else | |
self.next_promise.resolve(res) | |
end | |
end | |
if self.final then self.final() end | |
end | |
if err then self.reject = function(v) self.reject = nil err(v) if self.final then self.final() end end end | |
self.next_promise = setmetatable({}, Promise_mt) | |
return self.next_promise | |
end | |
Promise.Then = Promise.next | |
--- Sets the error handler for the promise. | |
---@param fn fun(err: any) The error handler to use | |
---@return Promise self | |
function Promise:catch(fn) | |
expect(1, fn, "function") | |
self.reject = function(err) self.reject = nil fn(err) if self.final then self.final() end end | |
return self | |
end | |
--- Sets a function to call after the promise settles. | |
---@param fn fun() The function to call | |
---@return Promise self | |
function Promise:finally(fn) | |
expect(1, fn, "function") | |
self.final = function() self.final = nil return fn() end | |
return self | |
end | |
---@diagnostic disable: missing-return | |
---@class PromiseConstructor | |
local PromiseConstructor = {} | |
--- Creates a new Promise on the selected run loop. | |
---@param fn fun(resolve: fun(...: any), reject: fun(err: any)) The main function for the promise | |
---@return Promise promise The new promise | |
function PromiseConstructor.new(fn) end | |
--- Creates a new Promise that resolves once all of the listed promises resolve. | |
---@param list Promise[] The promises to wait for | |
---@return Promise promise The new promise | |
function PromiseConstructor.all(list) end | |
--- Creates a new Promise that resolves once any of the listed promises resolve, or rejects if all promises reject. | |
---@param list Promise[] The promises to wait for | |
---@return Promise promise The new promise | |
function PromiseConstructor.any(list) end | |
--- Creates a new Promise that resolves once any of the listed promises resolve. | |
---@param list Promise[] The promises to wait for | |
---@return Promise promise The new promise | |
function PromiseConstructor.race(list) end | |
--- Creates a new Promise that immediately resolves to a value. | |
---@param val any The value to resolve to | |
---@return Promise promise The new promise | |
function PromiseConstructor.resolve(val) end | |
--- Creates a new Promise that immediately rejects with an error. | |
---@param err any The value to resolve to | |
---@return Promise promise The new promise | |
function PromiseConstructor.reject(err) end | |
--- Makes an HTTP request to a URL, and returns a Promise for the result. | |
--- The promise will resolve with the handle to the response, which will also | |
--- have the following methods: | |
--- - res.text(): Returns a promise that resolves to the body of the response. | |
--- - res.table(): Returns a promise that resolves to the body unserialized as a Lua table. | |
--- - res.json(): Returns a promise that resolves to the body unserialized as JSON. | |
---@param url string The URL to connect to | |
---@param body? string If specified, a POST body to send | |
---@param headers? table<string, string> Any HTTP headers to add to the request | |
---@param binary? boolean Whether to send in binary mode (deprecated as of CC:T 1.109.0) | |
---@overload fun(options: {url: string, body?: string, headers?: string, method?: string, binary?: string, timeout?: number}): Promise | |
---@return Promise promise The new promise | |
function PromiseConstructor.fetch(url, body, headers, binary) end | |
---@diagnostic enable: missing-return | |
---@class Taskmaster | |
---@field Promise PromiseConstructor | |
local Taskmaster = {} | |
local Taskmaster_mt = {__name = "Taskmaster", __index = Taskmaster} | |
--- Adds a task to the loop. | |
---@param fn fun(Task) The main function to add, which receives the task as an argument | |
---@return Task task The created task | |
function Taskmaster:addTask(fn) | |
expect(1, fn, "function") | |
local task = setmetatable({coro = coroutine.create(fn), master = self, priority = 0}, Task_mt) | |
self.new[#self.new+1] = task | |
self.shouldSort = true | |
return task | |
end | |
--- Adds a task to the loop in builder style. | |
---@param fn fun(Task) The main function to add | |
---@return Taskmaster self | |
function Taskmaster:task(fn) self:addTask(fn) return self end | |
--- Adds a function to the loop. This is just like a task, but allows extra arguments. | |
---@param fn function The main function to add, which receives the arguments passed | |
---@param ... any Any arguments to pass to the function | |
---@return Task task The created task | |
function Taskmaster:addFunction(fn, ...) | |
expect(1, fn, "function") | |
local args = table.pack(...) | |
local task = setmetatable({coro = coroutine.create(function() return fn(table.unpack(args, 1, args.n)) end), master = self, priority = 0}, Task_mt) | |
self.new[#self.new+1] = task | |
self.shouldSort = true | |
return task | |
end | |
--- Adds a function to the loop in builder style. | |
---@param fn function The main function to add | |
---@param ... any Any arguments to pass to the function | |
---@return Taskmaster self | |
function Taskmaster:func(fn, ...) self:addFunction(fn, ...) return self end | |
--- Adds an event listener to the loop. This is a special task that calls a function whenever an event is triggered. | |
---@param name string The name of the event to listen for | |
---@param fn fun(string, ...) The function to call for each event | |
---@return Task task The created task | |
function Taskmaster:addEventListener(name, fn) | |
expect(1, name, "string") | |
expect(2, fn, "function") | |
local task = setmetatable({coro = coroutine.create(function() while true do fn(os.pullEvent(name)) end end), master = self, priority = 0}, Task_mt) | |
self.new[#self.new+1] = task | |
self.shouldSort = true | |
return task | |
end | |
--- Adds an event listener to the loop in builder style. This is a special task that calls a function whenever an event is triggered. | |
---@param name string The name of the event to listen for | |
---@param fn fun(string, ...) The function to call for each event | |
---@return Taskmaster self | |
function Taskmaster:eventListener(name, fn) self:addEventListener(name, fn) return self end | |
--- Adds a task that triggers a function repeatedly after an interval. The function may modify or cancel the interval through a return value. | |
---@param timeout number The initial interval to run the function after | |
---@param fn fun():number|nil The function to call. | |
---If this returns a number, that number replaces the timeout. | |
---If this returns a number less than or equal to 0, the timer is canceled. | |
---If this returns nil, the timeout remains the same. | |
---@return Task task The created task | |
function Taskmaster:addTimer(timeout, fn) | |
expect(1, timeout, "number") | |
expect(2, fn, "function") | |
local task = setmetatable({coro = coroutine.create(function() | |
while true do | |
sleep(timeout) | |
timeout = fn() or timeout | |
if timeout <= 0 then return end | |
end | |
end), master = self, priority = 0}, Task_mt) | |
self.new[#self.new+1] = task | |
self.shouldSort = true | |
return task | |
end | |
--- Adds a task that triggers a function repeatedly after an interval in builder style. The function may modify or cancel the interval through a return value. | |
---@param timeout number The initial interval to run the function after | |
---@param fn fun():number|nil The function to call. | |
---If this returns a number, that number replaces the timeout. | |
---If this returns a number less than or equal to 0, the timer is canceled. | |
---If this returns nil, the timeout remains the same. | |
---@return Taskmaster self | |
function Taskmaster:timer(timeout, fn) self:addTimer(timeout, fn) return self end | |
--- Sets a blacklist for events to send to all tasks. Tasks can override this with their own blacklist. | |
---@param list? string[] A list of events to not send to any task | |
function Taskmaster:setEventBlacklist(list) | |
if expect(1, list, "table", "nil") then | |
self.blacklist = {} | |
for _, v in ipairs(list) do self.blacklist[v] = true end | |
else self.blacklist = nil end | |
end | |
--- Sets a whitelist for events to send to all tasks. Tasks can override this with their own whitelist. | |
---@param list? string[] A list of events to send to all tasks (others are discarded) | |
function Taskmaster:setEventWhitelist(list) | |
if expect(1, list, "table", "nil") then | |
self.whitelist = {} | |
for _, v in ipairs(list) do self.whitelist[v] = true end | |
else self.whitelist = nil end | |
end | |
--- Sets a function that is used to transform events. This function takes a task | |
--- and event table, and may modify the event table to adjust the event for that task. | |
---@param fn fun(Task, table)|nil A function to use to transform events | |
function Taskmaster:setEventTransformer(fn) | |
expect(1, fn, "function", "nil") | |
self.transformer = fn | |
end | |
--- Sets a function to call before yielding. This can be used to reset state such | |
--- as terminal cursor position. | |
---@param fn? fun() The function to call | |
function Taskmaster:setPreYieldHook(fn) | |
expect(1, fn, "function", "nil") | |
self.preYieldHook = fn | |
end | |
--- Runs the main loop, processing events and running each task. | |
---@param count? number The number of tasks that can exit before stopping the loop | |
function Taskmaster:run(count) | |
count = expect(1, count, "number", "nil") or math.huge | |
self.running = true | |
while self.running and (#self.tasks + #self.new) > 0 and count > 0 do | |
self.dead = {} | |
for i, task in ipairs(self.new) do | |
self.currentTask = task | |
local old = term.current() | |
local ok, filter = coroutine.resume(task.coro, task) | |
task.window = term.redirect(old) | |
if not ok then | |
self.currentTask = nil | |
self.running = false | |
self.new = {table.unpack(self.new, i + 1)} | |
return error(filter, 0) | |
end | |
task.filter = filter | |
if coroutine.status(task.coro) == "dead" then count = count - 1 | |
else self.tasks[#self.tasks+1], self.shouldSort = task, true end | |
if not self.running or count <= 0 then break end | |
end | |
self.new = {} | |
if self.shouldSort then table.sort(self.tasks, function(a, b) return a.priority > b.priority end) self.shouldSort = false end | |
if self.running and #self.tasks > 0 and count > 0 then | |
if self.preYieldHook then self.preYieldHook() end | |
local _ev = table.pack(os.pullEventRaw()) | |
for i, task in ipairs(self.tasks) do | |
local ev = _ev | |
if self.transformer then | |
ev = table.pack(table.unpack(_ev, 1, _ev.n)) | |
self.transformer(task, ev) | |
end | |
local wl, bl = task.whitelist or self.whitelist, task.blacklist or self.blacklist | |
if not task.paused and | |
(task.filter == nil or task.filter == ev[1] or ev[1] == "terminate") and | |
(not bl or not bl[ev[1]]) and | |
(not wl or wl[ev[1]]) then | |
self.currentTask = task | |
local old = term.redirect(task.window) | |
local ok, filter = coroutine.resume(task.coro, table.unpack(ev, 1, ev.n)) | |
task.window = term.redirect(old) | |
if not ok then | |
if task.errh then | |
task.errh(filter, task) | |
else | |
self.currentTask = nil | |
self.running = false | |
table.remove(self.tasks, i) | |
return error(filter, 0) | |
end | |
end | |
task.filter = filter | |
if coroutine.status(task.coro) == "dead" then self.dead[#self.dead+1] = task end | |
if not self.running or #self.dead >= count then break end | |
end | |
end | |
end | |
self.currentTask = nil | |
for _, task in ipairs(self.dead) do | |
for i, v in ipairs(self.tasks) do | |
if v == task then | |
table.remove(self.tasks, i) | |
count = count - 1 | |
break | |
end | |
end | |
end | |
end | |
self.running = false | |
end | |
--- Runs all tasks until a single task exits. | |
function Taskmaster:waitForAny() return self:run(1) end | |
--- Runs all tasks until all tasks exit. | |
function Taskmaster:waitForAll() return self:run() end | |
--- Stops the main loop if it is running. This will yield if called from a running task. | |
function Taskmaster:stop() | |
self.running = false | |
if self.currentTask then coroutine.yield() end | |
end | |
Taskmaster_mt.__call = Taskmaster.run | |
local function fetch(loop, url, ...) | |
local ok, err = http.request(url, ...) | |
if not ok then return Promise:_reject(loop, err) end | |
return loop.Promise.new(function(resolve, reject) | |
while true do | |
local event, p1, p2, p3 = os.pullEvent() | |
if event == "http_success" and p1 == url then | |
p2.text = function() | |
return loop.Promise.new(function(_resolve, _reject) | |
local data = p2.readAll() | |
p2.close() | |
_resolve(data) | |
end) | |
end | |
p2.json = function() | |
return loop.Promise.new(function(_resolve, _reject) | |
local data = p2.readAll() | |
p2.close() | |
local d = textutils.unserializeJSON(data) | |
if d ~= nil then _resolve(d) | |
else _reject("Failed to parse JSON") end | |
end) | |
end | |
p2.table = function() | |
return loop.Promise.new(function(_resolve, _reject) | |
local data = p2.readAll() | |
p2.close() | |
local d = textutils.unserialize(data) | |
if d ~= nil then _resolve(d) | |
else _reject("Failed to parse Lua table") end | |
end) | |
end | |
return resolve(p2) | |
elseif event == "http_failure" and p1 == url then | |
if p3 then p3.close() end | |
return reject(p2) | |
end | |
end | |
end) | |
end | |
--- Creates a new Taskmaster run loop. | |
---@param ... fun() Any tasks to add to the loop | |
---@return Taskmaster loop The new Taskmaster | |
return function(...) | |
local loop = setmetatable({tasks = {}, dead = {}, new = {}}, Taskmaster_mt) | |
for i, v in ipairs{...} do | |
expect(i, v, "function") | |
loop:addTask(v) | |
end | |
loop.Promise = { | |
new = function(fn) return Promise:new(loop, fn) end, | |
all = function(list) return Promise:all(loop, list) end, | |
any = function(list) return Promise:any(loop, list) end, | |
race = function(list) return Promise:race(loop, list) end, | |
resolve = function(val) return Promise:_resolve(loop, val) end, | |
reject = function(err) return Promise:_reject(loop, err) end, | |
fetch = function(...) return fetch(loop, ...) end | |
} | |
setmetatable(loop.Promise, {__call = function(self, ...) return Promise:new(loop, ...) end}) | |
return loop | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment