Skip to main content
  1. Posts/

1.2GBの.appと、静かに壊れていた–no-chaptersの話

·3918 words·8 mins
Table of Contents

はじめに
#

narou_dl (なろう小説をEPUB化するPython製CLI/GUIツール)をmacOS用の .app バンドルにする作業と、その後CLIとGUIで設定を共有する仕組みを作る中で見つけた実バグの話。

一見関係のない2つの作業なのだが、書いていて気づいたことがある。どちらも「表面上は動いているのに、内部では想定と違うことが起きている」という点で根が同じだった。前者は容量という数字で嘘が見えたが、後者は数字が出ないぶん発見が遅れた。この2つを並べて書く。

リポジトリはこちら: https://github.com/ac1965/narou_dl/

.app が1.2GBになった日
#

事の発端
#

narou_dl はもともとCLIのみのツールだった。PySide6でGUIを追加した後、「じゃあダブルクリックで起動できる .app にしよう」と考えるのは自然な流れだ。ツールは py2app 。すでにインストール済みだったので、setup.py を書いてビルドを叩いた。

結果:

dist/narou-dl.app  →  1.2GB

原因はすぐに分かった。QtWebEngineCore だけで 589MB 。このアプリはブラウザエンジンなど一切使っていない。QMainWindowQTabWidgetQThread だけの、ごく普通のフォームアプリだ。

py2appは依存関係を静的に解析するのではなく、PySide6パッケージ全体を「使うかもしれないもの」としてまるごと同梱する。これは安全側に倒した挙動としては理解できるが、配布物としては明らかにやりすぎだった。

トリミングの方針
#

scripts/trim_macos_bundle.py というビルド後処理スクリプトを書いた。方針はシンプルで、

  1. アプリが実際に import しているQtモジュールを洗い出す(QtCore, QtGui, QtWidgets の3つのみ)
  2. Contents/Resources/lib/python3.10/PySide6/ 以下から、上記以外の .so / フレームワークをまるごと削除する
  3. Qt プラグイン(platforms/, styles/ など)も、実際にヘッドレス実行・GUI実行の両方で必要な最小セットだけ残す

これで 1.2GB → 約183MB まで落ちた。ここまでは順調だった。

問題: 署名とrpathが同時に壊れる
#

トリミング後に codesign --verify --deep --strict を通したところ、当然ながら失敗した。ファイルを後から削除しているので、元の署名(コードサイニングのハッシュ)が無効になる。これは想定内で、削除後に codesign --force --deep -s - (ad-hoc署名)をかけ直すことで解決した。

ここまでは想定内。ここから先が想定外だった。

署名を通したあと実際に open dist/narou-dl.app を叩くと、*ウィンドウが一切出ずに即終了する* 。エラーダイアログもなし。ターミナルから直接バイナリを叩いてようやく手がかりが出た。

qt.qpa.plugin: Could not load the Qt platform plugin "cocoa" in "" even though it was found.

libqcocoa.dylib 自体はファイルとしては存在している。しかし読み込めない。otool -L で依存関係を追うと、原因がわかった。

py2appがコピーしたQtプラグイン内の .dylib は、リンク時に埋め込まれた rpath (実行時の探索パス)が、コピー先での実際のフレームワーク配置場所を指していなかった。つまり libqcocoa.dylib は「QtCore.framework はここにあるはずだ」という思い込みを埋め込まれたまま複製され、その思い込みが .app バンドル内の実際のパスとズレていた。

修正は install_name_tool -change で各プラグインの依存パスをバンドル内の実パスに書き換える、という力技になった。トリミングスクリプトの後処理として追加した。

さらにもう一段。qt.conf によるプラグインパスの自動検出も期待通りには動かず(py2appの Resources 構成と Qt の探索ロジックの相性が悪い)、結局 mac_app_launcher.py (py2app用のエントリスクリプト)の中で QT_PLUGIN_PATH を明示的に環境変数として設定する形に落ち着いた。

最終結果
#

段階サイズ状態
素のpy2appビルド1.2GB起動する(が巨大)
Qtモジュールトリミング後183MB起動しない(署名/rpath崩壊)
rpath修正 + 再署名217MB(保持リスト拡大のため微増)起動する

最終的に183MBではなく217MBに落ち着いたのは、rpath修正の過程で「本当に必要なもの」を洗い出し直した結果、保持リストを少し広げたため。削減率を追い求めすぎて起動しなくなるより、10MB弱の余裕を持たせて確実に動く方を選んだ。

codesign --verify --deep --strict を通した状態で、実機相当の環境で5秒間安定稼働・エラーログゼロ(exit 143 = 正常なSIGTERM終了)、さらにアプリ内蔵Pythonからキャッシュ経由でEPUB生成まで確認して、この作業は完了とした。

この作業から得た教訓
#

  • 「動いた」の基準を早めに引き上げる。 ビルドが通っただけでは何も保証されない。署名検証・実際の起動・実際の機能実行、この3点を毎回セットで確認しないと、削減作業のどこかで静かに壊れていても気づかない。
  • 削るときは「使っているもの」から機械的に洗い出す。 「たぶん要らないだろう」という直感でexcludeリストを書くと、後で excludes に実効性がないものが混ざっていることに気づいて整理する羽目になる(実際そうなった)。
  • rpathと署名はセットで壊れる。 ファイルを後から動かしたり消したりするビルドパイプラインでは、この2つは必ず両方チェックする。

–no-chapters が静かに機能していなかった話
#

背景
#

CLIとGUI、両方で同じオプション(縦書き/横書き、章分割の有無、挿絵埋め込みの有無、バックエンド選択など)を扱うようになったので、~~/.config/narou-dl/config.json~ という共有設定ファイルを新設した。CLIは --save-config で明示的に保存、GUIはダウンロード開始のたびに自動保存する。

この機能自体は問題なく動いていた。ただ、CLI↔GUIの往復を全項目について再検証していたとき、1つだけ奇妙な挙動に気づいた。

narou-dl n7308he --save-config --no-chapters

を実行しても、config.jsonno_chapters は常に false のまま保存される。他の8項目(yoko, sleep, backend など)は正しく往復するのに、これだけ効かない。

原因: BooleanOptionalAction と –no- の衝突
#

該当のオプション定義は argparse.BooleanOptionalAction を使っていた。

parser.add_argument(
    "--no-chapters",
    action=argparse.BooleanOptionalAction,
    default=False,
)

BooleanOptionalAction は、指定した1つのフラグ名から自動的に「肯定形」と「–no- を前置した否定形」の両方を生成してくれる、Python 3.9で入った便利機能だ。–yoko を渡せば --yoko (True)と --no-yoko (False)の両方が自動的に使えるようになる。ここまでは他のオプションで問題なく機能していた。

問題は、フラグ名を 最初から –no-chapters にしていた こと。BooleanOptionalAction の内部実装は、渡された文字列が --no- で始まっているかどうかだけを見て否定形を生成する。つまり:

  • --no-chapters というフラグ名を渡すと
  • 内部では「肯定形: --no-chapters 」「否定形: --no-no-chapters 」というペアが作られる
  • ユーザーが素直に --no-chapters と打つと、それは「肯定形」として解釈される
  • しかし肯定形のdestのデフォルト値の扱いと、もともと「chaptersを分割する/しないをTrue/Falseで持ちたい」という設計意図が噛み合っておらず、結果的に指定してもされなくても False が保存される

要するに、*フラグ名自体が否定の意味を持っている場合、BooleanOptionalAction の「自動で否定形を作る」機能と名前が二重に衝突する* 。ドキュメントを読んだだけでは気づきにくい、この機能特有の落とし穴だった。

修正
#

--no-chapters / --no-images のような「もともと否定形の名前」のオプションは、BooleanOptionalAction をやめて、同じ dest を共有する2つの明示的なフラグに分離した。

parser.add_argument(
    "--no-chapters",
    dest="no_chapters",
    action="store_true",
    default=argparse.SUPPRESS,
)
parser.add_argument(
    "--chapters",
    dest="no_chapters",
    action="store_false",
    default=argparse.SUPPRESS,
)

default=argparse.SUPPRESS にしているのは、「どちらも指定されなかった場合はconfig.jsonの保存値をそのまま使う」という優先順位(CLI引数 > 設定ファイル > コード上のデフォルト)を壊さないため。二重否定(--no-no-chapters)という不自然な名前も解消され、–chapters / --no-chapters という素直な対になった。

気づけなかった理由
#

このバグが厄介だったのは、*単体では正しく動いているように見える* ことだ。

  • narou-dl --help を見ても、–no-chapters はちゃんと表示される
  • --no-chapters を渡してもエラーは出ない
  • ダウンロード自体は成功する(章分割されるかどうかは目視で気づきにくい)
  • 設定ファイルへの書き込みも失敗せず、ただ 中身が常にfalse になっているだけ

つまり「エラーは出ないが意図と違う値が保存される」タイプのバグで、CLI単体をどれだけ動作確認しても気づけない。今回発見できたのは、CLIとGUIの間で設定を共有する仕組みを作り、両者の値を 機械的に突き合わせるテスト を書いたからだった。9項目を保存→読み込みで往復させ、全項目が一致するかを確認する回帰テストの中で、この1項目だけが浮かび上がった。

修正後、この不具合を検知する回帰テストを4件追加して、二度と気づかないまま再発しないようにしてある。

2つの話に共通すること
#

.app の話は「1.2GB」という誰の目にも明らかな数字が出た。だから疑うきっかけがあった。一方 --no-chapters の話は、実害(章分割されないEPUBが生成される)はあっても、それに気づくには「章が分割されているかどうかを毎回目視する」という地味な確認が必要で、数字のようなわかりやすい異常サインがなかった。

その意味で、後者の方が実は厄介だ。ビルドサイズのような「定量的に監視できる指標」がない機能ほど、*値の往復を機械的に突き合わせるテスト* を持っておかないと、静かに壊れたまま気づかずに使い続けることになる。

.app の軽量化は「起動する/しない」という白黒はっきりした結果に守られていたが、設定の永続化のような機能は「動いているように見えて実は違う値が入っている」という灰色の壊れ方をする。この違いを意識しておくと、次にどこにテストを厚くすべきかの判断が少し楽になる。

参考: narou_dl はなろう小説をEPUB化するPython製ツール。ソースは GitHub (ac1965) で公開。

Related