refactor(hammerspoon): split init.lua into monitors + linkrouting - #32
Merged
Merged
Conversation
init.lua held two unrelated concerns — the aerospace monitor-sync screen watcher and http(s) link routing — because link routing was appended to what started as the watcher. Plain Lua modules, not Spoons: ~/.hammerspoon is already on the package path, and Spoon ceremony buys distribution and reuse that never applies to single-user dotfiles. The watcher, pending timer and running task are collected the moment nothing references them, and a collected watcher stops firing silently, so monitors.lua hangs the watcher off its module table and init.lua holds both modules in globals. hs.urlevent.httpCallback stays a global assignment, now in linkrouting.lua. No behavior change.
kelvinst
force-pushed
the
dot-m12-split-monitors-and-linkrouting
branch
from
September 7, 2026 12:52
e23d9cb to
9b1aee2
Compare
`hs.task.new(...):start()` left the task referenced by nothing: httpCallback returns immediately and the userdata is unreachable from Lua. A collection during the up-to-half-second wait for the window raise finalizes it before it exits, so the callback never runs — the link never opens and the failure alert never fires either, leaving the click with no trace at all. Same discipline monitors.lua already applies to its sync task, but a table rather than a single local: link clicks overlap, and one slot would drop the reference to whichever run is still going.
`aerospace layout --window-id X accordion` exits 1, with nothing on stderr, when the layout asked for is already the one in force. By exit code alone that is indistinguishable from a real failure, and `sync_monitors_set_layout` was treating it as one. On an undock every populated workspace is already accordion, so every call failed, the run reported failure, and the mode was never recorded. The stale state file then sent the next dock into the early return that skips the work entirely — layouts stopped changing on dock or undock, and the display watcher retried into the same wall every 60s. `sync_monitors_tiling_ids` becomes `sync_monitors_tiling_windows` and hands back the layout alongside the id, so the loop can skip a workspace that is already laid out instead of asking and misreading the answer. The layout in force is oriented (`h_accordion`) and the one asked for is not (`accordion`), so the two compare on the suffix. A genuine failure still fails the run.
The display watcher moved out of `init.lua` when it was split into modules; the help text still sent readers to the old file.
dot-zq9 ran silently for a week: every sync exited 1, the mode was never recorded, and nothing anywhere would have noticed. This is the first test in the repo, so it also brings a `make test` that walks ./test/*.sh. The test drives bin/orbit against a stubbed `aerospace` on PATH with XDG_CACHE_HOME pointed at a temp dir. Driving the real one would mean flattening workspaces and moving them between monitors on whatever machine runs the test — rude, and only correct in one dock state. The stub records what it was asked to do, so a case can assert that orbit did NOT call `aerospace layout` for a workspace already in the target layout, which is the whole of dot-zq9. Alongside it the paths that skip must not swallow: a genuine layout failure still fails the run, an unreadable monitor count still aborts without recording, the dock direction still seeds 0/G/M/P, and an unchanged mode still returns early without mutating anything. Those five pass against the pre-fix orbit too — only the dot-zq9 case flips.
kelvinst
force-pushed
the
dot-m12-split-monitors-and-linkrouting
branch
from
September 16, 2026 01:44
b707a3e to
eb159de
Compare
The display watcher isn't working well, so init.lua no longer requires monitors.lua. The module stays in the repo and installed; turning it back on is uncommenting the one line. Revisit tracked in dot-nhy.
The workspace-mutation lock lived at a fixed /tmp path the test sandbox didn't cover, so a live orbit run holding it failed the suite, and the suite holding it silently no-op'd real keybindings. ORBIT_LOCK_DIR now overrides the path, and the test points it into its temp dir.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
hammerspoon/init.luawas 246 lines holding two unrelated concerns: theaerospace monitor-sync screen watcher, and http(s) link routing. History, not
design — init.lua started as the watcher and link routing got appended to it.
What changed
hammerspoon/init.luarequire("hs.ipc"), then wires up the two moduleshammerspoon/monitors.luaorbit sync-monitorsretry/backoff chainhammerspoon/linkrouting.luaOPEN_LINKshell +hs.urlevent.httpCallbackPlain Lua modules, not Spoons. Spoons buy distribution and reuse — a
Name.spoon/dir,objmetadata,:start()/:stop()/:bindHotkeys(),docs.json,hs.loadSpoon— none of which applies to personal single-userdotfiles that are never published.
~/.hammerspoonis already onHammerspoon's
package.path, so a barerequireworks, and the Makefilealready handles
.hammerspoonas a whole directory at every stage (install,backup, clean, update), so the new files need no Makefile change.
The refactor itself is behaviour-preserving: module bodies were moved
verbatim, and the only edited lines are comments.
Two follow-up fixes rode along
fix(hammerspoon): hold the link-opening task until it exits— thelink-opening
hs.taskwas started with nothing referencing it, so acollection during the up-to-half-second wait for the window raise would
finalize it before it exited: the callback never runs, the link never opens,
and the failure alert never fires either. Now held in a module-level table
until it exits — a table rather than a single slot, because link clicks
overlap. Pre-existing on main; fixed here because this PR is about lifetimes.
fix(orbit): don't read an already-applied layout as a failure— foundwhile chasing a report that dock/undock had stopped changing layouts.
aerospace layout --window-id X accordionexits 1, with nothing on stderr,when the layout asked for is already in force — indistinguishable by exit
code from a real failure. On an undock every populated workspace is already
accordion, so every call failed, the run reported failure, and the mode was
never recorded; the stale state file then sent the next dock into the early
return that skips the work entirely. Self-perpetuating: the display watcher
retried into the same wall every 60s for days.
sync_monitors_tiling_idsbecomes
sync_monitors_tiling_windowsand returns the layout alongside theid, so an already-laid-out workspace is skipped rather than misread. A
genuine failure still fails the run. (dot-zq9)
What a reviewer should know
Two lifetime gotchas the split had to respect:
purpose. A garbage-collected
hs.screen.watcherstops firing —silently, with nothing logged — and a collected
hs.timer/hs.tasktakes the retry chain with it.
monitors.luahangs the watcher off itsmodule table and keeps the rest as file-locals;
init.luaholds bothmodules in globals, since a
local m = require(...)there would goout of scope when the chunk returns.
hs.urlevent.httpCallbackis a global assignment. It moved tolinkrouting.luaand stays global there.Verification
luac -pon all three Lua files;sh -non theOPEN_LINKbody and onbin/orbitmain— identical apart from theM.screenWatcherrename and reflowed commentsmake install+hs.reload()— console clean;monitorsandlinkroutinglive,monitors.screenWatchera live watcher,hs.urlevent.httpCallbacka function, http/https still handled byHammerspoon
survived two forced
collectgarbage("collect")passes and still calledback
--forcerun onalready-accordion workspaces exits 1 and never writes the state file;
after, it exits 0 and records
solo, while a bogus window-id still fails.Confirmed live — the watcher's 60s retry chain had been logging
orbit sync-monitors exited 1every minute and went quiet the moment thefix was installed.
A regression test rode along too
dot-zq9 ran silently for a week and nothing would have caught it, so
test/sync-monitors.shnow pins it, along with amake testthat walks./test/*.sh(the repo had no test harness before this).It drives
bin/orbitagainst a stubbedaerospaceon PATH withXDG_CACHE_HOMEin a temp dir — driving the real one would flattenworkspaces and move them between monitors on whatever machine runs the test,
and would only be correct in one dock state. The stub records what it was
asked to do, so the dot-zq9 case can assert orbit did not call
aerospace layoutfor a workspace already in the target layout.Five more cases cover what the skip must not swallow: a genuine layout
failure still fails the run, an unreadable monitor count still aborts without
recording, the dock direction still seeds 0/G/M/P, and an unchanged mode
still returns early without mutating anything. Those five pass against the
pre-fix orbit as well — only the dot-zq9 case flips, which is what makes it a
regression test rather than a restatement of the fix.
Still worth a manual pass: a real link click on a workspace with and without
a Chrome window.
Closes dot-m12, dot-zq9.
🤖 Generated with Claude Code