Skip to content

refactor(hammerspoon): split init.lua into monitors + linkrouting - #32

Merged
kelvinst merged 7 commits into
mainfrom
dot-m12-split-monitors-and-linkrouting
Sep 18, 2026
Merged

kelvinst merged 7 commits into
mainfrom
dot-m12-split-monitors-and-linkrouting

Conversation

@kelvinst

@kelvinst kelvinst commented Sep 7, 2026

Copy link
Copy Markdown
Owner

hammerspoon/init.lua was 246 lines holding two unrelated concerns: the
aerospace 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

File Lines Holds
hammerspoon/init.lua 13 require("hs.ipc"), then wires up the two modules
hammerspoon/monitors.lua 143 screen watcher + orbit sync-monitors retry/backoff chain
hammerspoon/linkrouting.lua 118 OPEN_LINK shell + hs.urlevent.httpCallback

Plain Lua modules, not Spoons. Spoons buy distribution and reuse — a
Name.spoon/ dir, obj metadata, :start()/:stop()/:bindHotkeys(),
docs.json, hs.loadSpoon — none of which applies to personal single-user
dotfiles that are never published. ~/.hammerspoon is already on
Hammerspoon's package.path, so a bare require works, and the Makefile
already handles .hammerspoon as 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 — the
link-opening hs.task was started with nothing referencing it, so a
collection 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 — found
while chasing a report that dock/undock had stopped changing layouts.
aerospace layout --window-id X accordion exits 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_ids
becomes sync_monitors_tiling_windows and returns the layout alongside the
id, 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:

  1. The watcher, the pending timer and the running task are held on
    purpose.
    A garbage-collected hs.screen.watcher stops firing —
    silently, with nothing logged — and a collected hs.timer/hs.task
    takes the retry chain with it. monitors.lua hangs the watcher off its
    module table and keeps the rest as file-locals; init.lua holds both
    modules in globals, since a local m = require(...) there would go
    out of scope when the chunk returns.
  2. hs.urlevent.httpCallback is a global assignment. It moved to
    linkrouting.lua and stays global there.

Verification

  • luac -p on all three Lua files; sh -n on the OPEN_LINK body and on
    bin/orbit
  • relocated bodies diffed against main — identical apart from the
    M.screenWatcher rename and reflowed comments
  • make install + hs.reload() — console clean; monitors and
    linkrouting live, monitors.screenWatcher a live watcher,
    hs.urlevent.httpCallback a function, http/https still handled by
    Hammerspoon
  • task retention smoke-tested: a task held only by an equivalent table
    survived two forced collectgarbage("collect") passes and still called
    back
  • orbit fix has a repro script: before it, a second --force run on
    already-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 1 every minute and went quiet the moment the
    fix 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.sh now pins it, along with a make test that walks
./test/*.sh (the repo had no test harness before this).

It drives bin/orbit against a stubbed aerospace on PATH with
XDG_CACHE_HOME in a temp dir — driving the real one would flatten
workspaces 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 layout for 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

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
kelvinst force-pushed the dot-m12-split-monitors-and-linkrouting branch from e23d9cb to 9b1aee2 Compare September 7, 2026 12:52
`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
kelvinst force-pushed the dot-m12-split-monitors-and-linkrouting branch from b707a3e to eb159de Compare September 16, 2026 01:44
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.
@kelvinst
kelvinst merged commit f90076c into main Sep 18, 2026
@kelvinst
kelvinst deleted the dot-m12-split-monitors-and-linkrouting branch September 18, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant