-
Notifications
You must be signed in to change notification settings - Fork 589
CoroutinesBroken
Lua as implemented in Hammerspoon executes solely on the main application thread. Executing as a single thread is consistent with the stock implementation of Lua -- while there are some provisions for implementing locks within the Lua source that could facilitate running in a multithreaded manner, these are seldom implemented and not formally supported by the Lua development team. As an alternative to multi-threading, Lua implements a feature known as coroutines. Coroutines are a way for long running processes to yield so that other code my execute without losing the current state of the long running process.
As implemented in Hamemrspoon, there has been a flaw in the code which bridges between the Lua envirnonment within Hammerspoon and the Objective-C runtime which grants programatic access to the macOS and hardware features of the users computer. This flaw has gone unnoticed because coroutines have not been in common usage in most user configurations. Recent attempts to make Hammerspoon more responsive have brought this limitation to light.
As a solution, some changes are required to the LuaSkin
framework, the bridge described above that is used by Hammerspoon. This document is an attempt to explain the required changes and what they offer for future development within Hammerspoon and future modules.
This document gives a brief introduction to coroutines and explains the problem; if you only care about the solution and how to modify existing modules or code new modules going forward, you can skip to Fixing the Problem.
While it is beyond the scope of fully detailing what coroutines are and how the work within Lua, a brief overview will be useful in understanding how these changes affect Hammerspoon and why they are useful. The description provided here is by its nature simplisitic and incomplete, but will suffice for our purposes in this document. For a more thourough understanding, you should consult Lua references, starting with the online reference and books linked to from the Lua website.
In Hammerspoon, there is one Lua engine running on the main thread. This engine maintains the global variable space, links to loaded modules, and handles the interpretation and execution of Lua code blocks. This engine runs on the main application thread of Hammerspoon; while in theory additional Lua engines could run on other application threads, they would be independant and share no global variables or modules between them -- they would be completely independant. Managing this is beyond the scope of Hammerspoon's current infrastructure and design and no plans currently exist for changing this.
Like most programming languages, Lua as executed by this engine is sequential in its operation -- programs execute line by line in the order commands are given. Program flow may "jump around" in the sense that functions are defined before they are invoked, but execution of the function occurs when it is invoked as the next step in a sequence of commands to execute, not where they are defined. Loops may repeat code by "resetting" the program counter to a previous line number or location, but again, the program flow is sequential and deterministic.
Internally, the current execution stack (line number/program counter, parameters or up-values, etc.) for a given block of executing code is maintained within a variable referred to as the lua_State
in C or Objective-C code, or as a Lua thread (an unfortunate choice of terminology, but one found in almost all Lua references, as this usage of the word thread
has no relationship to the concept of application threads as implemented by macOS) within the Lua environment itself.
Coroutines as implemented in Lua allow a given block of Lua code (an instance of lua_State
) currently being processed by the Lua engine to "yield" and allow a different instance of a lua_State
to resume processing by the Lua engine. At no time are multiple states being processed -- only one state is ever being processed at any given instance.
Because each state maintains an independant record of local variables, up-values, line counters, etc. this allows independant code blocks to operate in what appears to be an almost simultaneous manner to the end user, as long as the developer codes with coroutines in mind.
A simplistic example may help to understand this better (This particular example will work even within the current official release of Hammerspoon which is not considered coroutine safe -- more on why this is the case in the next section):
local a = "" -- define variable local to this codeblock
local b = 1
-- define a block which will execute on an independant lua "thread"
local background = coroutine.create(function()
-- here n is local and a is an up-value (i.e. defined outside of our scope)
for n = 1, 5 do
a = a .. tostring(n) .. "\n"
coroutine.yield() -- yield execution of this "thread"
end
-- normal termination of this "thread"
end)
-- this executes immediately in the main, or primary, lua "thread"
while coroutine.status(background) ~= "dead" do
b = 2 * b
-- suspends the main "thread" and executes the background one for a while
coroutine.resume(background)
end
print(a, b)
Whils this example is extrememly simplistic, it contains all of the basics necessary to see how to take advantage of coroutines and how execution can "bounce" between independant code blocks or sections. The important things to make note of here are (1) the definition of the coroutine as a function, (2) the use of coroutine.yield
within the coroutine function to indicate when it is a good time to "yield" and allow other "threads" time to do work, and (3) the coroutine.resume
in the main "thread" code which allows the coroutine function to execute until its next yield
or completion of the coroutine function.
Coroutines which solely rely upon stock lua or modules which do not take advantage of the bridgining framework LuaSkin
are not a problem, and it is theorized that some of our users which have included code imported with LuaRocks may have unknowingly been using coroutines in their setups for a while now. The problem is when a Hammerspoon specific extension is added into the code executing within a coroutine; for example:
local a = ""
local b = 1
local background = coroutine.create(function()
a = hs.screen.mainScreen():name() -- this is a line which invokes a Hammerspoon specific extension
for n = 1, 5 do
a = a .. tostring(n) .. "\n"
coroutine.yield()
end
end)
while coroutine.status(background) ~= "dead" do
b = 2 * b
coroutine.resume(background)
end
print(a, b)
The addition of the single line a = hs.screen.mainScreen():name()
causes Hammerspoon to crash, and usually not in a "clean" way that dumps a trace to the console.
The reason for this can be traced back to a simple misunderstanding made when LuaSkin
was first conceived as a bridge between Lua and the macOS Objective-C runtime -- that the lua_State
is singular and unchanging once the Lua engine has been created.
This assumption simplified a LOT of code necessary for integrating Lua with the macOS Objective-C runtime because much of the power Hammerspoon grants us comes from initiating the execution of Lua code blocks when macOS events occur. This can occur at any time, and is often triggered within Objective-C delegate method invocations or event callbacks. In such places, no existing lua_State
reference is vailable to us, unless we have stored it in the callbacks context (some very early modules predating Hammerspoon itself took just this approach) or require each and every module added to Hammerspoon to be linked at compile time against the core application itself. The problems with these approaches are (1) not every case where we want a macOS event to trigger the execution of Lua code allow for an arbitrary context variable, and (2) recompiling the entire Hammerspoon application everytime a new module is conceived or debugged is time consuming and difficult for developers not intimately familiar with the Hammerspoon codebase.
Creating the LuaSkin
framework allowed for linking against just a single shared framework, and by storing the lua_State
that was created when the Lua engine was first instantiated by Hammerspoon as an instance variable within the framework, any module could determine the main lua_State
at any time without requiring the use of arbitrary context variables, recompiling the entire application during module development, or brittle and complex linking tricks which would probably break with the next macOS update.
And as long as no Hammerspoon specific additions were ever invoked within a coroutine, this was fine. LuaRocks code would work most of the time (there are a few edge cases where Lua internals are wrapped by Hammerspoon code so we can alter the default behavior -- see hs.ipc
if you want an example where print
is replaced)
For those of you familiar with Objective-C and the template against which most of our modules are written, a little Objective-C code here can show exactly where the problem occurs. Take, for example, hs.screen.mainScreen
which is defined as follows:
static int screen_mainScreen(lua_State* L) {
LuaSkin *skin = [LuaSkin shared];
[skin checkArgs:LS_TBREAK];
new_screen(L, [NSScreen mainScreen]);
return 1;
}
The line LuaSkin *skin = [LuaSkin shared];
accesses the shared framework and gives the function access to the bridging tools added by the LuaSkin
framework. In this case, the only one really being taken advantage of is to check the arguments passed into the function with [skin checkArgs:LS_TBREAK];
. Within the checkArgs:
method, a variety of calls to the lua C api are invoked, using the instance variable skin.L
as the lua_State
variable specifying the specific stack to examine corresponding to the currently executing Lua code. If you enter hs.screen.mainScreen
into the Hammerspoon console, or access it from within a callback triggered by a timer or other macOS event, this is fine as skin.L
is the lua_State
of the Lua "thread" currently being executed.
The problem occurs when hs.screen.mainScreen
is invoked from within a coroutine. Within the coroutine, the lua_State
currently being acted upon by the Lua engine is not the same as skin.L
, so when checkArgs:
is invoked, it attempts to verify the values of the wrong lua_State
, a state which is in an indeterminate state because it isn't even active in the Lua engine at the present instance.
To make matters more complicated, there does not seem to be any obvious way, without introducing changes to the Lua source code itself, to determine which lua_State
is in fact being executed by the Lua engine, so we can't even have the LuaSkin
framework check to see if it should procede or not, no way for us to gracefully error out instead of crashing completely.
This leads to what I see as three possible approaches:
-
Do nothing; after all, we've gotten this far and this was discovered only by accident. Obviously coroutines haven't been a priority for our users.
- Simple.
- This is a bad idea on general principle, and limits some of the additions and improvements that we (and possibly more accomplished Lua programmers then we have thus far been privileged to work with) can bring to Hammerspoon going forward
- It's also within the realm of possibility that some of the unidentified crashlogs we've seen could be from early attempts by people to use coroutines, but they didn't know or care enough to report the issues with enough detail for us to determine this from the issues tracker for Hammerspoon.
- For additions I have in mind, never mind others, coroutines are becoming more important, even if no one else wants them for a while, I do.
-
Remove the coroutine library from Hammerspoon's implementation of Lua entirely.
- Simple.
- Also seems like a bad idea on general principle, and defintaly makes Hammerspoon less attractive to accomplished Lua programmers who have likely come to expect coroutines as part of the available toolbox.
- Would break LuaRocks which have worked anyways, though only through luck.
-
Fix LuaSkin so that
skin.L
can be updated to track the currentlua_State
.- Not simple.
- Requires modification to every module and the changes have to be carried forward into new module code as well...
- breaks existing templates and module "walkthroughs", though to be honest, the ones I am aware of are pretty out of date anyways and primarily affects only 3 or 4 of us (that I know of; maybe more in practice, but not a huge number).
- Could introduce subtle errors if the state isn't tracked carefully -- more on this point in the companion document.
Obviously for those tracking https://github.com/Hammerspoon/hammerspoon/pull/2308 and https://github.com/Hammerspoon/hammerspoon/issues/2306, I'm advocating for option 3, and now that the problem has been identified and described in detail, I present the changes, both to code and the way to think about module writing, in Fixing the Problem.
Standard disclaimers apply, this document is comprised soley of my own thoughts and interpretations and does not necessarily reflect the beliefs, understanding, knowledge, etc. of anyone else who has, ever will, or ever will not contribute to the Hammerspoon project.
No warranties implied or expressed, etc. etc. etc.