クラウド Mac のファイル監視を検証する:通知回数を変更回数と見なさない

クラウド Mac のファイル監視を検証する:通知回数を変更回数と見なさない

クラウド Mac 上でソースコードのディレクトリを監視し、ファイルが保存されるたびにコンパイルを始めるビルドツールがあります。しかし実際には、エディタが一時ファイルに書き込んでから、名前を変更して元のファイルを置き換えることがあります。連続した保存でも、コールバックは一度しか発生しないかもしれません。「イベントを一度受け取った」ことを「変更されたファイルはちょうど一つ」と解釈すると、コンパイル漏れが起こり得ます。以下の小さな実験では、通知と最終的な状態を分けて検証します。

まず監視対象を決める

特定のファイルディスクリプタだけを監視する方法は、そのファイルオブジェクト自体の観察には向いています。一方、同じパスが新しいファイルを指すようになったかどうかの判断には向きません。多くのエディタは、一時ファイルへの書き込みを終えてから名前の変更で置き換える、原子的な保存を行います。この場合、パスは同じでも、その先のファイルオブジェクトは変わっている可能性があります。

この実験では、代わりに親ディレクトリを監視します。手順は二段階です。ディレクトリのイベントを受けたら「変更された可能性がある」と記録し、その後ディレクトリを読み直して前回のスナップショットと比較します。途中の書き込みを一回ずつ記録する方法ではありませんが、ビルドツールにとってより重要な「前回の確認時点から、現在どの入力ファイルが変わったか」には答えられます。

コールバックは再走査の合図であり、変更履歴ではありません。二回の書き込みが一回の通知にまとめられても、最終的なファイルは次の確認対象に含める必要があります。

実行できる最小限の監視スクリプトを書く

クラウド Mac のコマンドラインで実験用ディレクトリを作り、次の内容を watch.swift として保存します。このスクリプトが確認するのはディレクトリ直下の項目だけで、再帰的には調べません。ファイル名、サイズ、更新日時、ファイル番号を記録します。ファイル番号は、同じ名前のファイルが置き換えられたことを見分けるのに役立ちます。

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()

ここでは .write のコールバックを特定のファイル名に直接結び付けていません。ディレクトリのイベントが示すのは、そのディレクトリを確認すべきだということだけです。snapshot() で読み取りに失敗した項目はスキップされます。そのため実運用のツールでは、読み取りエラーを別途記録し、次回に再試行する必要があります。一時的な権限の問題や名前変更との競合を、確実に削除されたという判断にしてはいけません。

三種類の操作で動作の境界を確かめる

まず一つ目のターミナルで監視スクリプトを起動し、二つ目のターミナルで操作を実行します。以下のパスは実験専用です。作業中のリポジトリを指定しないでください。

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"

出力が五つのコマンドと一対一に対応することは期待しないでください。書き込みと名前変更が二回のスナップショット取得の間に起きると、途中の状態は表示されないことがあります。ただし、最後に削除した後のスナップショットには input.txt が残らないはずです。作成・置換・削除それぞれの結果を個別に確かめたい場合は、各操作の後でいったん止め、対応する出力を確認してから次へ進みます。

同名の置換にファイル番号が必要な理由

ファイル名だけを比較すると、「古いファイルがなくなり、新しいファイルが同じパスを占めた」ことを見逃します。ファイルサイズも偶然一致する場合があります。そこで、この実験ではファイル番号も記録します。原子的な置換後に名前とサイズが同じでも、通常はファイルオブジェクトの変化を検出できます。更新日時は、通常の上書きによる変更を捉えます。このスナップショットは変更を軽量に判別するためのもので、ファイル内容の検証ではありません。公開の判断にバイト単位の一致が必要なら、対象ファイルが安定してから内容のハッシュを別途計算してください。

実験の方法をビルドフローに組み込む

実際のビルドでは、イベントのたびに即座にコンパイルを始めるべきではありません。「通知を受ける → 再走査を予定する → 変更されたパスを集める → ビルドを起動する」という状態管理のほうが堅実です。再走査中に新たな通知が届いた場合は、その回が終わってからもう一度走査します。これならイベントがまとめられても対応でき、ビルドの実行中という理由だけで新しい変更を捨てずに済みます。

監視するディレクトリ階層の範囲も明確にする必要があります。上のスクリプトが列挙するのは直下の項目だけです。入れ子になったソースコードディレクトリ内の変更は、これだけでは網羅できません。ツリー全体を対象にするなら、再帰的なディレクトリ変更に適した仕組みを使い、「通知後に再走査する」という原則は維持します。また、ビルドの出力ディレクトリは除外してください。そうしないと、生成物の書き込みが繰り返しビルドを引き起こすおそれがあります。

最後に、監視対象ディレクトリ自体のライフサイクルも確認します。そのディレクトリが削除または置換された場合、古いディスクリプタは新しいパスを表せません。パスが引き続き想定したディレクトリを指しているか確認し、必要なら開き直して監視を設定し直すべきです。

実行して確認できる受け入れチェックリスト

監視ロジックを本番に導入する前に、独立した一時ディレクトリで次の項目を確認します。

このチェックによって、問うべきことは「なぜシステムからのイベントが一回少なかったのか」から「現在の入力を漏れなく照合できたか」に変わります。ファイルの変更をきっかけにタスクを実行するクラウド Mac のワークフローでは、受け入れ基準にできるのは後者です。

よくある質問

短時間に二度書き込んでも通知が一度だけなのはなぜですか?

ファイルシステムのイベントは統合される場合があります。通知回数を数えず、通知後にディレクトリを再走査してください。

原子的保存の後にファイル単位の監視が外れるのはなぜですか?

保存時に別のファイルへ置き換わると、元の記述子は古い実体を指し続けます。親ディレクトリを監視してください。

次の実験には、専用のMacを

物理Mac mini、ノード、利用期間を必要に応じてお選びいただけます。モデルと在庫状況はコンソールでリアルタイムにご確認ください。

クラウドMacのレンタルプランを見る