はじめに
subprocessで外部コマンドを叩いて画像やファイルを作っていると、たまに妙なことが起きます。
コマンドは成功(exit 0)を返しているのに、直後に確認すると肝心のファイルが無い。しかも毎回ではなく、間欠的に。
私はヘッドレスEdgeでスクリーンショットを撮る処理で、まさにこれにハマりました。
原因の切り分けを2回外して、最終的に「ファイルの出現をポーリングで待つ」というシンプルな形に落ち着いた——その過程の記録です。
こんな人に向けて書いています
-
subprocessなどで外部コマンドを呼び、ファイルを生成している人 - 「コマンドは成功しているのに出力ファイルが無い」で困っている人
- 間欠的にしか再現しない不具合の切り分け方を知りたい人
この記事でわかること
- 「exit 0なのにファイルが無い」がどういう条件で起こるのか
- 同じ原理で踏みやすい他の場面(動画書き出し・PDF生成・ダウンロードなど)
- 終了コードではなく「ファイルの出現」で成否を判定する実装(実際のコード付き)
- 切り分けを2回外した過程と、そこから得た教訓
前提
- Python(
subprocess.run())とヘッドレスEdge(--headless=new --screenshot)を使っています - ただし考え方自体は、他の言語・他の外部コマンドでも同じです
背景:何を作っていたか
mediagenという投稿画像生成ツールを個人で作っていて、Pillowで足りない複雑なレイアウト(グラデ・mono見出し・日本語)をHTML+Edgeヘッドレスに逃がす設計にしています。
その実装中に踏んだ不具合です。
こういう時に起きます(いつ・他の場面・困りポイント)
この不具合、実はEdge特有の話ではありません。
「別のプログラムを呼んでファイルを作らせて、その直後に『できた?』と確認する」——この形なら、いつでも起こりえます。
同じ空振りは、たとえばこんな場面でも起こりえます。
- ffmpegで動画や画像を書き出す
- 別ツールでPDFやサムネイルを生成する
- 別プロセスにファイルをダウンロードさせる
いずれも「別のプログラムが、自分の裏でファイルを書く」処理です。同じ原理で、同じ罠を踏みかねません。
そして、この手の不具合が厄介なのは次の3点です。
- エラーが出ない:コマンドは成功(exit 0)を返すので、「失敗した」と気づけない
- 間欠的:毎回ではなくたまに起きるので、再現させにくい
- 成功扱いで先に進む:そのまま次の処理に進んでしまい、後工程で「ファイルが無い」と別の顔で問題が出る
前提知識
-
subprocess.run()は指定したコマンドの終了を待ってから返る(result.returncodeが 0 なら正常終了)。 - Edgeの
--headless=new --screenshot=<path>は、指定URLを描画してPNGを書き出す。 - ここで前提にしたいのは「exit 0=出力ファイルが存在する」とは限らない、という一点です。
手順:切り分けを2回外した経緯
一番気持ち悪かったのは「exit 0 なのにファイルが無い」でした。
成功を返しているので、そもそも失敗として扱っていいのかから迷います。
最初に疑ったのは、Edgeが常駐して競合しているという説でした。
でも実測すると、msedgeプロセスは0個でした。
プロセス一覧が0個と出た時点で常駐競合説は崩れているのに、まだ環境のせいにしていました(1つ目の外し)。
次に疑ったのは、一時プロファイルのlockfile競合です。
プロファイルを専用に分離したら改善したように見えたのですが、また再発しました(2つ目の外し)。
転機は、失敗した直後のディレクトリを後から見たときです。
プロセス終了の直後にはPNGが無かったのに、少し後で見ると正常なPNG(約151KB)が出現していました。
ここで「環境のせいではなく、終了直後にはまだ書き込みが見えていないだけかもしれない」と腑に落ちました。
つまり subprocess.run() の直後に is_file() を見るのが早すぎて、空振りしていたわけです。
ひとつ補足しておくと、厳密な内部原因(Chromium側の書き込み完了タイミングなど)まで追い切れてはいません。
なので、ここでは「終了直後にはファイルが見えず、後から出現するタイミング問題」として扱います。
コード例
対処は、プロセス終了後にファイルの出現をポーリングで待ち、出なければ再試行する形にしました。
mediagenの edge_screenshot() はこうなっています(逐語引用)。
def edge_screenshot(command: list[str], png_path: Path, label: str, attempts: int = 3) -> None:
"""--headless=new は子プロセスがPNGを書き終える前に親が終了するため、
終了後にファイル出現をポーリングし、それでも出なければ再試行する。"""
detail = ""
for _ in range(attempts):
if png_path.exists():
png_path.unlink()
result = subprocess.run(command, capture_output=True, text=True, timeout=45)
for _ in range(25):
if png_path.is_file() and png_path.stat().st_size > 0:
return
time.sleep(0.2)
detail = (result.stderr or result.stdout).strip()
raise OSError(f"EdgeのPNG出力に失敗しました({label}・{attempts}回試行): {detail}")
ポイントを説明用に整理すると、次のようになっています。
- 内側の
for _ in range(25)でtime.sleep(0.2)を回すので、1回の試行につき最大5秒ファイルの出現を待つ。 - 待つ条件は
png_path.is_file()だけでなくpng_path.stat().st_size > 0も見る(0バイトのファイルを掴まないため)。 - 出なければ外側の
attempts(既定3回)で再試行する。試行前にpng_path.unlink()で前回の残骸を消す。 - 全部ダメなら
OSErrorを投げる。
呼び出し側では、実際に edge_screenshot(command, png_path, f"size={media}") の形で使っています。
carousel側(render_carousel_html)にも同じ関数を通して同じ待機を効かせています。
なぜ「終了コードを見て終わり」にしなかったかというと、まさに今回、exit 0 が出力の存在を保証してくれなかったからです。
成否は「終了コード」ではなく「実際にファイルが出たか」で判定する、というのがこの関数の考え方です。
もう一点、一時ディレクトリ側では tempfile.TemporaryDirectory(prefix="mediagen-titlecard-", ignore_cleanup_errors=True) のように ignore_cleanup_errors=True を付けています。
プロファイルの後片付けでたまに失敗しても、生成そのものは止めたくないためです。
この形にしてから、連続実行でexit 0の安定を確認できました。
まとめ
外部プロセスで生成したファイルは、プロセスの終了と「ファイルが見える」が同時とは限りません。
今回はそこを取り違えて、常駐競合・lockfile競合と、環境のせいにする切り分けを2回外しました。
教訓としては、0件・空振りを見たときに「無い」と決める前に、「まだ測れていないだけ」を疑うことでした。
失敗フォルダに後からPNGが出ていたのを見るまで、自分は原因を環境に押し付け続けていました。
対処自体は、終了コードではなくファイルの出現で成否を判定し、少し待ってから再試行する、というシンプルなものです。
内部の詳しいタイミングまでは追い切れていませんが、まずは動く形に落とせたので、同じ「exit 0 なのにファイルが無い」に困っている人の手がかりになればと思います。
個人でAIツールや業務自動化を作っているMiyokiといいます。
今回のmediagenのように、自分の作業を楽にするための小さなツールを作っては、その開発ログをZennとQiitaに書いています。
作ったものやポートフォリオはこちらです。