diff --git a/plugins/autofrontlight.koplugin/main.lua b/plugins/autofrontlight.koplugin/main.lua index 302845f7c..e742d6ddd 100644 --- a/plugins/autofrontlight.koplugin/main.lua +++ b/plugins/autofrontlight.koplugin/main.lua @@ -8,6 +8,7 @@ end local ConfirmBox = require("ui/widget/confirmbox") local DataStorage = require("datastorage") local LuaSettings = require("luasettings") +local PluginShare = require("pluginshare") local UIManager = require("ui/uimanager") local WidgetContainer = require("ui/widget/container/widgetcontainer") local logger = require("logger") @@ -21,27 +22,34 @@ local AutoFrontlight = { last_brightness = -1, } +PluginShare.backgroundJobs = PluginShare.backgroundJobs or {} + function AutoFrontlight:_schedule(settings_id) - if not self.enabled then - logger.dbg("AutoFrontlight:_schedule() is disabled") - return - end - if settings_id ~= self.settings_id then - logger.dbg("AutoFrontlight:_schedule(): registered settings_id ", - settings_id, - " does not equal to current one ", - self.settings_id) - return + local enabled = function() + if not self.enabled then + logger.dbg("AutoFrontlight:_schedule() is disabled") + return false + end + if settings_id ~= self.settings_id then + logger.dbg("AutoFrontlight:_schedule(): registered settings_id ", + settings_id, + " does not equal to current one ", + self.settings_id) + return false + end + + return true end - logger.dbg("AutoFrontlight:_schedule() starts with settings_id ", settings_id) - - self:_action() - - logger.dbg("AutoFrontlight:_schedule() @ ", os.time(), ", it should be executed at ", os.time() + 2) - UIManager:scheduleIn(2, function() - self:_schedule(settings_id) - end) + table.insert(PluginShare.backgroundJobs, { + when = 2, + repeated = enabled, + executable = function() + if enabled() then + self:_action() + end + end + }) end function AutoFrontlight:_action() diff --git a/plugins/backgroundrunner.koplugin/commandrunner.lua b/plugins/backgroundrunner.koplugin/commandrunner.lua new file mode 100644 index 000000000..60d9892d4 --- /dev/null +++ b/plugins/backgroundrunner.koplugin/commandrunner.lua @@ -0,0 +1,90 @@ +local logger = require("logger") + +local CommandRunner = { + pio = nil, + job = nil, +} + +function CommandRunner:createEnvironmentFromTable(t) + if t == nil then return "" end + + local r = "" + for k, v in pairs(t) do + r = r .. k .. "=" .. v .. " " + end + + if string.len(r) > 0 then r = "export " .. r .. ";" end + return r +end + +function CommandRunner:createEnvironment() + if type(self.job.environment) == "table" then + return self:createEnvironmentFromTable(self.job.environment) + end + if type(self.job.environment) == "function" then + local status, result = pcall(self.job.environment) + if status then + return self:createEnvironmentFromTable(result) + end + end + return "" +end + +function CommandRunner:start(job) + assert(self ~= nil) + assert(self.pio == nil) + assert(self.job == nil) + self.job = job + self.job.start_sec = os.time() + assert(type(self.job.executable) == "string") + local command = self:createEnvironment() .. " " .. + "sh plugins/backgroundrunner.koplugin/luawrapper.sh " .. + "\"" .. self.job.executable .. "\"" + logger.dbg("CommandRunner: Will execute command " .. command) + self.pio = io.popen(command) +end + +--- Polls the status of self.pio. +-- @return a table contains the result from luawrapper.sh. Returns nil if the +-- command has not been finished. +function CommandRunner:poll() + assert(self ~= nil) + assert(self.pio ~= nil) + assert(self.job ~= nil) + local line = self.pio:read() + if line == "" then + return nil + else + if line == nil then + -- The binary crashes without output. This should not happen. + self.job.result = 223 + else + line = line .. self.pio:read("*a") + logger.dbg("CommandRunner: Receive output " .. line) + local status, result = pcall(loadstring(line)) + if status and result ~= nil then + for k, v in pairs(result) do + self.job[k] = v + end + else + -- The output from binary is invalid. + self.job.result = 222 + end + end + self.pio:close() + self.pio = nil + self.job.end_sec = os.time() + local job = self.job + self.job = nil + return job + end +end + +--- Whether this is a running job. +-- @treturn boolean +function CommandRunner:pending() + assert(self ~= nil) + return self.pio ~= nil +end + +return CommandRunner diff --git a/plugins/backgroundrunner.koplugin/luawrapper.sh b/plugins/backgroundrunner.koplugin/luawrapper.sh new file mode 100755 index 000000000..21aad4381 --- /dev/null +++ b/plugins/backgroundrunner.koplugin/luawrapper.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +# Converts the return of "sh wrapper.sh $@" into Lua format. + +CURRENT_DIR=$(dirname "$0") +sh "$CURRENT_DIR/wrapper.sh" "$@" >/dev/null 2>&1 & +JOB_ID=$! + +while true; do + if ps -p $JOB_ID >/dev/null 2>&1; then + # Unblock f:read(). + echo + else + wait $JOB_ID + EXIT_CODE=$? + if [ "$EXIT_CODE" -eq "255" ]; then + TIMEOUT="true" + else + TIMEOUT="false" + fi + + if [ "$EXIT_CODE" -eq "127" ]; then + BADCOMMAND="true" + else + BADCOMMAND="false" + fi + + echo "return { \ + result = $EXIT_CODE, \ + timeout = $TIMEOUT, \ + bad_command = $BADCOMMAND, \ + }" + exit 0 + fi +done diff --git a/plugins/backgroundrunner.koplugin/main.lua b/plugins/backgroundrunner.koplugin/main.lua new file mode 100644 index 000000000..18ffa1be2 --- /dev/null +++ b/plugins/backgroundrunner.koplugin/main.lua @@ -0,0 +1,234 @@ +local CommandRunner = require("commandrunner") +local PluginShare = require("pluginshare") +local UIManager = require("ui/uimanager") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local logger = require("logger") + +-- BackgroundRunner is an experimental feature to execute non-critical jobs in +-- background. A job is defined as a table in PluginShare.backgroundJobs table. +-- It contains at least following items: +-- when: number, string or function +-- number: the delay in seconds +-- string: "best-effort" - the job will be started when there is no other jobs +-- to be executed. +-- "idle" - the job will be started when the device is idle. +-- function: if the return value of the function is true, the job will be +-- executed immediately. +-- +-- repeated: boolean or function or nil or number +-- boolean: true to repeated the job once it finished. +-- function: if the return value of the function is true, repeated the job +-- once it finished. If the function throws an error, it equals to +-- return false. +-- nil: same as false. +-- number: times to repeat. +-- +-- executable: string or function +-- string: the command line to be executed. The command or binary will be +-- executed in the lowest priority. Command or binary will be killed +-- if it executes for over 1 hour. +-- function: the action to be executed. The execution cannot be killed, but it +-- will be considered as timeout if it executes for more than 1 +-- second. +-- If the executable times out, the job will be blocked, i.e. the repeated +-- field will be ignored. +-- +-- environment: table or function or nil +-- table: the key-value pairs of all environments set for string executable. +-- function: the function to return a table of environments. +-- nil: ignore. +-- +-- callback: function or nil +-- function: the action to be executed when executable has been finished. +-- Errors thrown from this function will be ignored. +-- nil: ignore. +-- +-- If a job does not contain enough information, it will be ignored. +-- +-- Once the job is finished, several items will be added to the table: +-- result: number, the return value of the command. In general, 0 means +-- succeeded. +-- For function executable, 1 if the function throws an error. +-- For string executable, several predefined values indicate the +-- internal errors. E.g. 223: the binary crashes. 222: the output is +-- invalid. 127: the command is invalid. 255: the command timed out. +-- Typically, consumers can use following states instead of hardcodeing +-- the error codes. +-- exception: error, the error returned from function executable. Not available +-- for string executable. +-- timeout: boolean, whether the command times out. +-- bad_command: boolean, whether the command is not found. Not available for +-- function executable. +-- blocked: boolean, whether the job is blocked. +-- start_sec: number, the os.time() when the job was started. +-- end_sec: number, the os.time() when the job was stopped. +-- insert_sec: number, the os.time() when the job was inserted into queue. + +PluginShare.backgroundJobs = PluginShare.backgroundJobs or {} + +local BackgroundRunner = { + jobs = PluginShare.backgroundJobs, +} + +--- Copies required fields from |job|. +-- @return a new table with required fields of a valid job. +function BackgroundRunner:_clone(job) + assert(job ~= nil) + local result = {} + result.when = job.when + result.repeated = job.repeated + result.executable = job.executable + result.callback = job.callback + result.environment = job.environment + return result +end + +function BackgroundRunner:_shouldRepeat(job) + if type(job.repeated) == "nil" then return false end + if type(job.repeated) == "boolean" then return job.repeated end + if type(job.repeated) == "function" then + local status, result = pcall(job.repeated) + if status then + return result + else + return false + end + end + if type(job.repeated) == "number" then + job.repeated = job.repeated - 1 + return job.repeated > 0 + end + + return false +end + +function BackgroundRunner:_finishJob(job) + assert(self ~= nil) + if type(job.executable) == "function" then + job.timeout = ((job.end_sec - job.start_sec) > 1) + end + job.blocked = job.timeout + if not job.blocked and self:_shouldRepeat(job) then + self:_insert(self:_clone(job)) + end + if type(job.callback) == "function" then + pcall(job.callback) + end +end + +--- Executes |job|. +-- @treturn boolean true if job is valid. +function BackgroundRunner:_executeJob(job) + assert(not CommandRunner:pending()) + if job == nil then return false end + if job.executable == nil then return false end + + if type(job.executable) == "string" then + CommandRunner:start(job) + return true + elseif type(job.executable) == "function" then + job.start_sec = os.time() + local status, err = pcall(job.executable) + if status then + job.result = 0 + else + job.result = 1 + job.exception = err + end + job.end_sec = os.time() + self:_finishJob(job) + return true + else + return false + end +end + +--- Polls the status of the pending CommandRunner. +function BackgroundRunner:_poll() + assert(self ~= nil) + assert(CommandRunner:pending()) + local result = CommandRunner:poll() + if result == nil then return end + + self:_finishJob(result) +end + +function BackgroundRunner:_execute() + logger.dbg("BackgroundRunner: _execute() @ ", os.time()) + assert(self ~= nil) + if CommandRunner:pending() then + self:_poll() + else + local round = 0 + while #self.jobs > 0 do + local job = table.remove(self.jobs, 1) + if job.insert_sec == nil then + -- Jobs are first inserted to jobs table from external users. So + -- they may not have insert_sec field. + job.insert_sec = os.time() + end + local should_execute = false + local should_ignore = false + if type(job.when) == "function" then + local status, result = pcall(job.when) + if status then + should_execute = result + else + should_ignore = true + end + elseif type(job.when) == "number" then + if job.when >= 0 then + should_execute = ((os.time() - job.insert_sec) >= job.when) + else + should_ignore = true + end + elseif type(job.when) == "string" then + -- TODO(Hzj_jie): Implement "idle" mode + if job.when == "best-effort" then + should_execute = (round > 0) + elseif job.when == "idle" then + should_execute = (round > 1) + else + should_ignore = true + end + else + should_ignore = true + end + + if should_execute then + assert(not should_ignore) + self:_executeJob(job) + break + elseif not should_ignore then + table.insert(self.jobs, job) + end + + round = round + 1 + if round > 2 then break end + end + end + + if PluginShare.stopBackgroundRunner == nil then + self:_schedule() + end +end + +function BackgroundRunner:_schedule() + assert(self ~= nil) + UIManager:scheduleIn(2, function() self:_execute() end) +end + +function BackgroundRunner:_insert(job) + assert(self ~= nil) + job.insert_sec = os.time() + table.insert(self.jobs, job) +end + +BackgroundRunner:_schedule() + +local BackgroundRunnerWidget = WidgetContainer:new{ + name = "backgroundrunner", + runner = BackgroundRunner, +} + +return BackgroundRunnerWidget diff --git a/plugins/backgroundrunner.koplugin/wrapper.sh b/plugins/backgroundrunner.koplugin/wrapper.sh new file mode 100755 index 000000000..e468fea2f --- /dev/null +++ b/plugins/backgroundrunner.koplugin/wrapper.sh @@ -0,0 +1,39 @@ +#!/bin/sh + +# Starts the arguments as a bash command in background with low priorty. The +# command will be killed if it executes for over 1 hour. If the command failed +# to start, this script returns 127. If the command is timed out, this script +# returns 255. Otherwise the return value of the command will be returned. + +echo "TIMEOUT in environment: $TIMEOUT" + +if [ -z "$TIMEOUT" ]; then + TIMEOUT=3600 +fi + +echo "Timeout has been set to $TIMEOUT seconds" + +echo "Will start command $*" + +echo "$@" | nice -n 19 sh & +JOB_ID=$! +echo "Job id: $JOB_ID" + +for i in $(seq 1 1 $TIMEOUT); do + if ps -p $JOB_ID >/dev/null 2>&1; then + # Job is still running. + sleep 1 + ROUND=$(printf "%s" "$i" | tail -c 1) + if [ "$ROUND" -eq "0" ]; then + echo "Job $JOB_ID is still running ... waited for $i seconds." + fi + else + wait $JOB_ID + exit $? + fi +done + +echo "Command $* has timed out" + +kill -9 $JOB_ID +exit 255 diff --git a/spec/unit/autofrontlight_spec.lua b/spec/unit/autofrontlight_spec.lua index 91992e78c..1d6730429 100644 --- a/spec/unit/autofrontlight_spec.lua +++ b/spec/unit/autofrontlight_spec.lua @@ -1,5 +1,5 @@ describe("AutoFrontlight widget tests", function() - local Device, PowerD, MockTime, AutoFrontlight + local Device, PowerD, MockTime, class, AutoFrontlight, UIManager setup(function() require("commonrequire") @@ -41,20 +41,28 @@ describe("AutoFrontlight widget tests", function() open(require("datastorage"):getSettingsDir() .. "/autofrontlight.lua"): saveSetting("enable", "true"): close() - require("ui/uimanager")._run_forever = true + + UIManager = require("ui/uimanager") + UIManager._run_forever = true + + requireBackgroundRunner() + class = dofile("plugins/autofrontlight.koplugin/main.lua") + + -- Ensure the background runner has succeeded set the job.insert_sec. + MockTime:increase(2) + UIManager:handleInput() end) after_each(function() AutoFrontlight:deprecateLastTask() -- Ensure the scheduled task from this test case won't impact others. MockTime:increase(2) - require("ui/uimanager"):handleInput() + UIManager:handleInput() AutoFrontlight = nil + stopBackgroundRunner() end) it("should automatically turn on or off frontlight", function() - local UIManager = require("ui/uimanager") - local class = dofile("plugins/autofrontlight.koplugin/main.lua") AutoFrontlight = class:new() Device.brightness = 3 MockTime:increase(2) @@ -99,8 +107,6 @@ describe("AutoFrontlight widget tests", function() end) it("should turn on frontlight at the begining", function() - local UIManager = require("ui/uimanager") - local class = dofile("plugins/autofrontlight.koplugin/main.lua") Device.brightness = 0 AutoFrontlight = class:new() MockTime:increase(2) @@ -109,8 +115,6 @@ describe("AutoFrontlight widget tests", function() end) it("should turn off frontlight at the begining", function() - local UIManager = require("ui/uimanager") - local class = dofile("plugins/autofrontlight.koplugin/main.lua") Device.brightness = 3 AutoFrontlight = class:new() MockTime:increase(2) @@ -119,8 +123,6 @@ describe("AutoFrontlight widget tests", function() end) it("should handle configuration update", function() - local UIManager = require("ui/uimanager") - local class = dofile("plugins/autofrontlight.koplugin/main.lua") Device.brightness = 0 AutoFrontlight = class:new() MockTime:increase(2) diff --git a/spec/unit/background_runner_spec.lua b/spec/unit/background_runner_spec.lua new file mode 100644 index 000000000..ff31f32d6 --- /dev/null +++ b/spec/unit/background_runner_spec.lua @@ -0,0 +1,292 @@ +describe("BackgroundRunner widget tests", function() + local Device, PluginShare, MockTime, UIManager + + setup(function() + require("commonrequire") + package.unloadAll() + -- Device needs to be loaded before UIManager. + Device = require("device") + Device.input.waitEvent = function() end + PluginShare = require("pluginshare") + MockTime = require("mock_time") + MockTime:install() + UIManager = require("ui/uimanager") + UIManager._run_forever = true + requireBackgroundRunner() + end) + + teardown(function() + MockTime:uninstall() + package.unloadAll() + stopBackgroundRunner() + end) + + it("should start job", function() + local executed = false + table.insert(PluginShare.backgroundJobs, { + when = 10, + executable = function() + executed = true + end, + }) + MockTime:increase(2) + UIManager:handleInput() + MockTime:increase(9) + UIManager:handleInput() + assert.is_false(executed) + MockTime:increase(2) + UIManager:handleInput() + assert.is_true(executed) + end) + + it("should repeat job", function() + local executed = 0 + table.insert(PluginShare.backgroundJobs, { + when = 1, + repeated = function() return executed < 10 end, + executable = function() + executed = executed + 1 + end, + }) + + MockTime:increase(2) + UIManager:handleInput() + + for i = 1, 10 do + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(i, executed) + end + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(10, executed) + end) + + it("should repeat job for predefined times", function() + local executed = 0 + table.insert(PluginShare.backgroundJobs, { + when = 1, + repeated = 10, + executable = function() + executed = executed + 1 + end, + }) + + MockTime:increase(2) + UIManager:handleInput() + + for i = 1, 10 do + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(i, executed) + end + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(10, executed) + end) + + it("should block long job", function() + local executed = 0 + local job = { + when = 1, + repeated = true, + executable = function() + executed = executed + 1 + MockTime:increase(2) + end, + } + table.insert(PluginShare.backgroundJobs, job) + + MockTime:increase(2) + UIManager:handleInput() + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(1, executed) + assert.is_true(job.timeout) + assert.is_true(job.blocked) + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(1, executed) + end) + + it("should execute binary", function() + local executed = false + local job = { + when = 1, + executable = "ls | grep this-should-not-be-a-file", + callback = function() + executed = true + end, + } + table.insert(PluginShare.backgroundJobs, job) + + while job.end_sec == nil do + MockTime:increase(2) + UIManager:handleInput() + end + + -- grep should return 1 when there is no match. + assert.are.equal(1, job.result) + assert.is_false(job.timeout) + assert.is_false(job.bad_command) + assert.is_true(executed) + end) + + it("should forward string environment to the executable", function() + local job = { + when = 1, + repeated = false, + executable = "echo $ENV1 | grep $ENV2", + environment = { + ENV1 = "yes", + ENV2 = "yes", + } + } + table.insert(PluginShare.backgroundJobs, job) + + while job.end_sec == nil do + MockTime:increase(2) + UIManager:handleInput() + end + + -- grep should return 0 when there is a match. + assert.are.equal(0, job.result) + assert.is_false(job.timeout) + assert.is_false(job.bad_command) + + job.environment = { + ENV1 = "yes", + ENV2 = "no", + } + job.end_sec = nil + table.insert(PluginShare.backgroundJobs, job) + + while job.end_sec == nil do + MockTime:increase(2) + UIManager:handleInput() + end + + -- grep should return 1 when there is no match. + assert.are.equal(1, job.result) + assert.is_false(job.timeout) + assert.is_false(job.bad_command) + + assert.are.not_equal(os.getenv("ENV1"), "yes") + assert.are.not_equal(os.getenv("ENV2"), "yes") + assert.are.not_equal(os.getenv("ENV2"), "no") + end) + + it("should forward function environment to the executable", function() + local env2 = "yes" + local job = { + when = 1, + repeated = false, + executable = "echo $ENV1 | grep $ENV2", + environment = function() + return { + ENV1 = "yes", + ENV2 = env2, + } + end, + } + table.insert(PluginShare.backgroundJobs, job) + + while job.end_sec == nil do + MockTime:increase(2) + UIManager:handleInput() + end + + -- grep should return 0 when there is a match. + assert.are.equal(0, job.result) + assert.is_false(job.timeout) + assert.is_false(job.bad_command) + + job.end_sec = nil + env2 = "no" + table.insert(PluginShare.backgroundJobs, job) + + while job.end_sec == nil do + MockTime:increase(2) + UIManager:handleInput() + end + + -- grep should return 1 when there is no match. + assert.are.equal(1, job.result) + assert.is_false(job.timeout) + assert.is_false(job.bad_command) + end) + + it("should block long binary job", function() + local executed = 0 + local job = { + when = 1, + repeated = true, + executable = "sleep 1h", + environment = { + TIMEOUT = 1 + } + } + table.insert(PluginShare.backgroundJobs, job) + + while job.end_sec == nil do + MockTime:increase(2) + UIManager:handleInput() + end + + assert.are.equal(255, job.result) + assert.is_true(job.timeout) + assert.is_true(job.blocked) + end) + + it("should execute callback", function() + local executed = 0 + table.insert(PluginShare.backgroundJobs, { + when = 1, + repeated = 10, + executable = function() end, + callback = function() + executed = executed + 1 + end, + }) + + MockTime:increase(2) + UIManager:handleInput() + + for i = 1, 10 do + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(i, executed) + end + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(10, executed) + end) + + it("should not execute two jobs sequentially", function() + local executed = 0 + table.insert(PluginShare.backgroundJobs, { + when = 1, + executable = function() + executed = executed + 1 + end, + }) + table.insert(PluginShare.backgroundJobs, { + when = 1, + executable = function() + executed = executed + 1 + end, + }) + + MockTime:increase(2) + UIManager:handleInput() + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(1, executed) + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(2, executed) + MockTime:increase(2) + UIManager:handleInput() + assert.are.equal(2, executed) + end) +end) diff --git a/spec/unit/commonrequire.lua b/spec/unit/commonrequire.lua index 899409538..bb72cc0a6 100644 --- a/spec/unit/commonrequire.lua +++ b/spec/unit/commonrequire.lua @@ -97,3 +97,20 @@ package.unloadAll = function() end return #pending end + +local background_runner +requireBackgroundRunner = function() + require("pluginshare").stopBackgroundRunner = nil + if background_runner == nil then + local package_path = package.path + package.path = "plugins/backgroundrunner.koplugin/?.lua;" .. package.path + background_runner = dofile("plugins/backgroundrunner.koplugin/main.lua") + package.path = package_path + end + return background_runner +end + +stopBackgroundRunner = function() + background_runner = nil + require("pluginshare").stopBackgroundRunner = true +end