Skip to content

test: executeProcess の Oracle 要素を抽象化してフレークを構造的に断つ #340

Description

@GeneralD

agent type scope priority complexity relates

問題

CustomScriptLyricsDataSourceImplTests の実サブプロセステスト 2 件が CI で間欠的に落ちる。原因はテストoracle(合否を決める判定要素)が非決定的な OS 依存に直結していること。テスト側の小細工では直らず、実装側で oracle 要素を抽象化しない限り再発し続ける。

フレークの個別対処はこれまで複数回行われている(#146 TrackInteractor、#213 DecodeEffectState)。同じクラスの問題が場所を変えて再発しているので、対症療法ではなく構造で断ちたい。

実測(PR #339CI

同一 SHA の再実行で success したためフレーク確定。落ち方は次のとおり。

テスト 期待 実測
executeProcessRealSubprocessDoesNotHang /bin/echo が status 0 status -1(= 3000ms timeout 発火)、stdout 空
executeProcessTimeoutReturnsPromptly 100ms timeout が 5s 以内に返る 19.86s

/bin/echo が 3 秒で timeout するのは lyra のコードの性質ではなく、走らせた runner の性質。

なぜフレークするのか — Oracle が抽象化されていない

processRunner という seam は既にあり、呼び出し側テストはこれを stub して決定的になっている。しかし executeProcessその seam の live 実装そのもので、static func のため @Dependency を持てず、次の 4 つを直接ハードワイヤしている。

  1. 実クロック — timeout 発火が asyncAfter(deadline: .now() + ...)。SIGKILL エスカレーションの +500ms も同様
  2. 実プロセスProcess() / process.run()
  3. 実 global queue のスレッドプール — stdout/stderr の drain が readDataToEndOfFile()ブロッキング。1 呼び出しにつきプールのスレッドを 2 本、子プロセスの生存中ずっと占有する
  4. 実パイプ

結果executeProcess検証する手段が「実物を回して実時間を測る」しか無くなり、テストの oracle が次を測ってしまう。

#expect(elapsed < .seconds(30))  // 50 個の実サブプロセスを runner が十分速く捌けたか
#expect(elapsed < .seconds(5))   // 100ms の timer が実時間で発火できたか

#expect(result.status == 0) すら実スケジューリング依存になる(timeout が先に発火すれば -1)。

なお 3 は本番側の設計問題でもある。Swift Testing は suite を並列実行するため、並列テストの drain がプールを圧迫すると timeout work item も drain 自身も遅延する — 観測された症状と整合する。これは #308 で潰した waitUntilExit() hang と同系統の話。

該当箇所

  • Sources/LyricsDataSource/CustomScriptLyricsDataSourceImpl.swift:119-204executeProcess static
    • :186-187 timeout の実クロック / :181 SIGKILL の +500ms
    • :157-165 .global() 上のブロッキング drain ×2
  • Tests/LyricsDataSourceTests/CustomScriptLyricsDataSourceImplTests.swift:249-287 — 実時間 oracle の 2 件(:269, :286
  • Sources/Domain/Misc/ProcessGateway.swift:29-36後

既存抽象化との関係

ProcessGateway(Domain)+ DarwinGateway は既にあり、OS 境界を DataSource 層で消費するのは .claude/rules/architecture-boundaries.md の文書化された型(前例: AudioTapGatewayConfigWatchGateway)。現在の static はその型からの逸脱

ただし gateway 側にも穴がある。既存の操作は

  • run(executable:arguments:) -> Int32同期出力なし
  • runCapturingOutput(executable:arguments:) -> String?同期・timeout なし・stderr なし・environment なし

で、async + timeout + stdout/stderr capture を持つ操作が無い。各 DataSource が独自 static を抱えているのはこの穴が理由で、つまり抽象化の不足重複と非決定性を同時に生んでいる。

対応

  1. ProcessGateway に async + timeout + stdout/stderr capture の操作を追加し、DarwinGateway に live 実装を置く
  2. 2 つの executeProcess static をそこへ移送して重複を解消
  3. timeout をクロック注入で駆動する(@Dependency(\.continuousClock)前例: refactor: inject Clock and ProcessSnapshot into BenchmarkHandler #186 BenchmarkHandler への Clock/ProcessSnapshot 注入)。static を外す必要がある
  4. ブロッキング drain をやめる(readabilityHandler か専用 queue)。プール枯渇そのものを消す — テストだけでなく本番の話
  5. oracle を差し替える。自動・確信度ベースのメタデータ+歌詞解決サイクル #308 の hang 退行ガードは「終了しない fake process + TestClock で timeout が論理的に発火するか」を実時間待ちゼロで検証する形にできる。「50 個の実 echo が 30 秒以内に終わったか」より検出力が高く、かつ決定
  6. 残す実サブプロセス検証は timing oracle を持たない薄いスモークに縮小(status/stdout のみ、elapsed 境界なし)

当面の緩和として @Suite(.serialized)(実プロセスは global な OS 状態。#315 の CoreAudioTapGateway スモークと同じ考え方)も有効だが、これは緩和であって解決ではない。

付随して見つかったこと

YouTubeWallpaperDataSourceImpl.executeProcess:228-274)は同じ配管の重複で、しかも #308 で lyrics 側から除去された waitUntilExit():269 に残っている。timeout も無い。

ただし #308 の hang トリガは「短命プロセスの反復呼び出し」で、こちらは yt-dlp/ffmpeg(長寿命・低頻度)なので条件が違う。現に問題が出ているという主張ではないが、対応案 2 で 1 箇所に集約すれば自然に消える。

スコープ

config watch(#338 / #339)のスコープ外の follow-up。フレーク自体は #339 のマージをブロックしていない(同一 SHA の再実行で green、マージ済み)。

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions