9 giugno 2026
macOSlaunchdAutomationDevOps

Smetti di usare cron sul tuo Mac: usa launchd

cron fa polling su un intervallo e perde i job mentre il laptop dorme. WatchPaths di launchd reagisce agli eventi del filesystem invece che aspettarli: ed è l'automazione che ha sostituito cron per sempre.

8 min di lettura

Avevo bisogno che la mia macchina reagisse ai cambiamenti, non che li interrogasse a intervalli. La risposta era una funzionalità macOS vecchia di vent'anni che quasi nessuno usa, e ha battuto cron in ogni modo che contasse.

La settimana scorsa ho costruito un sistema che fa auto-commit della configurazione della mia macchina: installo un pacchetto Homebrew, e otto secondi dopo c'è un commit git su GitHub che lo registra.

Quando ho iniziato, il mio automatismo diceva quello che dice l'automatismo di ogni sviluppatore:

Mettilo semplicemente in cron.

Non l'ho fatto. E le ragioni sono diventate la lezione più utile su macOS che ho imparato in anni.

⏰ Il problema del polling

Ecco come apparirebbe la versione cron della mia automazione:

*/5 * * * * /Users/me/sync.sh

cron che fa polling ogni cinque minuti contro launchd che scatta sugli eventi del filesystem

Ogni cinque minuti, per sempre, la mia macchina sveglia un processo per chiedere: è cambiato qualcosa?

Quasi ogni volta, la risposta è no.

Questa è la forma fondamentale di cron: il polling. Non conosce gli eventi. Conosce solo il tempo. Quindi sei costretto in un compromesso negativo:

  • Interrogare spesso → centinaia di esecuzioni inutili al giorno, giusto per sicurezza
  • Interrogare raramente → le modifiche restano senza commit per un'ora, vanificando lo scopo

E cron su un laptop ha un secondo problema, più subdolo. cron presuppone che la macchina sia accesa. Chiudi il coperchio alle 23:58, e il job di mezzogiorno semplicemente non avviene. Nessun retry, nessun recupero, niente. I server sono sempre accesi; i laptop dormono. cron è stato progettato per i server.

Quello che volevo davvero era semplice da esprimere:

Quando il filesystem cambia, esegui il mio script. Altrimenti, non fare assolutamente nulla.

Non è una programmazione temporale. È un evento.

⚙️ Il demone che hai già

macOS include launchd, è letteralmente il processo 1, la cosa che avvia ogni servizio sul sistema. Apple ha sostituito cron con esso internamente anni fa (i tuoi job cron su Mac sono a loro volta eseguiti da launchd).

La maggior parte degli sviluppatori sa che launchd esiste. Quasi nessuno usa la sua funzionalità migliore:

WatchPaths, launchd sorveglierà un percorso del filesystem a livello di sistema operativo, e farà scattare il tuo job nel momento in cui cambia.

<key>WatchPaths</key>
<array>
    <string>/opt/homebrew/Cellar</string>
    <string>/opt/homebrew/Caskroom</string>
</array>

Questo è tutto il trucco. Homebrew installa ogni pacchetto in /opt/homebrew/Cellar, quindi qualsiasi install o uninstall, da qualsiasi shell, script o strumento, cambia quella directory. Il sistema operativo se ne accorge. Il mio script parte.

Nessun polling. Nessuna esecuzione sprecata. Zero CPU da inattivo. Il job esiste come un'intenzione registrata, ed è il kernel a fare la sorveglianza.

L'intera definizione del job è un unico file plist:

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.paurushrai.dotfiles-sync</string>
 
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/paurushrai/Developer/infra/dotfiles/sync.sh</string>
    </array>
 
    <key>WatchPaths</key>
    <array>
        <string>/opt/homebrew/Cellar</string>
    </array>
 
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key><integer>12</integer>
        <key>Minute</key><integer>0</integer>
    </dict>
 
    <key>ThrottleInterval</key>
    <integer>60</integer>
 
    <key>StandardOutPath</key>
    <string>/Users/paurushrai/Library/Logs/dotfiles-sync.log</string>
</dict>
</plist>

Mettilo in ~/Library/LaunchAgents/, registralo una volta:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.paurushrai.dotfiles-sync.plist

Sopravvive ai riavvii. Gira che ci sia o meno un terminale aperto. Fa parte del sistema operativo ora.

L'ho testato nel modo onesto, ho installato un pacchetto e non ho toccato nient'altro:

$ brew install jq
... 8 seconds later, zero shell involvement:
67ba27e chore: auto-sync .Brewfile  → pushed to GitHub ✅

brew install jq che innesca un auto-commit spinto su GitHub in otto secondi

🧩 Tre chiavi che fanno il lavoro pesante

Il plist qui sopra ha tre decisioni di design incorporate, e ognuna ha sostituito codice che altrimenti avrei dovuto scrivere.

le tre chiavi launchd: ThrottleInterval, StartCalendarInterval e StandardOutPath

ThrottleInterval: 60 è un debouncer gratuito. Installa cinque pacchetti di fila e il filesystem genera cinque eventi, ma launchd non eseguirà il job più di una volta ogni 60 secondi, quindi ottengo un commit che li copre tutti e cinque. Nel mondo di cron, un batching così è logica che devi scrivere e testare tu. Qui è un intero.

StartCalendarInterval è la programmazione di cron, meno il problema del laptop di cron. Mi dà un'esecuzione giornaliera a mezzogiorno come rete di sicurezza per tutto quello che le sorveglianze si perdono. E a differenza di cron, se la macchina dorme a mezzogiorno, launchd esegue il job quando si risveglia. Le programmazioni mancate vengono eseguite in ritardo invece che mai. Per un laptop, questo singolo comportamento chiude il confronto.

StandardOutPath è osservabilità gratuita. L'output di ogni esecuzione viene aggiunto a un file di log che ho scelto. Quando mi sono chiesto perché qualcosa non scattava, la risposta era già scritta.

🔁 La trappola che morde tutti

Ora la confessione. Il mio script fa commit su un repository git, e il primo istinto è stato sorvegliare la directory del repository stessa. Un'unica voce di watch, copre ogni file. Elegante.

È anche un ciclo infinito.

il ciclo di feedback: un commit modifica .git, che fa scattare l'agente, che fa commit di nuovo

Ogni commit git modifica .git/. Quindi: una modifica fa scattare l'agente → l'agente fa commit → il commit modifica .git/ → che è dentro la directory sorvegliata → che fa scattare di nuovo l'agente. All'infinito.

La soluzione è una regola che vale la pena memorizzare:

Non sorvegliare mai una directory in cui scrive il tuo job.

Sorveglio singoli file invece: ogni dotfile tracciato ha la sua voce WatchPaths, e la radice del repository è deliberatamente assente. Il compromesso: un file nuovo di zecca in una sottodirectory profonda non viene notato all'istante; l'esecuzione giornaliera di mezzogiorno lo raccoglie. Un prezzo giusto per un sistema che non può rincorrersi la coda.

Altre due trappole dello stesso pomeriggio, così non paghi tu il mio scotto:

  • Non simlinkare il plist. Tengo il plist nel mio repository di dotfiles e lo copio in ~/Library/LaunchAgents/, launchd è inaffidabile con definizioni di job simlinkate sul macOS moderno. Il repository custodisce la fonte di verità; l'installer distribuisce una copia.
  • I job launchd ottengono un ambiente minimale. Nessun PATH della tua shell, nessun alias, niente del tuo .zshrc. Il mio script imposta il proprio PATH nella prima riga. Se il tuo job "funziona nel terminale ma non in launchd", è quasi sempre questo.

🔔 Fallo parlare, poi fallo aspettare

Un job in background che non vedi è un job in background di cui smetterai di fidarti.

Il mio script annuncia ogni commit con una riga di codice nativo macOS:

osascript -e 'display notification "Committed & pushed: .Brewfile" with title "dotfiles sync"'

Primo test: non l'ho mai vista. Le notifiche a banner svaniscono in cinque secondi.

La soluzione non era codice, era un'impostazione che quasi nessuno cambia: Impostazioni di Sistema → Notifiche → Script Editor → Avvisi. Le notifiche in stile avviso restano sullo schermo finché non le chiudi. Ora ogni sincronizzazione aspetta nell'angolo che io la confermi, con i nomi dei file cambiati nel messaggio.

Dettaglio di design che conta: lo script notifica solo quando qualcosa è stato davvero committato. Un'esecuzione a vuoto è silenziosa. Una notifica che scatta di continuo è una notifica che hai già imparato a ignorare, l'affaticamento da allerta uccide l'osservabilità più in fretta di nessuna allerta.

🛠 Lo schema riassuntivo

Tutto quello che ti serve per gestirne uno tuo:

# register (once, or after editing the plist)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.myjob.plist
# unregister
launchctl bootout gui/$(id -u)/com.example.myjob
# is it loaded?
launchctl list | grep myjob
# what happened?
tail -f ~/Library/Logs/myjob.log

E la tabella decisionale che avrei voluto avere:

  • Il job deve reagire a un file o una directory che cambia → WatchPaths
  • Il job deve girare su una programmazione, sicura per il laptop → StartCalendarInterval
  • Il job scatta a raffiche che dovrebbero essere raggruppate → ThrottleInterval
  • Il job deve girare nel momento in cui viene registrato → RunAtLoad
  • Stai per digitare crontab -e su un Mac → fermati, scrivi un plist

🎯 La lezione sotto la lezione

L'automazione che ho costruito è piccola. Il principio no.

Ho quasi scelto cron perché cron è quello che conoscevo, e ho quasi scelto un pacchetto npm per il file-watching perché è quello che suggeriscono i risultati di ricerca. Entrambi avrebbero funzionato, ma male: uno interrogando a intervalli, l'altro aggiungendo una dipendenza per fare quello che il sistema operativo fa nativamente.

Lo strumento noioso, integrato, vecchio di vent'anni era migliore di entrambi. Guidato dagli eventi invece che interrogato. Consapevole del laptop invece che presuppone un server. Zero dipendenze invece che un'altra cosa da installare sulla prossima macchina.

Prima di aggiungere uno strumento, chiediti cosa il sistema operativo già include.

La risposta è più spesso di quanto pensi: tutto quello che ti serviva, seduto nel processo 1, in attesa dal 2005.

Cosa gira nella tua cartella LaunchAgents?

Sono curioso di sapere cos'hanno automatizzato altri a livello di sistema operativo, e in quali trappole sono caduti facendolo. Il ciclo di feedback di WatchPaths non può essere l'unico.