Hammerspoon: Runloop question and a "yieldToHammerspoon" function

Created on 16 Feb 2020  路  21Comments  路  Source: Hammerspoon/hammerspoon

@cmsj, and others with a background in Objective-C and the lua source code internals...

I had a thought about adding a function which would allow "yielding" to other things during long running lua code...

If we add a function like this:

~objc
static int hs_yield(lua_State *L) {
NSRunLoop *loop = NSRunLoop.currentRunLoop ;
[loop runMode:NSDefaultRunLoopMode beforeDate:[[NSDate date] dateByAddingTimeInterval:.1]];
return 0 ;
}
~

Then code like this:

~lua
a = hs.timer.doEvery(1, function()
print("in a")
end)
b = 0
c = false
d = hs.timer.doAfter(30, function()
c = true
a:stop()
end)
while (not c) do
b = b + 1
hs.yield()
end
print(b)
~

Should allow a to continue firing while the while loop is iterating...

I tried this by adding the function to a personal dev module I use for one-offs when testing new/hairbrained ideas, and it seems to work ok...

~~~

a = hs.timer.doEvery(1, function() print("in a") end) ; b = 0 ; c = false ; d = hs.timer.doAfter(30, function() c = true ; a:stop() end) ; while (not c) do b = b + 1 ; _xtras.runloop() ; end ; print(b)
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
in a
346
~~~

(It's not obvious here, but in a got repeatedly printed in the console while the beachball spun for 30 seconds until the final count of b was printed and it all stopped)

But... I'm a little hesitant to suggest adding it yet... are we asking for trouble by invoking runMode: at an arbitrary location? What about the lua stack, since its being invoked in the middle of another lua function? OTOH, since lua_pcall creates a "new" stack when a C function is invoked, the only stack in danger is the one for hs_yield which we don't care about and will go away when hs_yield returns...

Am I mad? missing something? I'd love to be able to add this ability to make Hammerspoon seem to block less (at least in long running lua code), but I freely admit I'm just not confident enough in my self-taught knowledge of Objective-C and the lua internals to know if this is safe or if I'm introducing a potential catastrophe...

All 21 comments

(FYI, decreasing the NSData interval to 0.01 resulted in b reaching 2719 and at 0.001, b reached 21511... since invoking yield would block for a minimum of the interval specified -- potentially longer depending upon how long whatever else was invoked during that interval took -- we'd want it short, not sure where the point of diminishing returns is yet)

A more (slightly) complex example that updates a canvas:

~~~lua
c = hs.canvas.new{ x = 100, y = 100, h = 100, w = 100 }:show()
c[#c + 1] = { type = "rectangle", fillColor = { white = .1 } }
c[#c + 1] = { type = "text", text = "**", textSize = 75 }

s = false
b = 0
d = 0

x = hs.timer.doEvery(1, function() c[2].text = tostring(d) ; d = d + 1 end)
y = hs.timer.doAfter(30, function() s = true ; x:stop() ; c:delete() ; print(b) end)

while (not s) do b = b + 1 ; _xtras.runloop() end
~~~

With the interval set at 0.0001, b ended up 184166 and the canvas was updated during the while loop as I'd hoped (i.e. incrementing the number ever second)...

I鈥檓 not sure I can mentally model this well enough to be sure it鈥檚 safe, but it鈥檚 a very interesting idea.

Maybe post to the lua mailing list about it? I鈥檓 sure the experts there would be able to let us know if it鈥檚 safe or not?

FYI, the current version I'm playing with is:

~~~objc
static int hs_yield(lua_State *L) {
NSTimeInterval interval = (lua_type(L, 1) == LUA_TNUMBER) ? 0.001 : lua_tonumber(L, 1) ;
NSDate *date = [[NSDate date] dateByAddingTimeInterval:interval] ;

// [NSRunLoop.currentRunLoop runUntilDate:date] ;

// from gnustep's implementation of [NSApp run]
// this allows acting on events (hs.eventtap) and keys (hs.hotkey) as well as timers, etc.
BOOL   mayDoMore = YES ;
while (YES == mayDoMore) {
    NSEvent *e = [NSApp nextEventMatchingMask:NSAnyEventMask
                                    untilDate:date
                                       inMode:NSDefaultRunLoopMode
                                      dequeue:YES] ;
    if (e != nil) [NSApp sendEvent: e] ;

    mayDoMore = !([date timeIntervalSinceNow] <= 0.0) ;
}

return 0 ;

}
~~~

This version also also allows events (hs.eventtap) and hotkeys (hs.hotkey) to be acted on during a yield. By default every time yield is invoked, it stops the current processing for a minimum of 0.001 seconds (which you can change with an argument to yield) to act on other "items" posted to the Hammerspoon main thread event queue. It may actually "pause" longer if one of the callbacks invoked takes longer, but if it doesn't, more than one pending action can be addressed.

@cmsj, I haven't really perused the lua lists in a while, is there one (or more) in particular you'd recommend?

This sounds really cool. I have no idea what the dangers or side-effects could be, however, I'm going to add it into CommandPost and see what explodes!

@asmagill - Also, I assume @cmsj means this mailing list:

https://www.lua.org/lua-l.html

Ok, so this is AMAZING. So far I haven't seen any explosions or crashes - it works great!

Very, very, very exciting.

I want to test some pretty contrived examples (yielding within an iterator function used by for, within various types of callbacks, in conjunction with co-routines (if I can figure them out!), from within another C function, etc.) and see what happens, but so far, It seems to work for me.

I have already verified that a lua error in one of the callbacks doesn't cause an issue -- I changed:
~lua
x = hs.timer.doEvery(1, function()
c[2].text = tostring(d)
d = d + 1
end)
~

to

~lua
x = hs.timer.doEvery(1, function()
d = d + 1
if d % 5 == 0 then
error("I hate things divisible by 5!")
else
c[2].text = tostring(d)
end
end, true)
~

and that worked as expected as well.

I did post to the mailing list, so we'll see what people more familiar with the lua internals might have to say.

My gut feeling is that using this within a co-routine will likely be the most challenging/dangerous, since yielding/resuming those actually changes some flags in the lua_State variable, but tracking what they mean... gave me a headache. So I need to understand and create some come co-routines and play with them a bit so I can craft a proper test and see...

And it should be stressed that this is not like the lua co-routine yield where you can choose which coroutine to resume and when... this is strictly within the current execution block... and if something triggered by an event during the yield also uses the yield function, it's like a stack -- first-on, last-off -- before control returns to the outermost execution block. I'll have to make that clear in the docs if this passes muster...

And it does slow down your loop by actually waiting the specified time to see if any events come in and/or perform more than one for each yield if time permits. If the delay is in the number of times you have to loop, rather than how long a single iteration takes, I'd use a counter and maybe yield every tenth time or so to reach that fine balance of responsiveness without slowing down the actual work being done more than necessary.

FWIW, I injected hs.yield() into our cp.just extension that's used right throughout CommandPost. I also inserted it into a lot of our big/expensive for loops such as this one. I didn't notice any crashes, bugs or issues. I didn't actually see any noticeable slowdown when injecting into a for loop.

We don't use co-routines at all currently.

I look very forward to this landing!

Note to self: if/when this does land, look at MJConsole.m and see if we can clear input field of console before executing content... the console is available to us while a yielding loop is executing, but you need to clear input field before typing or run the risk of executing same code twice. Oopsie!

Iterator test works (the iterator is very arbitrary, don't judge)

~~~lua
-- in iterator

st = false
a = hs.timer.doEvery(1, function() if (d or 0) > 1000000 then st = true ; a:stop() end end)
b = function(c) local i = 0 ; return function(x,y) _xtras.yield() ; i = i + 1 ; d = i ; return (not st and d) or nil end, c, 0 end
for i in b(10) do if i % 10000 == 0 then print(i) end end
~~~

Ok, stupid mistake in yield function...

the line setting interval should read as NSTimeInterval interval = (lua_type(L, 1) == LUA_TNUMBER) ? lua_tonumber(L, 1) : 0.000001 ;

Which I should have caught earlier because it explains why I was getting so little variance as I tried different intervals... and a microsecond interval rather than a millisecond interval as the default is a lot more reasonable...

For those playing along, my current version (with above bug fix and which "lua thread" detection:

~~~objc
static int extras_yield(lua_State *L) {
int isMainThread = lua_pushthread(L) ;
lua_pop(L, 1) ; // remove thread we just pushed onto the stack

if (isMainThread) {
    // if argument is 0, only 1 queued event will execute before resuming. Ok, if yield is called
    // often, but not as friendly if yield only called infrequently.
    NSTimeInterval interval = (lua_type(L, 1) == LUA_TNUMBER) ? lua_tonumber(L, 1) : 0.000001 ;
    NSDate         *date    = [[NSDate date] dateByAddingTimeInterval:interval] ;

    // a melding of code from gnustep's implementation of NSApplication's run and runUntilDate: methods
    // this allows acting on events (hs.eventtap) and keys (hs.hotkey) as well as timers, etc.
    BOOL   mayDoMore = YES ;
    while (mayDoMore) {
        NSEvent *e = [NSApp nextEventMatchingMask:NSAnyEventMask
                                        untilDate:date
                                           inMode:NSDefaultRunLoopMode
                                          dequeue:YES] ;
        if (e) [NSApp sendEvent:e] ;

        mayDoMore = !([date timeIntervalSinceNow] <= 0.0) ;
    }
}

// returns true if we were able to yield and allow the app to do other things, or
// false if no yielding actually occurred (because we're not on the main "thread" for the state)
lua_pushboolean(L, isMainThread) ;
return 1 ;

}
~~~

Actually thinking about it, hs_yield may not have to care about being invoked with a different lua_State since it only (potentially) allows external events to trigger lua execution, and those have to be running with the initial state LuaSkin was assigned, so no cross contamination of the states/stack is possible.

Have to ponder some more...

edit- oopsie! hit cmd-v

Legend. Thanks so much for your amazing experimentation, deep thinking and pushing the boundaries - HUGELY appreciated! I love the direction this issue is heading - and really can't wait to see where it ends up. Woohoo!

I wonder if you could insert hs.yield() into hs.timer.usleep()?

I wouldn't. usleep is supposed to sleep only a specific amount of time; yield pauses execution for a minimum of the specified time, quite possibly longer if a queued event (say, getting menus, which does run on the main queue, just after the code which spawned it finishes) that takes a long time gets executed.

Re getMenus, did we ever establish that AXUIElement queries need to be on the main thread, or was that in an attempt to address some of the crashes by ruling things out?

No idea to be honest. All I know is that I think you鈥檝e said in the past that all UI stuff needs to run on the main thread. @cmsj ?

I'm going to close this as I think we can more safely use coroutines and coroutine.applicationYield now rather then pray that we're polling the runloop correctly and Apple doesn't change anything on us.

Re-open if you disagree or find something that can't be made responsive with coroutines.

@cmsj, @latenitefilms After working with someone to figure out why yabai didn't seem to play well with Hammerspoon (skim https://github.com/koekeishiya/yabai/issues/502 for details if interested) I am thinking about re-considering this and wanted to get your thoughts...

I looked at gnustep's implementation of NSTask's waitUntilExit and it's basically doing what https://github.com/asmagill/hammerspoon_asm/blob/master/extras/internal.m#L773-L801 does.

So, I used Hopper and looked at the disassembly of NSTask's waitUntilExit, NSApplication's run, and NSRunLoop's runUntilDate: methods as well... I can't be 100% certain, because it is a disassembly, but they also look similar to what I've implemented.

As for Lua, since this is being done within a pcall to extras_yield, the stack is about as isolated and protected as we could hope for.

While I still think the coroutine approach is the better one most of the time, there are cases where a linear non-callback approach to things just makes more sense/is easier to code...

Is it worth re-considering this? I can argue both ways in my head, so... I figured I'd bounce it off you guys.

I鈥檓 fine with it being included if it鈥檚 useful, although maybe it needs some scary docs to warn people that Here Be Dragons?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

reestr picture reestr  路  3Comments

dasmurphy picture dasmurphy  路  4Comments

tomrbowden picture tomrbowden  路  3Comments

asmagill picture asmagill  路  4Comments

BigSully picture BigSully  路  3Comments