Notes sur la construction de systèmes qui marchent et d'autres choses... peut-être ?

La course au démarrage du shell sur macOS

· 7 min

Reboot. Tu ouvres un terminal. Tu tapes git status.

zsh: command not found: git

Pas à chaque fois. C'est ça qui rendait fou. Peut-être un boot sur cinq, et seulement dans le premier terminal que j'ouvrais. Tu attends vingt secondes, tu ouvres un nouvel onglet, et tout remarche. git, nix, tous mes outils, revenus d'entre les morts. Du coup, pendant un moment, j'ai fait le truc bête : je fermais l'onglet et j'en ouvrais un autre. Problème réglé, façon de parler, c'est-à-dire ignoré.

L'outil manquant changeait selon la vitesse à laquelle je tapais. Parfois git. Parfois nix. Une fois c'était ls qui revenait de /bin au lieu de mes coreutils, ce qui est un genre de confusion bien à part, parce que ls « marchait », il se comportait juste différemment. Le point commun : tout ce que Nix mettait sur mon PATH pouvait disparaître, et plus j'attendais avant de vérifier, plus c'était probable que tout aille bien.

Ce dernier détail, c'est toute l'histoire. Je ne l'avais simplement pas lu comme ça au début.

D'où vient vraiment le PATH

Sur nix-darwin, mon shell ne code pas son PATH en dur. Il y a un script set-environment généré qui est chargé tout en haut de /etc/zshenv, et qui définit la valeur canonique :

$HOME/.nix-profile/bin:/etc/profiles/per-user/$USER/bin:/run/current-system/sw/bin:...

/run/current-system/sw/bin, c'est là que vit le profil système. Ce lien symbolique, /run/current-system, c'est ce que nix-darwin bascule quand il active une génération. Pas d'activation, pas de lien, pas de sw/bin, pas de git.

Mon premier réflexe a été de me dire que le PATH lui-même était faux. Il ne l'était pas. J'ai passé une soirée à ajouter des lignes export PATH=... dans ma config zsh, à les voir aider, puis à les voir casser tranquillement autre chose : le gpg de brew se mettait à gagner contre celui de nix, parce que mon PATH en dur laissait tomber /etc/profiles/per-user/$USER/bin. Alors j'ai tout arraché et j'ai laissé une note pour le moi du futur, toujours là :

# NOTE: Do NOT export a hardcoded PATH here. nix-darwin's
# /nix/store/.../set-environment script (loaded automatically at the top
# of /etc/zshenv) already sets the canonical PATH:

Le PATH était correct. Le problème, c'était le moment où il existait.

La course

L'activation de nix-darwin tourne comme un daemon launchd, org.nixos.activate-system. launchd le lance au boot. launchd lance aussi ta session, ton window server, ton terminal. Ces trucs ne s'attendent pas les uns les autres dans un ordre que je peux choisir. Le daemon qui crée /run/current-system et mon shell qui lit /run/current-system/sw/bin sont en course, et sur un boot lent, le shell gagne. Il démarre, charge set-environment, ne trouve aucun profil système, et me tend un PATH avec un trou dedans.

Ouvre un terminal vingt secondes plus tard et l'activation est terminée depuis longtemps. Voilà pourquoi attendre « réglait » le problème. Je ne réglais rien, je perdais juste la course moins souvent.

Une fois que tu le vois comme une course, la forme du correctif est évidente : le shell doit attendre le marqueur avant de faire confiance au PATH. Du coup, mon shellInit interroge le lien symbolique en boucle :

programs.zsh.shellInit = ''
  # Wait for nix-darwin activation (with 15s timeout to prevent hung shells).
  if [ ! -e /run/current-system/sw ]; then
    if /bin/wait4path /nix/store 2>/dev/null && [ ! -e /run/current-system/sw ]; then
      _nix_wait=0
      while [ ! -e /run/current-system/sw ] && [ $_nix_wait -lt 15 ]; do
        sleep 1
        _nix_wait=$((_nix_wait + 1))
      done
      unset _nix_wait
    fi
    if [ ! -e /run/current-system/sw ]; then
      echo "[nix-darwin] WARNING: /run/current-system/sw not found after 15s. Run 'make fix-nix' to recover." >&2
    fi
  fi
'';

Plusieurs choses là dedans ne sont pas évidentes, et je me suis trompé sur la plupart au premier essai.

/bin/wait4path est un binaire Apple qui bloque jusqu'à ce qu'un chemin apparaisse. Je l'utilise sur /nix/store, pas sur /run/current-system/sw, et c'est volontaire. Le montage du store, c'est ce que launchd met vraiment du temps à mettre en place au boot. Aucun intérêt à faire tourner ma propre boucle sleep tant que le disque n'est même pas encore monté. Donc je laisse l'outil d'Apple bloquer sur le montage, puis j'interroge le marqueur d'activation une fois que le store est réellement là. Si wait4path réussit et que le lien symbolique n'est toujours pas présent, alors on est dans la vraie course et la boucle seconde par seconde prend le relais.

Pourquoi le timeout compte plus que l'attente

L'issue de secours, c'est la partie que je sauterais si je voulais faire le malin, et la sauter, c'est exactement le bug.

Imagine l'attente sans && [ $_nix_wait -lt 15 ]. Le shell bloquerait jusqu'à ce que /run/current-system/sw apparaisse. La plupart du temps, ça va, il apparaît en une seconde ou deux. Mais si l'activation a vraiment échoué, si le daemon ne s'est jamais chargé, si j'ai cassé ma propre config et que darwin-rebuild n'a laissé aucune génération fonctionnelle, alors le lien symbolique n'apparaît jamais et le shell attend pour toujours. Chaque nouveau terminal se fige. Tu ne peux pas ouvrir un shell pour réparer la chose qui empêche tes shells de s'ouvrir. Je me suis fait ça à moi-même. Ce n'est pas un bon après-midi.

Donc la boucle est bornée à quinze secondes, et quand elle abandonne elle affiche un avertissement qui me dit quoi lancer :

[nix-darwin] WARNING: /run/current-system/sw not found after 15s. Run 'make fix-nix' to recover.

Un shell dégradé avec un trou dans son PATH, c'est agaçant. Un shell figé qui ne s'ouvre pas, c'est un problème de récupération. Quinze secondes couvrent toutes les courses de boot honnêtes que j'ai jamais mesurées, et l'avertissement transforme le cas irrécupérable en un cas lisible. L'attente règle le cas courant. Le timeout fait en sorte que le cas rare reste survivable.

Cet ordre, au passage, c'est le seul changement qui compte dans le commit qui a réglé tout ça. La version précédente lançait wait4path puis entrait toujours dans la boucle de polling, et unset _nix_wait vivait tout à la fin, là où il n'était pas toujours atteint. Replier la boucle à l'intérieur de la branche de succès de wait4path veut dire que je ne fais pas tourner un polling seconde par seconde quand le vrai problème est juste un montage du store lent. Petit diff. Ça se lit comme un refactoring. C'est la différence entre attendre la bonne chose et attendre la mauvaise.

Ce que le shell ne peut pas réparer

Le polling rend un shell correct patient. Il ne fait rien si l'activation ne tourne jamais du tout.

Et launchd, il s'avère, refuse de temps en temps de prendre en charge org.nixos.activate-system depuis /Library/LaunchDaemons au boot. Je n'arrive pas à le reproduire sur commande et je ne peux pas l'expliquer complètement. Le daemon est installé, le plist est correct, et certains matins le service n'est simplement pas chargé. Quand ça arrive, aucune quantité d'attente n'aide, parce que la chose que j'attends ne viendra jamais. Le shell atteint son timeout, affiche son avertissement, et je vais lancer make fix-nix.

Je ne voulais rien lancer à la main. Du coup, il y a un deuxième daemon dont le seul boulot est de s'assurer que le premier tourne. Un watchdog, installé en dehors de nix-darwin pour ne pas dépendre de l'activation de nix-darwin pour exister, ce qui serait circulaire :

<key>ProgramArguments</key>
<array>
	<string>/bin/sh</string>
	<string>-c</string>
	<string>/bin/wait4path /nix/store &amp;&amp; (/bin/launchctl kickstart system/org.nixos.activate-system 2>/dev/null || /bin/launchctl bootstrap system /Library/LaunchDaemons/org.nixos.activate-system.plist 2>/dev/null || true)</string>
</array>
<key>RunAtLoad</key>
<true/>

wait4path encore une fois, pour la même raison : ne pas toucher à launchctl tant que le store n'est pas monté. Ensuite, tenter de kickstart le service (le redémarrer s'il est déjà chargé), et si ça échoue, le bootstrap (le charger de zéro). L'un des deux est le bon geste selon que launchd a chargé le daemon à moitié ou l'a sauté complètement, et je ne sais pas dans quel état je vais être, donc je tente les deux et je passe avec || true par dessus celui qui était déjà fait.

Le bootstrap qui installe ce watchdog vit dans fix-nix.sh, et il fait attention à ne réinstaller que quand le plist a réellement changé :

if ! cmp -s "$WATCHDOG_SRC" "$WATCHDOG_PLIST" 2>/dev/null; then
    echo "🔧 Installing activate-system watchdog..."
    sudo cp "$WATCHDOG_SRC" "$WATCHDOG_PLIST"
    sudo chown root:wheel "$WATCHDOG_PLIST"
    sudo chmod 644 "$WATCHDOG_PLIST"
    sudo launchctl bootout system/org.nixos.activate-system-watchdog 2>/dev/null || true
    sudo launchctl bootstrap system "$WATCHDOG_PLIST" 2>/dev/null || true
fi

Donc il y a deux couches, et elles répondent à deux échecs différents. L'attente du shell gère le cas où l'activation tourne en retard : sois patient, puis abandonne bruyamment. Le watchdog gère le cas où l'activation ne tourne pas du tout : relance-la au boot, une fois le store monté. Aucune des deux seule ne comble le trou. L'attente ne peut pas invoquer un daemon que launchd a oublié, et le watchdog ne peut pas faire en sorte qu'un shell parti trop tôt revienne relire son PATH.

Je ne connais toujours pas les conditions exactes où launchd laisse tomber le daemon. J'ai arrêté d'en avoir besoin. Le shell attend quand il le doit et lâche l'affaire quand il le faut, et le watchdog réarme tranquillement le truc à chaque boot. Je n'ai plus ouvert de terminal tout neuf sur command not found: git depuis.

Il m'arrive encore parfois de fermer l'onglet et d'en ouvrir un autre. Les vieilles habitudes.