Dateiüberwachung auf dem Cloud-Mac: Ereignisse sind keine Änderungen

Dateiüberwachung auf dem Cloud-Mac: Ereignisse sind keine Änderungen

Ein Build-Tool überwacht auf einem Cloud-Mac ein Quellcodeverzeichnis und startet nach dem Speichern einer Datei eine Kompilierung. In der Praxis schreibt ein Editor jedoch möglicherweise erst eine temporäre Datei und ersetzt die ursprüngliche Datei anschließend per Umbenennung. Auch mehrere Speichervorgänge kurz hintereinander können nur einen einzigen Rückruf auslösen. Wer „ein Ereignis empfangen“ mit „genau eine Datei wurde geändert“ gleichsetzt, riskiert, Änderungen beim Build zu übersehen. Das folgende kleine Experiment prüft deshalb Benachrichtigungen und endgültigen Dateizustand getrennt.

Zuerst das Überwachungsziel festlegen

Einen einzelnen Dateideskriptor zu überwachen, eignet sich für Änderungen am betreffenden Objekt. Ob der Pfad inzwischen auf eine neue Datei zeigt, lässt sich damit aber nicht zuverlässig feststellen. Viele Editoren speichern atomar: Sie schreiben zunächst eine temporäre Datei und ersetzen die ursprüngliche Datei dann durch Umbenennen. Der Pfad bleibt gleich, das Dateiobjekt dahinter kann sich jedoch geändert haben.

In diesem Experiment wird stattdessen das übergeordnete Verzeichnis überwacht. Die Strategie besteht aus zwei Schritten: Ein Verzeichnisereignis markiert, dass sich etwas geändert haben könnte. Anschließend wird das Verzeichnis erneut eingelesen und mit dem vorherigen Schnappschuss verglichen. So wird nicht jede zwischenzeitliche Schreiboperation protokolliert. Beantwortet wird aber die für ein Build-Tool wichtigere Frage: Welche Eingabedateien unterscheiden sich jetzt vom Stand der letzten Prüfung?

Ein Rückruf ist ein Signal zum erneuten Scannen, kein Änderungsprotokoll. Werden zwei Schreibvorgänge zu einer Benachrichtigung zusammengefasst, muss die endgültige Datei trotzdem in die nächste Prüfung eingehen.

Einen minimalen, lauffähigen Watcher schreiben

Erstellen Sie auf dem Cloud-Mac über die Kommandozeile ein Experimentverzeichnis und speichern Sie den folgenden Inhalt als watch.swift. Das Skript prüft nur die unmittelbaren Einträge des Verzeichnisses, nicht rekursiv. Es erfasst Dateiname, Größe, Änderungszeitpunkt und Dateinummer. Die Dateinummer hilft dabei, den Austausch einer Datei unter demselben Namen zu erkennen.

import Darwin
import Dispatch
import Foundation

struct Entry: Equatable {
    let size: UInt64
    let modified: Date
    let inode: UInt64
}

let path = CommandLine.arguments.dropFirst().first ?? ""
guard !path.isEmpty else {
    fputs("usage: swift watch.swift DIRECTORY\n", stderr)
    exit(2)
}
guard FileManager.default.fileExists(atPath: path) else {
    fputs("directory does not exist\n", stderr)
    exit(2)
}

func snapshot() -> [String: Entry] {
    var result: [String: Entry] = [:]
    let names = (try? FileManager.default.contentsOfDirectory(atPath: path)) ?? []
    for name in names {
        let file = (path as NSString).appendingPathComponent(name)
        guard let attributes = try? FileManager.default.attributesOfItem(atPath: file),
              let size = attributes[.size] as? NSNumber,
              let inode = attributes[.systemFileNumber] as? NSNumber,
              let modified = attributes[.modificationDate] as? Date else {
            continue
        }
        result[name] = Entry(
            size: size.uint64Value,
            modified: modified,
            inode: inode.uint64Value
        )
    }
    return result
}

let fd = open(path, O_EVTONLY)
guard fd >= 0 else {
    perror("open")
    exit(1)
}
let queue = DispatchQueue(label: "watch.lab")
let source = DispatchSource.makeFileSystemObjectSource(
    fileDescriptor: fd,
    eventMask: [.write, .rename, .delete],
    queue: queue
)
var previous = snapshot()
source.setEventHandler {
    let current = snapshot()
    let changed = Set(previous.keys).union(current.keys)
        .filter { previous[$0] != current[$0] }
        .sorted()
    if !changed.isEmpty {
        print("changed: \(changed.joined(separator: ", "))")
        fflush(stdout)
    }
    previous = current
}
source.setCancelHandler { close(fd) }
source.resume()
print("watching: \(path)")
dispatchMain()

Hier wird ein .write-Rückruf nicht direkt einem bestimmten Dateinamen zugeordnet: Das Verzeichnisereignis sagt nur, dass das Verzeichnis geprüft werden soll. Einträge, die snapshot() nicht lesen kann, werden übersprungen. Ein Produktivwerkzeug sollte Lesefehler daher gesondert protokollieren und die Einträge beim nächsten Durchlauf erneut prüfen, statt vorübergehende Berechtigungsprobleme oder einen Wettlauf mit einer Umbenennung als sicheren Beleg für eine Löschung zu werten.

Grenzen mit drei Arten von Operationen prüfen

Starten Sie den Watcher zunächst in einem Terminal und führen Sie die Operationen in einem zweiten Terminal aus. Die folgenden Pfade dienen nur dem Experiment; verwenden Sie dafür kein Repository, an dem Sie gerade arbeiten.

mkdir -p "$HOME/watch-lab"
swift watch.swift "$HOME/watch-lab"
printf 'first\n' > "$HOME/watch-lab/input.txt"
printf 'second\n' > "$HOME/watch-lab/input.txt"
printf 'replacement\n' > "$HOME/watch-lab/.input.tmp"
mv -f "$HOME/watch-lab/.input.tmp" "$HOME/watch-lab/input.txt"
rm "$HOME/watch-lab/input.txt"

Erwarten Sie bei der Ausgabe keine Eins-zu-eins-Zuordnung zu den fünf Befehlen. Finden Schreibvorgänge und Umbenennung zwischen zwei Schnappschüssen statt, erscheinen Zwischenzustände möglicherweise nie. Nach der abschließenden Löschung sollte input.txt jedoch nicht mehr im Schnappschuss stehen. Wenn Sie die drei Ergebnisse einzeln prüfen möchten, pausieren Sie nach dem Erstellen, Ersetzen und Löschen jeweils, bis die zugehörige Ausgabe erscheint, bevor Sie fortfahren.

Warum beim Ersetzen unter gleichem Namen die Dateinummer zählt

Wer nur Dateinamen vergleicht, übersieht, dass die alte Datei verschwunden ist und eine neue denselben Pfad belegt. Auch die Dateigröße kann zufällig gleich sein. Das Experiment erfasst deshalb zusätzlich die Dateinummer. So lässt sich nach einer atomaren Ersetzung normalerweise auch dann ein anderes Dateiobjekt erkennen, wenn Name und Größe unverändert sind. Der Änderungszeitpunkt deckt gewöhnliche Schreibvorgänge innerhalb derselben Datei ab. Dieser Schnappschuss dient als leichtgewichtige Änderungsprüfung, nicht als Kontrolle des Dateiinhalts. Wenn eine Veröffentlichung bytegenaue Übereinstimmung voraussetzt, berechnen Sie zusätzlich einen Hash des Inhalts, sobald die betreffende Datei stabil ist.

Das Verfahren in den Build-Prozess integrieren

Im Build-Betrieb sollte nicht jedes Ereignis sofort eine neue Kompilierung starten. Robuster ist ein Zustandsablauf nach dem Muster „Benachrichtigung empfangen → erneutes Scannen einplanen → geänderte Pfade sammeln → Build auslösen“. Trifft während eines Scans eine weitere Benachrichtigung ein, wird nach dessen Abschluss nochmals gescannt. So dürfen Ereignisse zusammengefasst werden, ohne dass neue Änderungen verloren gehen, nur weil gerade ein Build läuft.

Auch der Umfang der Verzeichnisüberwachung muss klar sein. Das obige Skript listet nur unmittelbare Einträge auf; Änderungen in verschachtelten Quellcodeverzeichnissen erfasst es nicht vollständig. Wenn der gesamte Verzeichnisbaum abgedeckt werden soll, verwenden Sie einen für rekursive Verzeichnisänderungen geeigneten Mechanismus und behalten Sie das Prinzip „nach einer Benachrichtigung erneut scannen“ bei. Schließen Sie außerdem das Build-Ausgabeverzeichnis aus, da erzeugte Dateien sonst immer wieder den nächsten Build auslösen könnten.

Prüfen Sie schließlich den Lebenszyklus des überwachten Verzeichnisses selbst: Wird es gelöscht oder ersetzt, repräsentiert der alte Deskriptor nicht mehr den neuen Pfad. Das Werkzeug sollte prüfen, ob der Pfad noch auf das erwartete Verzeichnis zeigt, und die Überwachung bei Bedarf durch erneutes Öffnen wiederherstellen.

Eine ausführbare Abnahme-Checkliste

Prüfen Sie die Überwachungslogik vor dem Einsatz in einem separaten temporären Verzeichnis:

Damit verschiebt sich die Frage von „Warum hat das System ein Ereignis weniger geliefert?“ zu „Wurden die aktuellen Eingaben vollständig abgeglichen?“. Für Cloud-Mac-Workflows, die Aufgaben durch Dateiänderungen auslösen, ist nur Letzteres ein überprüfbares Ergebnis.

Häufig gestellte Fragen

Warum kommt nach zwei schnellen Änderungen nur ein Callback an?

Dateisystemereignisse können zusammengefasst werden. Scannen Sie nach einem Hinweis erneut, statt die Callbacks als Änderungen zu zählen.

Warum übersieht ein Wächter am einzelnen Datei-Handle atomare Speicherung?

Beim Ersetzen kann das Handle auf das alte Objekt zeigen. Überwachen Sie das Elternverzeichnis und prüfen Sie den Pfad erneut.

Mieten Sie einen eigenen Mac für Ihr nächstes Experiment

Wählen Sie einen physischen Mac mini, einen Standort und einen Abrechnungszeitraum. Modell und Verfügbarkeit sehen Sie in Echtzeit in der Konsole.

Cloud-Mac-Mietoptionen ansehen