Trapper: adds dismissableRunInSubprocess() + new TrapWidget

New TrapWidget: invisible full screen widget for catching UI events. Can
optionally display a text message at bottom left of screen (ie: "Loading..."),
used by default by Trapper:dismissable functions when no widget provided.
Added Trapper:dismissableRunInSubprocess() to run a lua function in
a sub-process, allowing it to be dismissed, and returns its return
value(s).
pull/3617/head
poire-z 6 years ago
parent b1863d3a71
commit 0ef948f60d

@ -1 +1 @@
Subproject commit feca07cc6f32271c9a904f67372719b13fe292d7
Subproject commit 9fb7d4ae04d3869579c16a2dc504a69b99e6295b

@ -11,8 +11,10 @@ Mostly done with coroutines, but hides their usage for simplicity.
local ConfirmBox = require("ui/widget/confirmbox")
local InfoMessage = require("ui/widget/infomessage")
local TrapWidget = require("ui/widget/trapwidget")
local UIManager = require("ui/uimanager")
local ffiutil = require("ffi/util")
local dump = require("dump")
local logger = require("logger")
local _ = require("gettext")
@ -307,10 +309,11 @@ Notes and limitations:
2) `cmd` needs to output something (we will wait till some data is available)
If there are chances for it to not output anything, append `"; echo"` to `cmd`
3) We need an @{ui.widget.infomessage|InfoMessage}, that, as a modal, will catch
any @{ui.event|Tap event} happening during `cmd` execution. This can be
provided as a string (a new InfoMessage will be created), or can be an
existing already displayed InfoMessage.
3) We need a @{ui.widget.trapwidget|TrapWidget} or @{ui.widget.infomessage|InfoMessage},
that, as a modal, will catch any @{ui.event|Tap event} happening during
`cmd` execution. This can be an existing already displayed widget, or
provided as a string (a new TrapWidget will be created). If nil, an invisible
TrapWidget will be used instead.
If we really need to have more control, we would need to use `select()` via `ffi`
or do low level non-blocking reading on the file descriptor.
@ -319,12 +322,11 @@ collect indefinitely, the best option would be to compile any `timeout.c`
and use it as a wrapper.
@string cmd shell `cmd` to execute and get output from
@param infomessage string or already shown @{ui.widget.infomessage|InfoMessage} widget instance
@int check_interval_sec[opt=0.1] float interval in second for checking pipe for available output
@param trap_widget_or_string already shown widget, string or nil
@treturn boolean completed (`true` if not interrupted, `false` if dismissed)
@treturn string output of command
]]
function Trapper:dismissablePopen(cmd, infomessage, check_interval_sec)
function Trapper:dismissablePopen(cmd, trap_widget_or_string)
local _coroutine = coroutine.running()
-- assert(_coroutine ~= nil, "Need to be called from a coroutine")
if not _coroutine then
@ -338,34 +340,58 @@ function Trapper:dismissablePopen(cmd, infomessage, check_interval_sec)
return false
end
local own_infomessage = false
if type(infomessage) == "string" then
infomessage = InfoMessage:new{text = infomessage}
UIManager:show(infomessage)
UIManager:forceRePaint()
own_infomessage = true
local trap_widget
local own_trap_widget = false
local own_trap_widget_invisible = false
if type(trap_widget_or_string) == "table" then
-- Assume it is a usable already displayed trap'able widget with
-- a dismiss_callback (ie: InfoMessage or TrapWidget)
trap_widget = trap_widget_or_string
else
if type(trap_widget_or_string) == "string" then
-- Use a TrapWidget with this as text
trap_widget = TrapWidget:new{
text = trap_widget_or_string,
}
UIManager:show(trap_widget)
UIManager:forceRePaint()
else
-- Use an invisible TrapWidget that resend event
trap_widget = TrapWidget:new{
text = nil,
resend_event = true,
}
UIManager:show(trap_widget)
own_trap_widget_invisible = true
end
own_trap_widget = true
end
infomessage.dismiss_callback = function()
trap_widget.dismiss_callback = function()
-- this callback will resume us at coroutine.yield() below
-- with a go_on = false
coroutine.resume(_coroutine, false)
end
if not check_interval_sec then
check_interval_sec = 0.1 -- default: check for output every 100ms
end
local collect_interval_sec = 5 -- collect cancelled cmd every 5 second, no hurry
local check_interval_sec = 0.125 -- start with checking for output every 125ms
local check_num = 0
local completed = false
local output = nil
local std_out = io.popen(cmd, "r")
if std_out then
-- We check regularly if data is available to be read, and we give
-- control in the meantime to UIManager so our InfoMessage.dismiss_callback
-- We check regularly if data is available to be read, and we give control
-- in the meantime to UIManager so our trap_widget's dismiss_callback
-- get a chance to be triggered, in which case we won't wait for reading,
-- We'll schedule a background function to collect the uneeded output and
-- close the pipe later.
while true do
-- Every 10 iterations, increase interval until a max of 1 sec is reached
check_num = check_num + 1
if check_interval_sec < 1 and check_num % 10 == 0 then
check_interval_sec = math.min(check_interval_sec * 2, 1)
end
-- The following function will resume us at coroutine.yield() below
-- with a go_on = true
local go_on_func = function() coroutine.resume(_coroutine, true) end
@ -390,7 +416,8 @@ function Trapper:dismissablePopen(cmd, infomessage, check_interval_sec)
UIManager:scheduleIn(collect_interval_sec, collect_and_clean)
break
end
-- the go_on_func resumed us, check if pipe is ready to be read
-- The go_on_func resumed us: we have not been dismissed.
-- Check if pipe is ready to be read
if ffiutil.getNonBlockingReadSize(std_out) ~= 0 then
-- Some data is available for reading: read it all,
-- but we may block from now on
@ -402,13 +429,233 @@ function Trapper:dismissablePopen(cmd, infomessage, check_interval_sec)
-- logger.dbg("no cmd output yet, will check again soon")
end
end
if own_infomessage then
-- Remove our own infomessage
UIManager:close(infomessage)
UIManager:forceRePaint()
if own_trap_widget then
-- Remove our own trap_widget
UIManager:close(trap_widget)
if not own_trap_widget_invisible then
UIManager:forceRePaint()
end
end
-- return what we got or not to our caller
return completed, output
end
--[[--
Run a function (task) in a sub-process, allowing it to be dismissed,
and returns its return value(s).
Notes and limitations:
1) As function is run in a sub-process, it can't modify the main
KOReader process (its parent). It has access to the state of
KOReader at the time the sub-process was started. It should not
use any service/driver that would make the parent process vision
of the device state incoherent (ie: it should not use UIManager,
display widgets, change settings, enable wifi...).
It is allowed to modify the filesystem, as long as KOreader
has not a cached vision of this filesystem part.
Its returned value(s) are returned to the parent.
2) task may return complex data structures (but with simple lua types,
no function) or a single string. If task returns a string or nil,
set task_returns_simple_string to true, allowing for some
optimisations to be made.
3) If dismissed, the sub-process is killed with SIGKILL, and
task is aborted without any chance for cleanup work: use of temporary
files should so be limited (do some cleanup of dirty files from
previous aborted executions at the start of each new execution if
needed), and try to keep important operations as atomic as possible.
4) We need a @{ui.widget.trapwidget|TrapWidget} or @{ui.widget.infomessage|InfoMessage},
that, as a modal, will catch any @{ui.event|Tap event} happening during
`cmd` execution. This can be an existing already displayed widget, or
provided as a string (a new TrapWidget will be created). If nil, an invisible
TrapWidget will be used instead.
@function task lua function to execute and get return values from
@param trap_widget_or_string already shown widget, string or nil
@boolean task_returns_simple_string[opt=false] true if task returns a single string
@treturn boolean completed (`true` if not interrupted, `false` if dismissed)
@return ... return values of task
]]
function Trapper:dismissableRunInSubprocess(task, trap_widget_or_string, task_returns_simple_string)
local _coroutine = coroutine.running()
if not _coroutine then
logger.warn("unwrapped dismissableRunInSubprocess(), falling back to blocking in-process run")
return true, task()
end
local trap_widget
local own_trap_widget = false
local own_trap_widget_invisible = false
if type(trap_widget_or_string) == "table" then
-- Assume it is a usable already displayed trap'able widget with
-- a dismiss_callback (ie: InfoMessage or TrapWidget)
trap_widget = trap_widget_or_string
else
if type(trap_widget_or_string) == "string" then
-- Use a TrapWidget with this as text
trap_widget = TrapWidget:new{
text = trap_widget_or_string,
}
UIManager:show(trap_widget)
UIManager:forceRePaint()
else
-- Use an invisible TrapWidget that resend event
trap_widget = TrapWidget:new{
text = nil,
resend_event = true,
}
UIManager:show(trap_widget)
own_trap_widget_invisible = true
end
own_trap_widget = true
end
trap_widget.dismiss_callback = function()
-- this callback will resume us at coroutine.yield() below
-- with a go_on = false
coroutine.resume(_coroutine, false)
end
local collect_interval_sec = 5 -- collect cancelled cmd every 5 second, no hurry
local check_interval_sec = 0.125 -- start with checking for output every 125ms
local check_num = 0
local completed = false
local ret_values = nil
local pid, parent_read_fd = ffiutil.runInSubProcess(function(pid, child_write_fd)
local output_str = ""
if task_returns_simple_string then
-- task is assumed to return only a string or nil, avoid
-- possibly expensive dump()/dofile()
local result = task()
if type(result) == "string" then
output_str = result
elseif result ~= nil then
logger.warn("returned value from task is not a string:", result)
end
else
-- task may return complex data structures, that we dump()
-- Note: be sure these data structures contain only classic types,
-- and no function (dofile() with fail if it meets
-- "function: 0x55949671c670"...)
-- task may also return multiple return values, so we
-- wrap them in a table (beware the { } construct may stop
-- at the first nil met)
local results = { task() }
output_str = "return "..dump(results).."\n"
end
ffiutil.writeToFD(child_write_fd, output_str, true)
end, true) -- with_pipe = true
if pid then
-- We check regularly if subprocess is done, and we give control
-- in the meantime to UIManager so our trap_widget's dismiss_callback
-- get a chance to be triggered, in which case we'll terminate the
-- subprocess and schedule a background function to collect it.
while true do
-- Every 10 iterations, increase interval until a max of 1 sec is reached
check_num = check_num + 1
if check_interval_sec < 1 and check_num % 10 == 0 then
check_interval_sec = math.min(check_interval_sec * 2, 1)
end
-- The following function will resume us at coroutine.yield() below
-- with a go_on = true
local go_on_func = function() coroutine.resume(_coroutine, true) end
UIManager:scheduleIn(check_interval_sec, go_on_func) -- called in 100ms by default
local go_on = coroutine.yield() -- gives control back to UIManager
if not go_on then -- the dismiss_callback resumed us
UIManager:unschedule(go_on_func)
-- We kill and forget the sub-process here, but something has
-- to collect it so it does not become a zombie
ffiutil.terminateSubProcess(pid)
local collect_and_clean
collect_and_clean = function()
if ffiutil.isSubProcessDone(pid) then
if parent_read_fd then
ffiutil.readAllFromFD(parent_read_fd) -- close it
end
logger.dbg("collected previously dismissed subprocess")
else
if parent_read_fd and ffiutil.getNonBlockingReadSize(parent_read_fd) ~= 0 then
-- If subprocess started outputing to fd, read from it,
-- so its write() stops blocking and subprocess can exit
ffiutil.readAllFromFD(parent_read_fd)
-- We closed our fd, don't try again to read or close it
parent_read_fd = nil
end
-- reschedule to collect it
UIManager:scheduleIn(collect_interval_sec, collect_and_clean)
logger.dbg("previously dismissed subprocess not yet collectable")
end
end
UIManager:scheduleIn(collect_interval_sec, collect_and_clean)
break
end
-- The go_on_func resumed us: we have not been dismissed.
-- Check if sub process has ended
-- Depending on the the size of what the child has to write,
-- it may has ended (if data fits in the kernel pipe buffer) or
-- it may still be alive blocking on write() (if data exceeds
-- the kernel pipe buffer)
local subprocess_done = ffiutil.isSubProcessDone(pid)
local stuff_to_read = parent_read_fd and ffiutil.getNonBlockingReadSize(parent_read_fd) ~=0
logger.dbg("subprocess_done:", subprocess_done, " stuff_to_read:", stuff_to_read)
if subprocess_done or stuff_to_read then
-- Subprocess is gone or nearly gone
completed = true
if stuff_to_read then
local ret_str = ffiutil.readAllFromFD(parent_read_fd)
if task_returns_simple_string then
ret_values = { ret_str }
else
local ok, results = pcall(load(ret_str))
if ok and results then
ret_values = results
else
logger.warn("load() failed:", results)
end
end
if not subprocess_done then
-- We read the output while process was still alive.
-- It may be dead now, or it may exit soon, and we
-- need to collect it.
-- Schedule that in 1 second (it should be dead), so
-- we can return our result now.
local collect_and_clean
collect_and_clean = function()
if ffiutil.isSubProcessDone(pid) then
logger.dbg("collected subprocess")
else -- reschedule
UIManager:scheduleIn(1, collect_and_clean)
logger.dbg("subprocess not yet collectable")
end
end
UIManager:scheduleIn(1, collect_and_clean)
end
else -- subprocess_done: process exited with no output
ffiutil.readAllFromFD(parent_read_fd) -- close our fd
-- no ret_values
end
break
end
logger.dbg("process not yet done, will check again soon")
end
end
if own_trap_widget then
-- Remove our own trap_widget
UIManager:close(trap_widget)
if not own_trap_widget_invisible then
UIManager:forceRePaint()
end
end
-- return what we got or not to our caller
if ret_values then
return completed, unpack(ret_values)
end
return completed
end
return Trapper

@ -252,7 +252,7 @@ function UIManager:close(widget, refreshtype, refreshregion)
Input.disable_double_tap = false
end
end
if dirty then
if dirty and not widget.invisible then
-- schedule remaining widgets to be painted
for i = 1, #self._window_stack do
self:setDirty(self._window_stack[i].widget)
@ -368,7 +368,7 @@ function UIManager:setDirty(widget, refreshtype, refreshregion)
for i = 1, #self._window_stack do
self._dirty[self._window_stack[i].widget] = true
end
else
elseif not widget.invisible then
self._dirty[widget] = true
end
end

@ -0,0 +1,167 @@
--[[--
Invisible full screen widget for catching UI events.
(for use with or by Trapper to interrupt its processing).
Can optionally display a text message at bottom left of screen
(ie: "Loading…")
]]
local Blitbuffer = require("ffi/blitbuffer")
local BottomContainer = require("ui/widget/container/bottomcontainer")
local CenterContainer = require("ui/widget/container/centercontainer")
local Device = require("device")
local Event = require("ui/event")
local Font = require("ui/font")
local FrameContainer = require("ui/widget/container/framecontainer")
local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local InputContainer = require("ui/widget/container/inputcontainer")
local LeftContainer = require("ui/widget/container/leftcontainer")
local RenderText = require("ui/rendertext")
local Size = require("ui/size")
local TextBoxWidget = require("ui/widget/textboxwidget")
local TextWidget = require("ui/widget/textwidget")
local UIManager = require("ui/uimanager")
local Input = Device.input
local Screen = Device.screen
local TrapWidget = InputContainer:new{
modal = true,
dismiss_callback = function() end,
text = nil, -- will be invisible if no message given
face = Font:getFace("infofont"),
-- Whether to resend the event caught and used for dismissal
resend_event = false,
}
function TrapWidget:init()
local full_screen = Geom:new{
x = 0, y = 0,
w = Screen:getWidth(),
h = Screen:getHeight(),
}
if Device:hasKeys() then
self.key_events = {
AnyKeyPressed = { { Input.group.Any },
seqtext = "any key", doc = "dismiss" }
}
end
if Device:isTouchDevice() then
self.ges_events.TapDismiss = {
GestureRange:new{ ges = "tap", range = full_screen, }
}
self.ges_events.HoldDismiss = {
GestureRange:new{ ges = "hold", range = full_screen, }
}
self.ges_events.SwipeDismiss = {
GestureRange:new{ ges = "swipe", range = full_screen, }
}
end
if self.text then
local textw
-- Don't make our message reach full screen width, so
-- it looks like popping from bottom left corner
local tsize = RenderText:sizeUtf8Text(0, Screen:getWidth(), self.face, self.text)
if tsize.x < Screen:getWidth() * 0.9 then
textw = TextWidget:new{
text = self.text,
face = self.face,
}
else -- if text too wide, use TextBoxWidget for multi lines display
textw = TextBoxWidget:new{
text = self.text,
face = self.face,
width = math.floor(Screen:getWidth() * 0.9)
}
end
local border_size = Size.border.default
self.frame = FrameContainer:new{
background = Blitbuffer.COLOR_WHITE,
bordersize = border_size,
margin = 0,
padding = 0,
padding_left = Size.padding.default,
padding_right = Size.padding.default,
textw,
}
-- To have our frame message a bit prettier with its left
-- and bottom borders not displayed, we make use of this
-- combination of Containers to push them off-screen
self[1] = CenterContainer:new{
dimen = full_screen:copy(),
BottomContainer:new{
dimen = Geom:new{
w = full_screen.w,
h = full_screen.h + 2*border_size,
},
LeftContainer:new{
dimen = Geom:new{
w = full_screen.w + 2*border_size,
h = self.frame:getSize().h,
},
self.frame,
}
}
}
else
-- So that UIManager knows no refresh is needed and
-- avoids some unnecessary refreshes
self.invisible = true
end
end
function TrapWidget:_dismissAndResent(evtype, ev)
self.dismiss_callback()
UIManager:close(self)
if self.resend_event and evtype and ev then
-- XXX There may be timing problems that could cause crashes, as we
-- use nextTick, if the dismiss_callback uses UIManager:scheduleIn()
-- or has set up some widget that may catch that event while not being
-- yet fully initialiazed.
-- It happened mostly when I had some bug somewhere, and it was a quite
-- reliable sign of a bug somewhere, but the stacktrace was unrelated
-- to the bug location.
-- Fix to avoid crashes: in GestureRange:match(), check that self.range()
-- does not return nil before using it:
-- if not range or not range:contains(gs.pos) then return false
UIManager:nextTick(function() UIManager:handleInputEvent(Event:new(evtype, ev)) end)
end
return true
end
function TrapWidget:onAnyKeyPressed(_, ev)
return self:_dismissAndResent("KeyPress", ev)
end
function TrapWidget:onTapDismiss(_, ev)
return self:_dismissAndResent("Gesture", ev)
end
function TrapWidget:onHoldDismiss(_, ev)
return self:_dismissAndResent("Gesture", ev)
end
function TrapWidget:onSwipeDismiss(_, ev)
return self:_dismissAndResent("Gesture", ev)
end
function TrapWidget:onShow()
if self.frame then
UIManager:setDirty(self, function()
return "ui", self.frame.dimen
end)
end
return true
end
function TrapWidget:onCloseWidget()
if self.frame then
UIManager:setDirty(nil, function()
return "ui", self.frame.dimen
end)
end
return true
end
return TrapWidget
Loading…
Cancel
Save