yabai: Commands fail from Hammerspoon

Apologies if this is not the right place to post a question like I have, but I’m not sure where else to go.

I am attempting to invoke yabai through key bindings implemented in Hammerspoon. Both Hammerspoon and yabai have accessibility permissions granted. Yabai commands work fine from the terminal. But attempting to execute yabai commands, e.g. os.execute("/usr/local/bin/yabai -m window --focus west") in Hammerspoon fails with exit code 1. The Yabai log prints EVENT_HANDLER_DAEMON_MESSAGE: window --focus west, but then it hangs briefly and fails. There’s nothing in the error log either.

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Comments: 31 (6 by maintainers)

Most upvoted comments

Yes, it’s “blocking” on the fifo:read('a') line.

And to answer your other question, no… and yes, sorta.

Lua is by default a single application threaded language… there are some various projects out there that attempt to make Lua truly multi-threaded in the sense most people mean when they talk about multi-threaded applications, but their statuses vary and in any case, we’re not using any of them.

And while Lua is running code (i.e. in the middle of your function), Hammerspoon can’t do anything else (timers, tasks, events, hotkeys, etc.) on the main application thread because that’s where Lua is currently processing code.

However, Lua does support coroutines, which is a way for a particular chunk of Lua code to “pause” it’s execution and wait for a separate “thread” (poor choice of words, in my opinion, but that’s what the Lua docs use) of Lua code to perform some action. This is an extreme over simplification and more detail is beyond the scope of this thread – if you’re really interested check out the Lua docs at https://www.lua.org/docs.html. I’ve found the third and fourth editions of the “Programming in Lua” book they mention very useful over the years, but the online reference is good, if a little more formal and reference like (as opposed to the books which provide demonstrations), as well.

BUT because of programming choices made early on with Hammerspoon, coroutines are not safe to use in the current release or earlier versions of Hammerspoon if your code uses any functionality that Hammerspoon has added beyond just stock Lua code within the coroutine – none of our modules work, none of the hooks into hotkeys, timers, tasks, etc. work, etc.

This has been fixed in the current development version of Hammerspoon which means it will be fixed in the next release of Hammerspoon, but I don’t have any idea what the time frame on that is. I would assume it’s relatively soon as there have been some other additions and bug fixes recently, but there are a couple of pull requests that I think we’re still trying to nail down first.

Once that version (or a development version if you want to try your hand at building it yourself – you’ll need XCode. Search the Hammerspoon issues and you should find at least a couple of threads about building it yourself) is installed, the following would work (also just tested this myself):

-- this version can *only* be used if invoked from within a coroutine
function getYabaiWindowsFromWithinCoroutine()
    if not coroutine.isyieldable() then
        error("this function cannot be invoked on the main Lua thread")
    end
    
    local taskIsDone = false
    local output
    local task = hs.task.new('/usr/local/bin/yabai',
        function(_, stdOut, stdErr)
            output = stdOut
            taskIsDone = true
        end,
        { '-m', 'query', '--windows'})
    task:start()

    -- this code waits until the flag taskIsDone is set, but requires this function to only
    -- be invoked from within a coroutine
    while not taskIsDone do
        coroutine.applicationYield()
    end
    return output
end

-- this is just a simplifier -- it wraps our code in a coroutine and starts it
-- in this case, I wanted to make sure that it always creates a *new* coroutine
-- so it can be invoked anew each time the hotkey below triggers it
function makeActionACoroutine()
    -- this makes your code run with a coroutine
    coroutine.wrap(function()
        -- this is where all of your code needs to go to use the getYabaiWindowsFromWithinCoroutine
        -- function and "wait" for it's response
        local yOutput = getYabaiWindowsFromWithinCoroutine()
        local yJson = hs.json.decode(yOutput)
        for i,v in ipairs(yJson) do
            print(v.id, v.app, v.title)
        end
    end)() -- and starts the coroutine running
end

-- I mostly prefer the action when I release the key, unless I'm also soing something with
-- the repeats, hence the initial nil
k = hs.hotkey.bind({"cmd","ctrl","alt"}, "g", nil, makeActionACoroutine)

What coroutine.applicationYield does is “yield” the coroutine (i.e. pause it and allow other code to do something) and then start a timer that automatically “resumes” the coroutine when the timer fires. In between, the Hammerspoon main application thread is allowed to respond to other events (timers, etc.) that have queued up for action. But as stated in the comments, it requires your code to be fully contained within a coroutine to work properly.

So, yes, it will be possible to pause and give Hammerspoon the time it needs to respond to external queries (a) with the next version of Hammerspoon, and (b) if you write your code appropriately and enclose the bulk of it within a coroutine.

I’m not sure what I was thinking… the coroutine support won’t help in that case because the function isn’t in a coroutine…

If you truly need to wait until the result is available, your best bet really is to use hs.execute instead of hs.task.

function getYabaiWindows()
    local out, status, exitType, rc = hs.execute("/usr/local/bin/yabai -m query --windows")
    if status and rc == 0 then
        return output
    elseif exitType == "exit" then
        -- the program exited normally, but with an error code in rc -- the error code is command
        -- specific, so it would depend upon what return values yabai can respond with; you'll
        -- need to check its docs and see what they are and what you might want to do here
        -- based on the value of `rc`
    else -- exitType == "signal"
        -- the command terminated because of a signal (TERM, KILL, SEGFAULT, etc.)
        -- and rc will be the number of the signal that caused the command to terminate
        -- again, you can do something else here if you need to
    end
end

I added error checking in there… if you really don’t care and can reasonably assume it won’t fail, then you could simplify it down to:

function getYabaiWindows()
    return hs.execute("/usr/local/bin/yabai -m query --windows")
end

If it does fail in this simplified version, the first argument returned will be an empty string (because it just returns the results from hs.execute, all four of the return values are actually returned, but if you do something like output = getYabaiWindows() then the other three are just silently dropped.)

hs.task is designed for more complex cases – if you specifically require stderr, if you need to programmatically provide data for stdin, if you are invoking something long running and you don’t want to block or wait until it’s finished to do something else while waiting for the callback function to be invoked, etc.

I’m really not sure why your original function seemed to work for me – it really shouldn’t have, the more I understand how hs.task is written. I’m not sure if we can make waitUntilExit reliably work like you want it to or not, but I’ll give it some thought. It will probably be sometime next week, though, as I have other priorities I’m trying to get finished first.

However, for your use case, I really think hs.execute should be sufficient.