Skip to content

One daemon owns the hotkey - #12

Merged
TerrifiedBug merged 1 commit into
mainfrom
fix/single-daemon-lock
Aug 21, 2026
Merged

One daemon owns the hotkey#12
TerrifiedBug merged 1 commit into
mainfrom
fix/single-daemon-lock

Conversation

@TerrifiedBug

Copy link
Copy Markdown
Owner

Two yap daemons could run at once, and both tap the same key — every press captured twice, every transcript typed twice.

Nothing prevented it, and it is not really about shipping a CLI inside a .app: there is one binary, and the bundle is only there so the build can be notarized and TCC has a stable identity to hang grants on. The problem is that the daemon has two entry routes that cannot see each other. launchd execs the binary directly for the login item; LaunchServices launches the app when you double-click it in Applications. Only the second registers as an application — launchctl list shows a double-clicked yap as application.com.terrifiedbug.yap.… and the agent merely as its label — so macOS's own "this app is already running" handling never sees the pair.

The cask makes it routine rather than exotic. Its postflight runs launchctl kickstart -k, which starts the login job whether or not it was running, so anyone whose daemon came up by double-click (or who used "Quit yap" and reopened it from Applications) gets a second daemon on their next brew upgrade: the old mapped image and the new one, listening together.

Fix

yap run claims a POSIX write lock on ~/Library/Application Support/yap/daemon.lock before it loads the model — after flag/config validation and the doctor report, so a typo or a missing grant never costs someone the daemon they already had, and before the model load, so a takeover never holds two copies of 220 MB.

A record lock rather than a pid file: the kernel holds it, it is released however the holder dies including a crash, and F_GETLK names the holder — no stale file to sweep up, and no recorded pid that may since have been recycled onto an unrelated process.

Last launch wins. The incumbent gets SIGTERM, which its own handler already routes through applicationWillTerminate, so a meeting recording in flight is finalized and transcribes on the next start instead of losing its meta.json, and the mic gain lease goes back. It exits 0, so KeepAlive SuccessfulExit:false leaves the job down rather than racing the newcomer. The alternative — newcomer refuses to start — would leave an upgrade running the code it just replaced while yap --version reads the new binary and agrees with the version you installed, which is the worst shape for a bug.

An incumbent that will not yield inside five seconds keeps the hotkey; the newcomer says so and exits 0, so launchd does not spin it.

Verification

Two real daemons, launched the way a user would, dev build:

step observed
daemon A alone listening on fn hold …
daemon B started B: another yap daemon has the hotkey (pid 49933) — replacing it → loads → listening; A: shutting down; exactly one process left
incumbent wedged (kill -STOP), daemon C started C: pid 50082 did not stop — leaving it the hotkey, exit code 0 after the 5 s wait; incumbent untouched
lock free starts normally, no message

No change to any measured path: the claim is one open plus one fcntl at daemon startup, not per press, so the bench table and the key-down budget are untouched by construction.

Note on the first upgrade

The guard only binds processes that have it. Upgrading to the release carrying this can still leave a pre-0.1.5 incumbent alongside the new daemon for that one upgrade — the postflight's kickstart -k covers the launchd case, and after that both sides hold the lock.

Two yap daemons could run at once, and both would tap the same key: every
press captured twice and the transcript typed twice. Nothing prevented it,
because the two ways the daemon starts cannot see each other. launchd
execs the binary directly for the login item, LaunchServices launches the
.app when you double-click it, and only the second registers as an
application — so macOS's own "already running" handling never sees the
pair.

The cask makes it routine rather than exotic. Its postflight runs
`launchctl kickstart -k`, which starts the login job whether or not it was
running, so anyone whose daemon came up by double-click gets a second one
on their next `brew upgrade` — the old image and the new one listening
together.

`yap run` now claims a POSIX write lock on
~/Library/Application Support/yap/daemon.lock before it loads the model.
The kernel holds it, releases it however the holder dies, and F_GETLK
names the holder, so there is no stale pid file to sweep up and no
recorded pid that may since have been recycled onto something unrelated.

Last launch wins: the incumbent gets SIGTERM, which its own handler
routes through applicationWillTerminate, so a meeting recording in flight
is finalized rather than lost. Refusing to start instead would leave an
upgrade running the code it just replaced while `yap --version` reads the
new binary and agrees with what you installed — the worst shape for a
bug. An incumbent that will not yield inside five seconds keeps the
hotkey, and the newcomer exits 0 so KeepAlive does not spin it.
@TerrifiedBug
TerrifiedBug merged commit 4016a6f into main Aug 21, 2026
1 check passed
@TerrifiedBug
TerrifiedBug deleted the fix/single-daemon-lock branch August 21, 2026 18:06
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