From 96eeec0069824a5281f0a80a3ce88baefb8a7072 Mon Sep 17 00:00:00 2001 From: gapul <92638132+gapul@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:04:50 +0900 Subject: [PATCH] =?UTF-8?q?refactor(mutagen):=20one=20mechanism=20?= =?UTF-8?q?=E2=80=94=20pair=20on=20ssh,=20nothing=20on=20a=20schedule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launchd agent recreated ~/Sync/mvrx-nolang-dev at login and every hour whether or not that machine had been touched, which is the opposite of what the directory is meant to tell you. The ssh path already covers it; the -sync alias is now looked up from ssh_config instead of being hard-coded in a peers list. --- nix/home/mutagen-sync.nix | 116 +++++++++----------------------------- 1 file changed, 27 insertions(+), 89 deletions(-) diff --git a/nix/home/mutagen-sync.nix b/nix/home/mutagen-sync.nix index 09f9b95b..aa30a953 100644 --- a/nix/home/mutagen-sync.nix +++ b/nix/home/mutagen-sync.nix @@ -20,9 +20,11 @@ # far side. That is fine for machines we own (homelab, macmini) and not fine for an external # company server, so those go through mutagen over the SSH connection we already have. # -# Which hosts get a session: whichever ones we actually ssh into. The zsh wrapper below pairs -# a host the first time we reach it, so the list maintains itself instead of being curated here. -# The peers list stays for the one host that needs a hand-written entry (see sshHost). +# Which hosts get a session: whichever ones we actually ssh into, and only then. The zsh wrapper +# below pairs a host the first time we reach it, so the list maintains itself instead of being +# curated here, and ~/Sync never shows a directory for a machine we have not talked to. +# There is deliberately no launchd agent: a background job would recreate directories on a +# schedule, which is exactly the thing we do not want to see. # # Why not restic-backed up: the data exists on both sides by definition, so restic keeps # covering ~/Sync/syncthing only (see home/restic-backup.nix). Nothing here is the only copy @@ -36,32 +38,20 @@ # .git carries absolute paths (git worktrees) and an index that must stay consistent with the # working tree, neither of which survives being copied file by file. # -# Prerequisites (harmless if unmet — the agent logs and exits, and launchd retries hourly): -# the sshHost entry resolves in ~/.ssh/config and accepts a key-based login. Check with -# ssh true -# That entry is a sync-only alias, and it has to be, for two reasons that both made this module -# fail silently for its first days: -# - launchd does not hand SSH_AUTH_SOCK down, so an agent-held key is invisible here unless the -# entry pins IdentityAgent itself. -# - an interactive entry that carries LocalForward plus ExitOnForwardFailure makes every later -# connection exit 255 while a normal session holds those ports. The alias clears forwardings. -# Both live in the sops-managed ssh_config, not here. +# Prerequisites (harmless if unmet — pairing is skipped and logged, and the next ssh retries): +# the host accepts an unattended key-based login. Check with +# ssh -o BatchMode=yes true +# +# The "-sync" convention: when ssh_config defines an entry of that name, it is dialled +# instead of the interactive one. That exists because an interactive entry can carry LocalForward +# plus ExitOnForwardFailure, and then every further connection exits 255 while a normal session +# holds those ports — precisely when the machine is in use. The sync alias clears forwardings. +# Agent access is not part of that convention any more: Host * pins IdentityAgent, which is what +# the mutagen daemon needs, since nothing hands SSH_AUTH_SOCK to it. let home = config.home.homeDirectory; logDir = "${home}/Library/Logs/mutagen"; - # session name == directory name under ~/Sync == SSH host name. - # remoteDir is relative to the far side's home directory. - # sshHost is the entry mutagen dials, which is deliberately not the interactive one: see the - # comment on the prerequisites above. - peers = [ - { - host = "mvrx-nolang-dev"; - sshHost = "mvrx-nolang-dev-sync"; - remoteDir = "Sync/MacBook-Mini"; - } - ]; - # No openssh here on purpose: mutagen shells out to `ssh`, and the one that is known to work # with this machine's config (Bitwarden agent socket, per-host IdentityFile) is /usr/bin/ssh, # which launchd already has on PATH. @@ -70,46 +60,6 @@ let pkgs.coreutils ]; - syncScript = - { - host, - sshHost, - remoteDir, - }: - let - localDir = "${home}/Sync/${host}"; - logFile = "${logDir}/${host}.log"; - in - pkgs.writeShellScript "mutagen-sync-${host}" '' - set -uo pipefail - export PATH=${mutagenBin}:$PATH - mkdir -p "${logDir}" "${localDir}" - - # Always exit 0. Paired with KeepAlive.SuccessfulExit=false below, launchd leaves a failed - # attempt alone instead of respawning it in a tight loop; StartInterval retries it hourly, - # which is what picks the session back up when the host was unreachable at login. - if mutagen sync list "${host}" >/dev/null 2>&1; then - exit 0 - fi - - echo "$(date '+%F %T') creating session ${host}" >>"${logFile}" - # mutagen creates the synchronization root but not the directories above it, and a missing - # parent only shows up afterwards as a transition problem on a session that otherwise looks - # healthy ("unable to walk to transition root parent"). Make the parent first. - /usr/bin/ssh -o BatchMode=yes "${sshHost}" "mkdir -p ${remoteDir}" >>"${logFile}" 2>&1 || true - # Creating a session contacts the far side to install the agent, so this is also where an - # unreachable or unconfigured host shows up. mutagen keeps reconnecting on its own once - # the session exists, so this only ever runs again after a terminate. - if ! mutagen sync create \ - --name="${host}" \ - --ignore-vcs \ - --ignore=.DS_Store \ - "${localDir}" "${sshHost}:${remoteDir}" >>"${logFile}" 2>&1; then - echo "$(date '+%F %T') SKIP: could not create session (check: ssh ${sshHost} true)" >>"${logFile}" - fi - exit 0 - ''; - # Pair whatever host we just reached over ssh. Called by the zsh wrapper with the very argv # the user typed, so the destination has to be dug out of it the way ssh itself would. ensureScript = pkgs.writeShellScriptBin "mutagen-sync-ensure" '' @@ -134,7 +84,7 @@ let host="''${dest#*@}" # Only named entries pair. An address is a one-off, and it would name the sync directory - # after something that changes; *-sync is our own alias for a host the peers list owns. + # after something that changes; *-sync is the alias we dial, not a machine of its own. case "$host" in *.* | *:* | */*) exit 0 ;; *-sync) exit 0 ;; @@ -159,9 +109,18 @@ let localDir="${home}/Sync/$host" remoteDir="Sync/$(scutil --get LocalHostName 2>/dev/null || hostname -s)" + # Dial the sync alias when ssh_config defines one: the interactive entry may carry port + # forwards that make a second connection fail exactly while the machine is in use. + dial="$host" + if awk -v want="$host-sync" ' + tolower($1)=="host"{for(i=2;i<=NF;i++) if($i==want){found=1}} + END{exit !found}' "${home}/.ssh/config" 2>/dev/null; then + dial="$host-sync" + fi + # Reachability and the parent directory in one round trip. BatchMode so a host that wants a # password fails here instead of waiting on a prompt that nobody can see. - if ! /usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=10 "$host" "mkdir -p '$remoteDir'"; then + if ! /usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=10 "$dial" "mkdir -p '$remoteDir'"; then echo "$(date '+%F %T') skip $host: no unattended login" exit 0 fi @@ -172,7 +131,7 @@ let --name="$host" \ --ignore-vcs \ --ignore=.DS_Store \ - "$localDir" "$host:$remoteDir"; then + "$localDir" "$dial:$remoteDir"; then printf '%s' "$host" >"$marker" else # leave no empty ~/Sync/ behind to suggest a pairing that does not exist @@ -199,25 +158,4 @@ in } ''; - # One agent per peer. The agent only ensures the session exists; the synchronization itself is - # carried by mutagen's own daemon, which the CLI starts on demand and which stays resident. - # If that daemon ever dies unnoticed, replace this with a foreground `mutagen daemon run` agent. - launchd.agents = lib.listToAttrs ( - map (peer: { - name = "mutagen-sync-${peer.host}"; - value = { - enable = true; - config = { - ProgramArguments = [ "${syncScript peer}" ]; - RunAtLoad = true; - StartInterval = 3600; # retry hourly: the host is often unreachable at login - KeepAlive.SuccessfulExit = false; - ProcessType = "Background"; - LowPriorityIO = true; - Nice = 5; - StandardErrorPath = "${logDir}/${peer.host}.log"; - }; - }; - }) peers - ); }