1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

☁️ iCloudがDesktopのファイルを勝手に追い出す:全レーンEDEADLK全滅と read_text_resilient 設計

1
Posted at

月10万の大学生から、掛け持ち月60万、解雇でゼロ、半年でClaude Code自律環境を建てて今は月商120万——その道のりで、iCloudという名の静かな刺客が4日間の穴を開けた。

なぜこの仕組みが効くのか

自動化が「環境」に依存するとはどういうことか

launchd に160本超のジョブを仕込んで常時稼働させていると、スクリプト本体のバグより先に「実行環境そのもの」が障害の原因になります。ポートが枯渇する、プロセスが孤児化して積み上がる、メモリが空かない——そういった資源リークの話は前回に書きました。今回はその翌日に起きた、まったく別の種類の全滅です。

ファイルが、読んだら死ぬ状態になっていた。

コードのバグではありません。ファイルシステムのバグでもありません。macOS が「最適化」を名乗って実行した仕組みの、意図せぬ副作用です。

optimize-storage が何をするか

macOS の「ストレージを最適化」(System Settings → General → Storage → Optimize Storage)は、iCloud Drive が有効な環境で ディスク残量が逼迫すると、Desktop と Documents 配下のファイルを iCloud にアップロードしてローカルの実体を削除します。Finder では通常のアイコンに見えますが、実体はなく「dataless(データなし)」状態になります。クリックすれば自動でダウンロードされる——人間が使う分には許容範囲のトレードオフです。

問題は自動化スクリプトです。

python3 の open()、pathlib.Path.read_text()、cat、jq、cp ——これらはすべて、dataless なファイルに触れると Errno 11: EDEADLK: Resource deadlock avoided で即死します。

「Resource deadlock」という名前から、デッドロックを疑いますが、これは POSIX の errno コードで macOS がファイルのダウンロード待ちに使う流用です。ロックが競合しているわけでも、スレッドが詰まっているわけでもありません。ただ「実体がローカルにない」というだけの話が、プロセスにとっては致命的なエラーコードに見えます。

EAGAIN(リソースが一時的に利用不可)が返ることもあります。こちらは「ダウンロード開始直後の競合」で出るパターンです。

4日間ゼロ投稿という実被害

2026年8月6日、note の自動投稿が全レーン停止しました。エラーログには一面の EDEADLK。

原因を探ると、~/Desktop/Article/ 配下の記事ファイル69本が dataless になっていました。ディスク使用率が 98%(残り 25GB)に達したタイミングで、iCloud が黙って実体を追い出していたのです。

note のレーンが読んでいたのはそこでした。読めないから投稿できない。投稿できないから売上が出ない。8月3日から4日間、ゼロ投稿が続いていました。

収益の大半を出しているレーンが、エラーも「失敗」のアラートも一見しにくい形で止まっていた——これが一番こたえました。スクリプトは exit 0 を返します。「記事が見つからなかったから何もしなかった」という扱いになるので、死活監視が「成功」として通過します。静かな失敗が、一番発見が遅れます。

前日(8月5日)の障害は「メモリと孤児プロセスの資源リーク」でした。1日で別の種類の全滅が来て、しかもどちらも症状が「全レーン停止」に見える。根本原因が違うのに表面上が同じです。切り分けの型を持っていないと、毎回1時間溶けます。

Vault も同じことになっていた

同じ日、作業中に気づいたことがもう一つあります。Obsidian の Vault(~/Documents/claude-obsidian/)配下の Markdown ファイルも、繰り返し dataless 化されていました。CLAUDE.md や学習ノートが、何度 brctl download で実体化しても、ディスク逼迫が続く限り再退避します。

Desktop 配下だけの問題ではありません。iCloud Drive に同期されているフォルダ全体が対象です。Documents も同様です。自動化の読み書き対象をそこに置くかぎり、ディスクが圧迫されるたびに同じことが起きます。


全体の流れ

恒久策は2本柱です。

【問題の構造】
  ディスク 98% → optimize-storage 発動
       ↓
  Desktop/Documents 配下のファイル → iCloud退避(dataless)
       ↓
  自動化スクリプトが open() → EDEADLK → 即死 → exit 0(静かな失敗)
       ↓
  note レーン 4日間ゼロ投稿(8/3〜8/6)

【恒久策 A: ファイルを逃がす】
  ~/Desktop/Article/  →  ~/content/article/  (実体を移動)
       ↓
  ~/Desktop/Article → ~/content/article のsymlink残置
  (移行中もジョブを壊さない)
       ↓
  パス定数を JS/MJS 6本 + Shell 3本 = 9ファイル更新(commit a1b37dc)

【恒久策 B: 読む側で EDEADLK を吸収する】
  Documents/Desktop 配下を読む箇所
       ↓
  read_text_resilient(path, attempts=4) を挟む
       ↓
  EDEADLK / EAGAIN のときだけ
    → brctl download <path>(実体化をカーネルに要求)
    → 2秒 → 4秒 → 8秒 の指数バックオフでリトライ
  それ以外の OSError → 即 raise(握りつぶさない)
  4回目も失敗 → OSError をそのまま raise

恒久策 A :自動化の読み書き対象を Desktop/Documents から出す

根本から断つ方向です。

~/Desktop/Article/ にあった記事ファイル796本の実体を ~/content/article/ へ移動し、元のパスには symlink を残しました。移行中に launchd のジョブが走っても、symlink 越しに読めるのでジョブが壊れません。

パス定数の更新はスクリプトへの1行置換で済みます。今回は JavaScript・MJS・Shell 合計9ファイルに Desktop/Article を content/article へ置換するだけで、残存チェック(grep -r 'Desktop/Article')でゼロを確認しました(commit a1b37dc)。

本筋はここです。~/content/ は iCloud Drive の同期対象外に設定するか、そもそも ~/ 直下の iCloud 非管理パスに置けば、ディスクが逼迫しても dataless 化は起きません。Desktop や Documents に自動化の読み書き対象を置かない、これが構造の修正です。

ただし Vault(~/Documents/claude-obsidian/)のように「iCloud 同期が必要なフォルダ」を自動化から読む箇所は、移動だけでは解決しません。そこで恒久策 B が要ります。

恒久策 B :read_text_resilient で EDEADLK を吸収する

Documents や Desktop を読むコードを、EDEADLK に対してリトライ耐性を持つ関数でラップします。

import errno
import subprocess
import time
from pathlib import Path


def read_text_resilient(path: Path, attempts: int = 4) -> str:
    """
    iCloud の dataless ファイルに対して brctl download → 指数バックオフでリトライ。
    EDEADLK / EAGAIN 以外の OSError はリトライせず即 raise する。
    """
    delays = [2, 4, 8]          # リトライ間隔(秒): 初回は sleep なし
    last_err: OSError | None = None

    for i in range(attempts):
        try:
            return path.read_text(encoding="utf-8")
        except OSError as e:
            if e.errno not in (errno.EDEADLK, errno.EAGAIN):
                raise           # 権限エラー・存在しないパスなどは即死させる
            last_err = e
            if i < len(delays):
                subprocess.run(
                    ["brctl", "download", str(path)],
                    capture_output=True,
                )
                time.sleep(delays[i])

    assert last_err is not None
    raise last_err

設計の肝はEDEADLK と EAGAIN のときだけリトライするという絞り込みです。

brctl download <path> は macOS のコマンドで、iCloud に退避したファイルのローカルへの再ダウンロードをカーネルに要求します。同期には時間がかかるため、2秒→4秒→8秒と待ちを増やします。4回目(attempts=4)も失敗した場合はその OSError をそのままスローします。例外を握りつぶして空文字を返すフォールバックは意図的に作りません。空文字を返すと「ファイルが空だった」と解釈されて処理が続行してしまい、「データが消えたのに成功扱い」という静かな失敗を再現します。

obsidian-notion-sync/sync.py にこの関数を追加し、Vault 配下の .md を読む箇所4か所に適用しました。既存テスト51本はすべてパス(pass)しています。

なぜ「静かなフォールバック」が毒になるか

ここで少し立ち止まります。

EDEADLK を握りつぶして "" を返す実装は、書くのは簡単です。try/except で空文字にすれば「エラーにならない」。でもそれをやると何が起きるか。

記事を読んで投稿を組み立てるスクリプトが「空のファイルを読んだ」と判断し、「今日は投稿する記事がなかった」として exit 0 を返します。アラートは鳴りません。死活監視は「正常」としてパスします。note のダッシュボードを開くまで気づきません——今回実際に4日間気づかなかった通りに。

フォールバックが賢いほど、問題の発見が遅れます。自動化において「静かな失敗」は「派手な失敗」より遥かに悪い。noise を嫌うあまり signal まで消す設計は、障害の事後処理コストが跳ね上がります。

EDEADLK は「実体がない」という明確な状態なので、リトライには意味があります。でも4回リトライして全部失敗したなら、それは「環境が壊れている」か「ファイル自体が問題」なので、例外として上位に伝えるのが正しい。上位が何をするかは上位が決める——それがエラー処理の責務の置き方です。

切り分けの型:3つの「全部落ちてる」を分離する

実際の障害対応では、同じ日に複数の原因が重なりました。

  • 資源リーク(メモリ・孤児プロセス):前日の 8/5 が主因
  • iCloud dataless による EDEADLK:8/6 が主因(今回の記事の本題)
  • 週次クォータ超過(You've hit your weekly limit · resets 10am):同日に並走

これらはすべて「全レーンが止まっている」という表面上同じ症状に見えます。「全部落ちてる」と切り分けの型なしに飛び込むと、メモリを調べながら別原因に時間を溶かします。

切り分けの順番は次の通りです。

①まずエラーコードを見る。EDEADLK ならファイル問題です。Timeout ならプロセス・メモリ問題です。You've hit your weekly limit ならクォータです。エラーメッセージは嘘をつきません。

②「何が同時に死んでいないか」を見る。今回で言えば、iCloud 問題なら Python の open() が死んでも curl は死なない。メモリ問題なら Chrome も claude -p も両方死ぬ。障害の「幅」が原因の性質を絞ります。

③ディスク残量を確認する。今回の根本は「ディスク 98%」でした。df -h ~ の1コマンドで見えます。逼迫していなければ iCloud の dataless 退避は起きません。

次のパートでは、実際に read_text_resilient を適用する際に踏んだ落とし穴と、Desktop からの移行作業でやらかしたことを具体的に書きます。

実装の詳細

設計判断①:errno で絞り込む理由

read_text_resilient の核心は、except OSError as e を全部リトライするのではなく、e.errno が EDEADLK か EAGAIN のときだけリトライするという絞り込みです。

なぜ全部リトライしないか。

OSError のサブクラスには PermissionError(errno 13)、FileNotFoundError(errno 2)、IsADirectoryError(errno 21)など、リトライしても絶対に通らないエラーが大量に含まれます。権限がないファイルを4回読もうとしても4回 PermissionError が返るだけです。brctl download は権限エラーには何の効果もありません。

EDEADLK(errno 11)と EAGAIN(errno 11/35)だけが「今は実体がないが、ダウンロードすれば読める可能性がある」状態を示します。EDEADLK は iCloud が実体をローカルに持っていないとき、EAGAIN はダウンロードが開始された直後の競合状態です。どちらも「一時的・かつ brctl で解消できる」という共通点があります。

except OSError as e:
    if e.errno not in (errno.EDEADLK, errno.EAGAIN):
        raise  # 権限エラー・存在しないパスは即死させる

この raise が重要です。ここを pass や continue に変えると、意図しないエラーを黙って飲み込む穴が開きます。

macOS では errno.EAGAIN の値は 35 です(Linux の 11 とは異なります)。念のため import errno して定数経由で比較してください。e.errno not in (11, 35) のようにマジックナンバーを書くと、移植時に必ず誤ります。

設計判断②:brctl download の非同期性と sleep の配置

brctl download <path> はダウンロードの開始をカーネルに要求するコマンドです。ダウンロードの完了を待ちません。つまりコマンドが返った瞬間に read_text() を呼んでも、まだ実体が来ていないことがほとんどです。

subprocess.run(["brctl", "download", str(path)], capture_output=True)
time.sleep(delays[i])  # ← brctl の後に sleep する

brctl の前に sleep を置いても意味がありません。brctl を叩いた後に待つから意味があります。初回 2 秒、次 4 秒、次 8 秒と増やすのは、ファイルサイズや回線状況に応じたダウンロード時間のばらつきに対応するためです。小さい Markdown ファイルなら 2 秒で十分ですが、記事ファイルが 10KB を超えていたり iCloud のサーバが遠かったりすると 8 秒でも厳しい場合があります。

attempts=4 を関数引数にしたのは、テスト時に上書きできるようにするためです。

# テスト内での呼び出し例
content = read_text_resilient(mock_path, attempts=1)  # 1回だけ試す

本番では attempts=4(初回 + リトライ 3 回)が妥当ですが、テストで実際に sleep を 14 秒(2+4+8)待つのは現実的でないので、attempts=1 で差し込んでいます。

設計判断③:capture_output=True にする理由

subprocess.run(["brctl", "download", str(path)], capture_output=True)

capture_output=True を省略すると、brctl の出力が呼び出し元プロセスの stdout/stderr に流れ出ます。launchd のジョブから呼ばれるスクリプトでは、stdout への意図しない出力がログを汚染します。呼び出し結果のステータスコードも今回は使っていません(brctl のリターンコードは「キューに積めたか」であって「ダウンロード完了か」ではないため、チェックしても意味がない)。

sync.py への適用:4 箇所の before/after

obsidian-notion-sync/sync.py には Vault 配下の .md を読む箇所が 4 つあります。もとのコードはすべて path.read_text(encoding="utf-8") の直接呼び出しでした。

# before
weekly_brief = Path(vault_root / "wiki/hot.md").read_text(encoding="utf-8")
template = Path(vault_root / "templates/daily-brief.md").read_text(encoding="utf-8")

# after
weekly_brief = read_text_resilient(vault_root / "wiki/hot.md")
template     = read_text_resilient(vault_root / "templates/daily-brief.md")

read_text_resilient は内部で encoding="utf-8" を固定しているため、呼び出し側では引数を省略できます。残り 2 箇所も同じパターンで置き換え、既存テスト 51 本(モックした Path.read_text を使ったユニットテスト群)はすべてパスしました。

Shell スクリプト側のガード

Python が使えない Shell スクリプトで iCloud 配下のファイルを読む場合は、brctl と sleep を組み合わせた関数を前置します。

read_resilient() {
  local path="$1"
  local delays=(2 4 8)
  local content

  for delay in "${delays[@]}"; do
    content=$(cat "$path" 2>/dev/null)
    local status=$?
    if [ $status -eq 0 ]; then
      echo "$content"
      return 0
    fi
    brctl download "$path" 2>/dev/null
    sleep "$delay"
  done

  # 最終試行
  cat "$path"
}

Shell 版は errno を直接取れないため「cat が失敗したらリトライ」という大雑把な判定になります。EDEADLK 専用に絞り込めない分、권限エラーでも無駄にリトライするリスクがあります。可能なかぎり Python 版を使い、Shell が必要な箇所は可能な限り Documents/Desktop 配下を読まないようにするのが本筋です。

移行(Desktop→content)の検証フロー

恒久策 A のパス移動後、残存チェックのコマンドは以下です。

# Git 管理下のファイルに Desktop/Article が残っていないか
grep -r 'Desktop/Article' ~/dev --include='*.js' --include='*.mjs' --include='*.sh'

# 管理外のスクリプト群も個別に確認
grep -r 'Desktop/Article' ~/.claude/scripts/

最初の grep がゼロ件を返しても安心できません。Git 管理外のスクリプト(~/.claude/scripts/ 配下の 3 本など)は別途確認が必要です。今回 commit a1b37dc でカバーした 9 ファイルは JS/MJS 6 本・Shell 3 本ですが、これらはすべてリポジトリ管理下です。管理外の 3 本は手動で更新したままの状態で、ローカルにしか存在しません。


私が詰まった話

詰まり①:「Resource deadlock」を真に受けてロック競合を探した

エラーメッセージは Errno 11: Resource deadlock avoided でした。

「Resource deadlock」という文言を見た瞬間、私はスクリプト間のファイルロック競合を疑いました。flock() を使っている箇所はないか。複数のジョブが同じファイルを同時に書いていないか。プロセスが SIGTERM なしで死んで .lock ファイルが残っていないか。

30 分ほど調べた後、ls -la ~/Desktop/Article/ を打って「@ マーク」に気づきました。macOS の ls -l@ は拡張属性を示しますが、dataless ファイルには特有のアイコンがあります。xattr -l <ファイル> を打つと com.apple.icloud.itemName が付いており、stat で st_size が実際の値を返しているのにローカルの実体がない、というのがわかりました。

# dataless かどうかを確認するコマンド
brctl status ~/Desktop/Article/article-001.md
# → 出力例: com.apple.clouddocs  dataless

教訓:POSIX の errno 11 は Linux では EAGAIN に使われますが、macOS では EDEADLK です。名前が示す「デッドロック」は macOS 独自の流用で、実際の意味は「ローカルに実体がないため操作を回避した」です。エラー名を文字通りに解釈する前に、errno の数値と OS ドキュメントを確認することです。

詰まり②:brctl download を打ったのに次の read_text が EDEADLK で死んだ

最初に read_text_resilient を書いたとき、sleep を brctl download の 前 に置いていました。

# 誤った実装(実際に書いた)
if i < len(delays):
    time.sleep(delays[i])           # ← 先に待つ
    subprocess.run(["brctl", "download", str(path)], capture_output=True)

「少し待ってから brctl でダウンロードを促す」という気持ちで書きましたが、これではダウンロードを要求した直後に read_text() を呼ぶことになります。brctl download はキューに積むだけなので、実体は一瞬では来ません。

症状は「リトライを 3 回しているのに毎回 EDEADLK」でした。ログを出してみると brctl は正常終了(exit 0)しているのに、次の read_text が同じエラーを返しています。brctl 後に適切な時間を待っていなかったことに気づき、sleep を brctl の後ろに移動して解決しました。

教訓:brctl download の戻り値はダウンロード「完了」ではなく「キュー追加」の成否です。呼んだ後に十分な時間を置かないと、次の read がすぐにEDEADLKを踏みます。

詰まり③:brctl で実体化したのに 10 分後にまた EDEADLK

8 月 6 日の対応中、一度 brctl download で 69 本全ファイルを実体化し、自動化スクリプトを手動で走らせて正常終了を確認しました。ところが 15 分後に launchd が次のジョブを起動したとき、また EDEADLK が出ました。

原因は根本の問題(ディスク 98% = 残り 25GB)を放置していたからです。

iCloud の optimize-storage は「ディスクが逼迫している」という条件が継続している限り、実体化したファイルをまた追い出します。brctl download で実体化しても、ディスクに余裕がなければ iCloud はすぐに再退避します。Vault(~/Documents/claude-obsidian/)配下の CLAUDE.md や wiki/learning/ 以下のファイルが、1 時間のうちに 2〜3 回 dataless 化されました。

read_text_resilient による吸収はあくまで「読む側の安全弁」であって、「ディスクが逼迫しているという根本原因の解決」ではありません。ディスクを空けるか、対象フォルダを iCloud 同期対象から外すか、どちらかをやらない限り、再退避は止まりません。

対応として、ComfyUI のサーバ停止(27GB 回収)と孤児プロセスの整理(ディスク上の一時ファイル群含む)でディスクを 60GB 台まで戻し、再退避が止まりました。しかしこれは同日の別障害対応の副産物であって、「ディスク管理を恒久策に入れ忘れていた」という反省が残ります。

教訓:EDEADLK 対策は「読む側のリトライ(Bプラン)」と「ファイルの場所を変える(Aプラン)」の両方が要る。Aプランなしに Bプランだけ入れると、ディスクが埋まるたびに Bプランが消耗します。

詰まり④:grep でゼロ件を確認したのに 3 本が残っていた

grep -r 'Desktop/Article' ~/dev でゼロ件を確認し、commit a1b37dc を積みました。その後、翌日に別のジョブが Desktop/Article パスで失敗しているのを発見しました。

犯人は ~/.claude/scripts/ 配下の Shell スクリプト 3 本です。このディレクトリは ~/dev/ の外にあり、Git 管理下にもありません。grep の検索範囲が ~/dev に絞られていたため、まったく引っかかりませんでした。

symlink 残置が効いていたので実害はありませんでしたが、「symlink が生きているからエラーが出ない」という状況は見えにくい問題を隠します。symlink を削除した瞬間にジョブが死ぬので、移行完了の確認は「symlink 越しに動く」ではなく「パスを直接書き換えて動く」で取る必要があります。

# 確認コマンドは scope を広く取る
grep -r 'Desktop/Article' ~ \
  --exclude-dir=.git \
  --include='*.sh' --include='*.py' --include='*.js' --include='*.mjs'

~ から探せば ~/.claude/scripts/ も ~/dev/ も一括で拾えます。~/dev に絞ると取りこぼします。

教訓:パス置換の残存確認は、Git 管理外のスクリプトを含む最広スコープで grep する。確認コマンド自体をスクリプト化して次回の移行に流用する。

詰まり⑤:Vault の CLAUDE.md が dataless になっていてセッション開始時にコンテキストが空になった

~/Documents/claude-obsidian/wiki/CLAUDE.md はセッション開始時に自動で読み込まれる設定になっています。この日、ファイルが dataless 状態で Claude Code を起動したところ、Vault の文脈が一切注入されないままセッションが始まりました。

エラーは出ませんでした。「ファイルが読めなかった」という通知はどこにもなく、セッションが始まってしばらく経つまで気づきませんでした。3 往復ほど会話した後、「Vault の情報が参照されていない」という違和感で気づき、brctl download で実体化してから再起動しました。

これは read_text_resilient とは別レイヤーの話です。Claude Code の設定ファイル読み込みは Python のカスタム関数を通らないため、EDEADLK で静かに失敗し、空のコンテキストでセッションが立ち上がります。

恒久策は「~/Documents/ 配下に置かないこと」ですが、Vault の性質上 iCloud 同期が必要なので移動できません。現状の運用としては、セッション開始前に brctl download -R ~/Documents/claude-obsidian/wiki/ で Vault 全体を実体化するスクリプトを launchd のログイン項目に入れています。

教訓:EDEADLK による静かな失敗は、スクリプト内の read_text に限らず、ツールやフレームワークのファイル読み込み全般に及ぶ。自分が書いたコードに対策を入れるだけでは不十分で、設定ファイルの読み込みパスが iCloud 配下にある場合は事前実体化も必要。


次のパートでは、この障害対応が「PDCAを回していたか」という問いへの答えにどう繋がったか——レーンの停止を4日間検知できなかったアラート設計の問題と、死活監視が「静かなゼロ投稿」を見抜けなかった構造的な欠陥を書きます。

つまずきポイント

実装を進める中で私が踏んだ落とし穴と、コードレビューで気づいた設計ミスをまとめます。「やってしまいがち」な順に並べているので、実装前のチェックリストとして使ってください。

  • 「Resource deadlock」を文字通りに読んでロック競合を調査した。
    Errno 11: Resource deadlock avoided は、macOS が iCloud dataless ファイルへのアクセスを回避したときに出す errno で、スレッドや flock のデッドロックとは無関係です。名前に引きずられて flock の実装を30分掘り返しました。brctl status <path> を最初に打てば1分で終わります。

  • brctl download を呼ぶ前に sleep を置いた。
    「少し待ってからダウンロードを促す」という直感で sleep → brctl の順に書きましたが、これでは brctl 直後に read_text() が走るためすぐ EDEADLK を踏みます。brctl はダウンロードの「開始をキューに積む」コマンドで完了を待ちません。正しい順序は brctl → sleep(2→4→8秒)です。

  • brctl download で実体化したのに15分後に再び EDEADLK が出た。
    一度実体化しても、ディスク残量が 98%(残り 25GB)のままだと iCloud がすぐに再退避します。対症療法(brctl)と根本治療(ディスクを空ける・フォルダを移す)は別の話です。ComfyUI サーバ停止で 27GB 回収し空きを 60GB 台に戻すまで、同じ症状が繰り返しました。

  • grep -r 'Desktop/Article' ~/dev でゼロ件を確認して「移行完了」とした。
    ~/dev 外の ~/.claude/scripts/ 配下に Shell スクリプトが 3 本あり、こちらは Git 管理外なので検索範囲に含まれていませんでした。symlink 残置のおかげで実害は免れましたが、翌日 launchd に踏まれて発覚しました。残存確認は grep -r 'Desktop/Article' ~ のように ~ からスコープを取ります。

  • symlink 越しに動くことで「移行完了」の確認を取った。
    ~/Desktop/Article → ~/content/article の symlink が生きている間は旧パス参照でも動きます。本当の確認は symlink を外した状態で直パスが通るかです。symlink を削除したタイミングで死ぬジョブが残っている場合、本番で事故になります。

  • EDEADLK と EAGAIN の errno 値を macOS と Linux で混同した。
    Linux では EAGAIN は errno 11、EDEADLK は errno 35 です。macOS は逆で EDEADLK が 11、EAGAIN が 35 です。e.errno not in (11, 35) のようにマジックナンバーを書くと OS を跨いだ移植で必ず誤ります。import errno して errno.EDEADLK・errno.EAGAIN の定数経由で比較することで、OS 差異を考慮しなくて済みます。

  • except OSError as e をすべてリトライした。
    PermissionError(errno 13)や FileNotFoundError(errno 2)は、何回リトライしても brctl download で解消しません。全 OSError をリトライすると、権限エラーを 14 秒(2+4+8 秒)待ってから再度失敗するというだけのコードになります。絞り込みは「EDEADLK か EAGAIN のときだけ」が正解です。

  • EDEADLK を握りつぶして空文字を返した。
    空文字のフォールバックは「エラーにならない」という意味でテストが通ります。しかし呼び出し側は「記事が 0 件だった」と解釈して exit 0 を返し、死活監視が「正常」としてパスします。8/3〜8/6 の 4 日間ゼロ投稿を検知できなかった根本はここにあります。最終リトライ後も失敗したら OSError をそのまま raise します。

  • capture_output=True を省略して launchd のログが汚染された。
    brctl download は成功・失敗にかかわらず標準出力にテキストを出します。launchd 管理のジョブは stdout を /tmp/ 以下のログに書くため、brctl の出力が混入してパース処理が壊れます。capture_output=True は省略しないのが原則です。

  • 設定ファイル(CLAUDE.md)が dataless になってもエラーが出なかった。
    ~/Documents/claude-obsidian/wiki/CLAUDE.md が dataless の状態で Claude Code を起動すると、コンテキストが空でセッションが立ち上がります。エラーメッセージは出ません。3 往復会話して「参照されていない」という違和感で気づきました。自分が書いた Python に read_text_resilient を入れるだけでは不十分で、ツールやフレームワークが読む設定ファイルが iCloud 配下にある場合は事前実体化が別途必要です。

  • attempts=4 を固定値にしてテストが 14 秒かかった。
    全リトライを消化すると 2+4+8=14 秒のsleep が走ります。attempts を引数化していないと、テストで mock しても sleep を切れません。read_text_resilient(path, attempts=1) のように上書きできる設計にすることで、テストは attempts=1 で実行して sleep を省略します。

  • Shell 版で権限エラーにも無駄にリトライした。
    bash では $?(終了コード)から errno を取れないため、cat が失敗したらすべてリトライするコードになりがちです。PermissionError でも 2+4+8 秒待ちながら 3 回リトライします。Shell スクリプトで Documents/Desktop 配下を読む箇所は最小化し、可能なかぎり Python 版に置き換えることが先決です。


ベストプラクティス

実装と障害対応から確立した運用ルールです。特に 「静かな失敗」を作らない という方針が全体を貫いています。

1. Desktop・Documents を自動化の読み書き対象にしない

これが最も根本的な対策です。iCloud Drive が有効な環境では Desktop と Documents が同期対象になっており、ディスクが逼迫すると optimize-storage が実体を自動退避します。自動化スクリプトが読み書きするファイルは ~/content/ や ~/dev/ 配下の iCloud 非管理パスに置きます。

移行手順は単純です。実体を ~/Desktop/Article/ から ~/content/article/ へ移動し、元のパスに symlink を残す。launchd のジョブは symlink 越しに読めるので移行中も止まりません。パス定数の更新はスクリプト内の1行置換です。残存確認は最広スコープで実施します。

grep -r 'Desktop/Article' ~ \
  --exclude-dir=.git \
  --include='*.sh' --include='*.py' --include='*.js' --include='*.mjs'

~/dev だけに絞ると ~/.claude/scripts/ など Git 管理外の領域を取りこぼします。

2. Documents・Desktop を読む箇所には read_text_resilient を必ず挟む

iCloud 同期が必要なフォルダ(Obsidian Vault など)を自動化から読む箇所は、A プランだけでは解決しません。B プランとして読む側でリトライ耐性を持たせます。関数の最終形は前のパートで示した通りですが、チェックポイントをまとめます。

  • errno.EDEADLK / errno.EAGAIN のときだけリトライ(他は即 raise)
  • brctl download を呼んだ後に sleep(前ではない)
  • capture_output=True を忘れない
  • 全リトライ失敗後は OSError をそのまま raise(空文字を返さない)
  • attempts は引数化してテストで上書きできるようにする

Vault 配下の .md を読む箇所を read_text_resilient に置き換えた後、既存テスト 51 本がすべて pass することを確認します。

3. 静かなフォールバックを作らない

自動化における「静かな失敗」は「派手な失敗」より遥かに悪い。EDEADLK を握りつぶして "" を返すと、投稿スクリプトは「今日は記事が 0 件だった」として exit 0 を返します。アラートは鳴らず、死活監視は「正常」としてパスします。note のダッシュボードを開くまで気づきません——実際に 4 日間気づきませんでした。

フォールバックの「賢さ」はノイズを減らしますが、同時にシグナルも消します。EDEADLK は「今は実体がない」という明確な状態です。リトライしても通らないなら「環境が壊れている」か「ファイルが問題」であり、その判断を上位に委ねるべきです。例外の責務を関数の中で飲み込まないことが、障害の早期発見につながります。

4. errno は定数で比較する

# 悪い
if e.errno not in (11, 35):
    raise

# 良い
if e.errno not in (errno.EDEADLK, errno.EAGAIN):
    raise

macOS では errno.EDEADLK = 11、errno.EAGAIN = 35 です。Linux は逆です。定数経由で比較すればOSの差異を意識する必要がありません。

5. ディスク残量を死活監視の指標に加える

今回の根本は「ディスクが 98%(残り 25GB)まで埋まっていた」という1点です。

df -h ~

この1コマンドで見えます。launchd ジョブの死活監視に disk 使用率を加えて、80% 超でアラートを出す設計にしておけば、iCloud の退避が始まる前に気づけます。容量の大きい原因(今回は ComfyUI サーバが 27GB 常駐していた)を特定して止めることがディスク管理の実態です。

6. brctl download -R でセッション開始前に Vault を実体化する

Vault 配下の CLAUDE.md や hot.md が dataless になっても、Claude Code 起動時にエラーは出ません。静かに空のコンテキストでセッションが始まります。ログイン項目(launchd の StartOnMount or Shell スクリプト)に以下を仕込みます。

brctl download -R ~/Documents/claude-obsidian/wiki/

-R は再帰的ダウンロードのオプションです。Vault 全体を一括で実体化してから Claude Code を起動する手順にすることで、「コンテキストが注入されていない」という静かな失敗を防ぎます。

7. brctl の返り値を信用してリトライ判定に使わない

brctl download は「ダウンロードをキューに積んだか」を返すコマンドです。exit 0 はダウンロード「完了」ではありません。subprocess.run の返り値でリトライ制御をしても意味がなく、次の read_text() が通るかどうかだけが判定の根拠です。現在の実装でも brctl の戻り値を確認していません(capture_output=True で捨てています)。これは設計として正しい。

8. 移行完了の確認は「symlink 越しに動く」ではなく「直パスで動く」

~/Desktop/Article に symlink を残す理由は移行期間中の安全弁です。symlink が生きている間は旧パスで動くので、テストが通っても移行は完了していません。symlink を削除した状態で全ジョブを手動実行して確認することが本当の完了確認です。symlink 削除後に死ぬジョブが残っている場合、それが未更新のパス参照です。

9. Shell スクリプトの残存確認は Git 管理外まで含める

Git リポジトリの grep でゼロ件を確認しても、~/.claude/scripts/ 配下の管理外スクリプトには届きません。残存確認コマンドそのものをスクリプト化して、スコープを ~ 全体に広げておくことで次回の移行でも流用できます。

#!/bin/bash
# path-check.sh — Desktop/Article が残存していないか確認
grep -r 'Desktop/Article' ~ \
  --exclude-dir=.git \
  --include='*.sh' --include='*.py' --include='*.js' --include='*.mjs' \
  --include='*.mjs'

ゼロ行を返せば「残存なし」が確認済みになります。

10. 「全部落ちてる」の切り分け型を先に持つ

同じ「全レーン停止」に見える症状でも、根本原因は複数あります。今回の 2 日間だけで「資源リーク(孤児プロセス 692 本・ComfyUI 27GB 常駐)」「iCloud dataless による EDEADLK」「週次クォータ超過」の 3 種類が並走しました。

切り分け順は決まっています。

①エラーコードを見る。 EDEADLK ならファイル問題。Timeout ならプロセス・メモリ問題。You've hit your weekly limit ならクォータ。エラーメッセージは嘘をつきません。

②「何が同時に死んでいないか」を見る。 iCloud 問題なら python3 open() が死んでも curl は生きています。メモリ問題なら Chrome も claude -p も両方死にます。障害の「幅」が原因の性質を絞ります。

③df -h ~ でディスク残量を確認する。 80% 以下なら iCloud の dataless 退避はまず起きません。逼迫していたなら今回と同じ経路です。

このフローを持っていなければ、毎回 1 時間を原因特定に溶かします。

11. 死活監視は「実投稿数」で取る。exit 0 だけを見ない

dataless でも多くのスクリプトは「記事が 0 件だった」として exit 0 を返します。死活監視で「プロセスが正常終了した」ことを確認しても、ゼロ投稿は検知できません。

監視すべきは「今日、この媒体への投稿が 1 件以上あったか」です。note・X・Instagram など媒体ごとに当日の実投稿数を集計し、ゼロが続いたらアラートを出す設計にします。収益の大半を出しているレーンほど、この監視が欠かせません。

12. attempts の引数化を忘れない

def read_text_resilient(path: Path, attempts: int = 4) -> str:

本番デフォルトは attempts=4(初回 + リトライ 3 回、計14秒の sleep)ですが、テストでは attempts=1 に上書きして sleep を省略します。引数化していない実装は、テストでもフル 14 秒待つか、unittest.mock で sleep を完全に差し替えるかの二択になります。引数で制御できる方が圧倒的に楽です。

13. plist には優先度降格キーを全数付与する

iCloud 関連の話から少し広げます。今回の障害対応で、Chrome 重量級ジョブ 18 本に Nice=10 / LowPriorityIO=true / ProcessType=Background が欠けていることが発覚しました。優先度が付いていないジョブはカーネルのリソース配分で不利を受け、Chrome の起動が 180 秒タイムアウトを踏む一因になっていました。

plist の規約違反を lint で一括検出し、18 本に付与してから再ロードすることで launchctl list 上 157/157 の準拠・ロード失敗 0 を確認しました。ジョブが多い環境ほど、lint を CI に組み込んで規約違反の混入を防ぐことが重要です。


まとめ

iCloud の optimize-storage が引き起こす EDEADLK は、コードのバグでも macOS のバグでもありません。「ディスクが逼迫したとき、Desktop と Documents 配下のファイルを iCloud に退避する」という macOS の設計が、自動化スクリプトに対して無音で致命的なエラーを引き起こす——その構造的な問題です。

私が 8 月 3 日から 4 日間、note レーンをゼロ投稿にしたのは、この仕組みを知らなかったからではなく、「静かな失敗が起きていること」に 4 日間気づかなかったからです。スクリプトは exit 0 を返していました。死活監視は「正常」を返していました。note のダッシュボードを開くまで、何も壊れていないように見えていました。

恒久策は 2 本柱です。

A. ファイルを逃がす。 自動化の読み書き対象を Desktop・Documents から出す。796 本の記事ファイルを ~/content/article/ に移動し、symlink を残して移行中もジョブを止めない。iCloud 非管理パスに置けば、ディスクが逼迫しても退避されません。

B. 読む側で EDEADLK を吸収する。 iCloud 同期が必要なフォルダ(Vault など)を読む箇所には read_text_resilient() を挟む。EDEADLK・EAGAIN のときだけ brctl download → 2→4→8 秒の指数バックオフでリトライし、それ以外の OSError は即 raise。最終失敗時も例外をそのまま上位に投げる。

この 2 本は役割が違います。A は「退避が起きない構造を作る」、B は「退避が起きても読める安全弁を入れる」。A なしに B だけ入れると、ディスクが埋まるたびに B が消耗します。B なしに A だけ入れると、Vault のような移動できないフォルダで無防備になります。どちらか一方では完結しません。

月商 120 万の自動化ラインを守るのは、160 本超の launchd ジョブだけではありません。それらのジョブが読むファイルが、静かに消えていないという保証が要ります。今回の障害は「ファイルシステムへの信頼」を基礎から問い直す経験でした。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?