TUIダッシュボードのパニッククラッシュをnil安全ガードで修正
別ターミナルから once teardown を実行した際にTUIがパニックしてクラッシュする問題を、scrollToSelection 関数へのnil安全ガードの追加で修正しました。
背景
once teardown を別ターミナルから実行すると、TUIを開いているターミナルでパニックが発生してクラッシュするバグが報告されていました(#49)。パニックのスタックトレースによると、クラッシュは dashboard.go:359 の scrollToSelection 関数内で発生しており、index out of range [0] with length 0 というエラーが原因でした。
teardown によってアプリが全て削除されると、m.panels スライスが空になります。しかし scrollToSelection はパネルが存在することを前提としており、空スライスに対してインデックスアクセスを試みた結果、ランタイムパニックが発生していました。
技術的な変更
修正は scrollToSelection 関数の冒頭に空スライスの早期リターンガードを追加するという、最小限の変更です。
変更前:
func (m *Dashboard) scrollToSelection() {
panelTop := 0
for i := range m.selectedIndex {
panelTop += m.panels[i].Height(dashboardShowDetails)
変更後:
func (m *Dashboard) scrollToSelection() {
if len(m.panels) == 0 {
return
}
panelTop := 0
for i := range m.selectedIndex {
panelTop += m.panels[i].Height(dashboardShowDetails)
合わせて dashboard_test.go にリグレッションテストが追加されました。testDashboard(3) で3アプリのダッシュボードを構築した後、d.apps = nil および d.buildPanels() を呼び出してteardown後の状態を再現し、selectPanel(0) がパニックしないことを assert.NotPanics で検証しています。
設計判断
修正箇所を selectPanel ではなく scrollToSelection に限定した ことが今回の設計判断の核心です。スタックトレースを見ると selectPanel → scrollToSelection の呼び出し順になっており、パニックの根本原因は scrollToSelection 内のインデックスアクセスにあります。ガードを呼び出し元の selectPanel に置く選択肢もありましたが、問題の責務を持つ関数自身がその前提条件を検査する方式が採用されています。
テストでは d.apps = nil という形でteardownのシナリオを模擬しています。実際の操作(別ターミナルからの once teardown)がアプリリストを空にすることで m.panels も空になるという因果関係を、テストコードのコメント // Simulate all apps disappearing (e.g. once teardown in another shell) が明示しており、将来の読者への文脈提供も兼ねています。
まとめ
4行の早期リターンガードと17行のリグレッションテストという最小限の変更で、並行操作によるUIクラッシュが解消されました。問題の発生箇所に責務を持たせてガードを配置し、再現シナリオを明示したテストで保護する——このアプローチは、TUIのような非同期・並行的な状態遷移が起きやすい環境でのバグ修正の手本といえます。