Skip to content

test: AppRouterTests の CI flaky を withMainSerialExecutor で決定化する #347

Description

@GeneralD

agent type priority complexity estimate AI flaky

問題

AppRouterTests の 2 本が CI で不定期に落ちる。PR #346(docs 1 行のみの変更)で発生し、同一コミットのまま再実行したら緑になった。

✘ "start forwards layout changes and player attachment to the window"
    (window.attachedPlayers.count → 0) == 1
    (window.appliedWallpaperScales → [1.0]).contains(1.4)
✘ "config reload that removes the wallpaper detaches the player from the window"
    (window.attachedPlayers.count → 0) == 1

手元では 0.34 秒で通る。CI では 3 秒のポーリング期限を使い切って count が 0 のまま。10 倍の余裕があって落ちているので「わずかに遅い」ではない。

原因 — 抽象化されているのは「時間」であって「実行順序」ではない

AppLaunchEnvironmentTests規約どおり ImmediateClock() を注入しており、待機経路に Date()Task.sleep も無い。それでも落ちるのは、待っている対象が時間ではないため。

router.start() → プレイヤー添付までの実際の経路(Sources/Presenters/Wallpaper/WallpaperPresenter.swift:69-94):

configInteractor.appStyleChanges
    .receive(on: DispatchQueue.main)      // 実物の GCD ホップ
    .sink { ... }
loadWallpapers(source: interactor.wallpaperSource)

// loadWallpapers()
let stream = interactor.resolvedWallpapers()   // AsyncStream
loadTask = Task { @MainActor [weak self] in    // 非構造化 Task、ハンドルは private
    ...                                         // 最終的に onPlayerAvailable → attachPlayerLayer
}

ImmediateClock が早送りできるのは clock.sleep だけで、上の 3 つ(非構造Task のディスパッチ / AsyncStream の要素配送 / DispatchQueue.main ホップ)はすべて協調スレッドプールと GCD の管轄にあり、注入可能な継ぎ目が無い。

結果として waitUntilAppLaunchEnvironmentTests.swift:14-22)は、

while !condition(), ContinuousClock.now < deadline {   // 実時計
    try? await Task.sleep(for: .milliseconds(10))
}

意味論的な問い(「その仕事はもう起きたか」)をマシンのスループットへの賭け(「3 秒以内にディスパッチされるか」)に変換している。負荷の高い CI runner では賭けが負ける。

waitUntil 自体は CLAUDE.md が推奨する「固定 sleep ではなくポーリング」の正しい書き方であり、固定 sleep より確実に良い。ただし flake 確率を下げるだけで消しはしない — 待つ先が実時計である限り賭けは賭けのまま。

対応案 — withMainSerialExecutor

swift-concurrency-extraswithMainSerialExecutorテスト中グローバル executor を単一シリアル実行に固定し、Task の割り込み順を決定的にする。ポーリングは await Task.yield()置換できる。

@Test("start forwards layout changes and player attachment to the window")
func startForwardsLayout() async {
    await withMainSerialExecutor {
        // …router.start() の後、ポーリングではなく yield で駆動
    }
}
  • 本番コードに触らないのが最大の利点。Presenter にテスト専用の継ぎ目を足す案(loadTask を await 可能にする)と比べ、production の API 面が増えない
  • swift-concurrency-extras は swift-dependencies 経由で既に推移的に入っている(ビルドログで確認済み)。Package.swift への明示的な追加が必要かは実装時に確認
  • 現状このリポジトリで withMainSerialExecutor一度も使われていない

該当箇所

  • Tests/AppRouterTests/AppLaunchEnvironmentTests.swift:14-22waitUntil ヘルパー
  • :553, :603 — 落ちた 2 本のアサーション
  • Sources/Presenters/Wallpaper/WallpaperPresenter.swift:69-94 — 待機対象の非決定性の出どころ(変更対象ではない、原因の所在として)

対象

  • waitUntil の期限延長 — 賭けの掛け金を上げるだけで、賭けであることは変わらない
  • 本番コードの構造変更Task + AsyncStream + Combine の設計自体は妥当。テスト側で決定性を取り戻すのが筋
  • リトライによる隠蔽CI で落ちたら再実行、を常態化させない

完了条件

  • 落ちた 2 本が withMainSerialExecutor 配下で決定的に動く
  • 実時計へのポーリング(waitUntil)がこの 2 本の経路から消えている
  • 本番コードに変更が無い
  • 同様の待ち方をしている他のテストを洗い出し、範囲を明記(一括で直すか別対応かは実装時に判断)

補足

同種の CI 負荷由来 flake は前歴がある(main9f7907fScopedAPISession の timing とサブプロセスの timing)。テストは別だが性質は同一なので、withMainSerialExecutor の適用範囲を検討する際の参考になる。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions