From 877c3ba7ae9bf81df6174ea5851cc3a284d92afd Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 21 Aug 2026 19:03:39 +0100 Subject: [PATCH] One daemon owns the hotkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 4 ++ Sources/yap/DaemonLock.swift | 110 +++++++++++++++++++++++++++++++++++ Sources/yap/Paths.swift | 17 ++++++ Sources/yap/Yap.swift | 6 ++ 4 files changed, 137 insertions(+) create mode 100644 Sources/yap/DaemonLock.swift diff --git a/README.md b/README.md index 6e34fa2..31d6824 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ the wrong window. yap holds the most recent one in memory and nowhere else. "Quit yap" stops the background daemon until your next login. `yap start` brings it back sooner. +One daemon holds the hotkey at a time. Start another — from Applications, from +a terminal, or because an upgrade restarted the login item — and the new one +takes over and the old one stops, so a press is never captured or typed twice. + If you run a menu bar manager — Ice, Thaw, Bartender — and the mark is nowhere to be seen, look in its hidden section. Managers that file newly-appeared items there catch yap the first time it shows up. Reveal that section, then hold diff --git a/Sources/yap/DaemonLock.swift b/Sources/yap/DaemonLock.swift new file mode 100644 index 0000000..f5c3f3c --- /dev/null +++ b/Sources/yap/DaemonLock.swift @@ -0,0 +1,110 @@ +import Darwin +import Foundation + +/// One daemon owns the hotkey. +/// +/// Nothing else enforces that, and there are two ways the daemon starts. The +/// login item is a LaunchAgent, so launchd execs the binary directly; the .app +/// in /Applications is launched by LaunchServices when you double-click it. +/// The two routes are invisible to each other — only the LaunchServices one +/// registers as an application, which is why `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. Both processes then install an event tap on the same +/// key, and every press is captured twice and typed twice. +/// +/// The cask makes that routine rather than exotic. Its postflight runs +/// `launchctl kickstart -k`, which starts the login job whether or not it was +/// already running, so anyone whose daemon came up by double-click — or who +/// used "Quit yap" and started it again from Applications — gets a second one +/// on their next `brew upgrade`, the old image and the new listening together. +/// +/// A POSIX 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, so there is no stale file to sweep up and no recorded pid that may +/// since have been recycled onto something unrelated. +enum DaemonLock { + /// Held for the life of the process. Closing this descriptor drops the + /// lock, so nothing ever closes it. + private static var descriptor: Int32 = -1 + + /// Take the hotkey, replacing a daemon that already holds it. + /// + /// Last launch wins, deliberately. You either double-clicked the app or + /// Homebrew replaced the binary under the running one, and both mean "this + /// image, from now on" — refusing to start would leave an upgrade running + /// the code it just replaced, which is the worst shape for a bug because + /// `yap --version` reads the new binary on disk and agrees with the + /// version you installed. + /// + /// The incumbent gets SIGTERM, which its own handler routes through + /// `applicationWillTerminate`: a meeting recording in flight is finalized + /// and transcribes on the next start rather than losing its meta.json, and + /// the mic gain lease is given back. It exits 0, so `KeepAlive + /// SuccessfulExit:false` leaves the job down instead of racing us for it. + /// + /// False means someone else still owns the hotkey and this process must + /// not run. + static func claim() -> Bool { + let path = Paths.daemonLock.path + let handle = open(path, O_CREAT | O_RDWR, 0o600) + guard handle >= 0 else { + // A lock we cannot take is not a reason to refuse to dictate. The + // guard exists to stop a duplicate typing everything twice, and + // losing it is strictly better than losing the daemon. + warn("warning: cannot open \(path) (\(errnoText())) — starting without the daemon lock") + return true + } + descriptor = handle + + if take(handle) { return true } + guard let holder = holder(of: handle) else { + // It was held a moment ago and is not held now, so the holder + // exited between the two calls. One more attempt, and if that + // fails something else took it in the same instant — treat it the + // same as a live incumbent that will not yield. + return take(handle) + } + + warn("another yap daemon has the hotkey (pid \(holder)) — replacing it") + // ESRCH means it exited while we were reading the lock, which is the + // outcome we wanted anyway; the wait below sees the lock go. + if kill(holder, SIGTERM) != 0, errno != ESRCH { + warn("could not signal pid \(holder) (\(errnoText())) — leaving it the hotkey") + return false + } + // Long enough for the other side to finalize a session it was writing, + // short enough that launch-at-login is not held up by a wedged + // process. Polling rather than waiting on the lock, because F_SETLKW + // would block forever against exactly that. + for _ in 0..<100 { + usleep(50_000) + if take(handle) { return true } + } + warn("pid \(holder) did not stop — leaving it the hotkey") + return false + } + + // MARK: - + + /// Whole-file write lock, non-blocking. + private static func take(_ handle: Int32) -> Bool { + var lock = Darwin.flock( + l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(F_WRLCK), l_whence: Int16(SEEK_SET)) + return fcntl(handle, F_SETLK, &lock) == 0 + } + + /// The pid holding the lock, straight from the kernel, or nil if nobody + /// holds it. + private static func holder(of handle: Int32) -> pid_t? { + var probe = Darwin.flock( + l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(F_WRLCK), l_whence: Int16(SEEK_SET)) + guard fcntl(handle, F_GETLK, &probe) == 0, probe.l_type != Int16(F_UNLCK), probe.l_pid > 0 + else { return nil } + return probe.l_pid + } + + private static func errnoText() -> String { + String(cString: strerror(errno)) + } +} diff --git a/Sources/yap/Paths.swift b/Sources/yap/Paths.swift index fcd1af6..efdca0d 100644 --- a/Sources/yap/Paths.swift +++ b/Sources/yap/Paths.swift @@ -23,6 +23,23 @@ enum Paths { static var stdoutLog: URL { logDirectory.appendingPathComponent("yap.out.log") } static var stderrLog: URL { logDirectory.appendingPathComponent("yap.err.log") } + /// `~/Library/Application Support/yap/daemon.lock`, the file whose write + /// lock means "this process owns the hotkey". See `DaemonLock`. + /// + /// Not in the log directory, tempting though that is for being created + /// already: someone clearing logs would delete the lock from under a live + /// daemon, and the next one to start would then see the hotkey as free. + static var daemonLock: URL { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/yap", isDirectory: true) + try? FileManager.default.createDirectory( + at: dir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + return dir.appendingPathComponent("daemon.lock") + } + /// launchd creates the log files itself and does not honour a mode, so /// tighten them to 0600 after the agent is installed. Existing files are /// tightened too, which covers an agent installed by an earlier build. diff --git a/Sources/yap/Yap.swift b/Sources/yap/Yap.swift index 7f970fb..ba1394a 100644 --- a/Sources/yap/Yap.swift +++ b/Sources/yap/Yap.swift @@ -87,6 +87,12 @@ struct Run: ParsableCommand { try checkStartup(root: root, model: chosenModel, hotkey: key) } + // After the flags, the config and the doctor report have all had their + // say, so a typo or a missing grant never costs someone the daemon + // they already had — and before the model loads, so a takeover never + // holds two copies of it in memory at once. + guard DaemonLock.claim() else { throw ExitCode.success } + // One model, loaded once, before anything can ask for it. Warming up // front is the difference between a 60 ms press and a press that waits // out an ANE compile.