Skip to content

Commit 28239b7

Browse files
authored
fix(#318): 歌詞・メタデータ取得を呼び出し毎のephemeralセッション化し失敗をログ出力 (#320)
![agent](https://img.shields.io/badge/agent-claude-D97757) ![type](https://img.shields.io/badge/type-fix-blue) ![breaking](https://img.shields.io/badge/breaking-no-green) ![scope](https://img.shields.io/badge/scope-network-blue) ![diff](https://img.shields.io/badge/diff-+110%20--35-green) ![files](https://img.shields.io/badge/files-7%20files-yellow) ![tests](https://img.shields.io/badge/tests-added-green) ![review](https://img.shields.io/badge/review-thorough-yellow) ## <img src="https://mojiemoji.jozo.beer/emoji/%E6%A6%82%E8%A6%81?font=toge&amp;color=f43f5e&amp;animation=kage_neon&amp;background=transparent&amp;outline=5ef43f&amp;outline_width=2" alt="概要" height="20" align="absmiddle"> 長時間稼働した daemon で歌詞が<img src="https://mojiemoji.jozo.beer/emoji/%E8%A1%A8%E7%A4%BA?font=gothic&amp;color=a855f7&amp;animation=bure&amp;background=transparent&amp;outline=f7a855&amp;outline_width=2" alt="表示" height="20" align="absmiddle">されなくなり `lyra restart` でのみ復旧する<img src="https://mojiemoji.jozo.beer/emoji/%E3%83%90%E3%82%B0?font=gothic-bold&amp;color=ef4444&amp;animation=gatagata&amp;speed=slow&amp;background=transparent&amp;outline=44ef44&amp;outline_width=2" alt="バグ" height="24" align="absmiddle">の<img src="https://mojiemoji.jozo.beer/emoji/%E4%BF%AE%E6%AD%A3?font=gothic-bold&amp;color=ef4444&amp;animation=kirari&amp;background=transparent&amp;outline=44ef44&amp;outline_width=2" alt="修正" height="20" align="absmiddle">。プロセス寿命の `URLSession.shared` を引き回すのをやめ、<img src="https://mojiemoji.jozo.beer/emoji/API?font=mincho&amp;color=10b981&amp;animation=patapata&amp;background=transparent&amp;outline=8110b9&amp;outline_width=2" alt="API" height="20" align="absmiddle"> 呼び出し毎に ephemeral セッションを<img src="https://mojiemoji.jozo.beer/emoji/%E7%94%9F%E6%88%90?font=maru&amp;color=facc15&amp;animation=tate_scroll&amp;background=transparent&amp;outline=04ca8a&amp;outline_width=2" alt="生成" height="20" align="absmiddle">する。 Closes #318 ## <img src="https://mojiemoji.jozo.beer/emoji/%E5%A4%89%E6%9B%B4?font=gothic&amp;color=22d3ee&amp;animation=tate_scroll&amp;background=transparent&amp;outline=b20891&amp;outline_width=2" alt="変更" height="20" align="absmiddle"><img src="https://mojiemoji.jozo.beer/emoji/%E5%86%85%E5%AE%B9?font=gothic&amp;color=fdba74&amp;animation=kira&amp;background=transparent&amp;outline_width=0" alt="内容" height="20" align="absmiddle"> - `LyricsDataSourceImpl` / `MusicBrainzMetadataDataSourceImpl`: `apiFactory` 化し、呼び出し毎に `URLSessionConfiguration.ephemeral` + request timeout 10 秒のセッションを<img src="https://mojiemoji.jozo.beer/emoji/%E7%94%9F%E6%88%90?font=maru&amp;color=facc15&amp;animation=tate_scroll&amp;background=transparent&amp;outline=04ca8a&amp;outline_width=2" alt="生成" height="20" align="absmiddle">(`LRCLibHealthCheck` と<img src="https://mojiemoji.jozo.beer/emoji/%E5%90%8C%E3%81%98?font=dela&amp;color=60a5fa&amp;animation=mochimochi&amp;background=transparent&amp;outline=fa60a5&amp;outline_width=2" alt="同じ" height="20" align="absmiddle"><img src="https://mojiemoji.jozo.beer/emoji/%E6%A7%8B%E6%88%90?font=zero&amp;color=a855f7&amp;animation=kage_bokashi&amp;background=transparent&amp;outline=f7a855&amp;outline_width=2" alt="構成" height="20" align="absmiddle">)。`init(api:)` の<img src="https://mojiemoji.jozo.beer/emoji/%E3%83%86%E3%82%B9%E3%83%88?font=maru-bold&amp;color=fbbf24&amp;animation=yokomoya&amp;background=transparent&amp;outline=06d977&amp;outline_width=2" alt="テスト" height="20" align="absmiddle">シームは<img src="https://mojiemoji.jozo.beer/emoji/%E7%B6%AD%E6%8C%81?font=maru-bold&amp;color=60a5fa&amp;animation=yurayura&amp;speed=slow&amp;background=transparent&amp;outline=fa60a5&amp;outline_width=2" alt="維持" height="24" align="absmiddle"> - `OpenAICompatibleAPI.provider(for:)`: <img src="https://mojiemoji.jozo.beer/emoji/%E5%90%8C%E6%A7%98%E3%81%AB?font=maru&amp;color=a78bfa&amp;animation=neruneru&amp;speed=slow&amp;background=transparent&amp;outline=faa78b&amp;outline_width=2" alt="同様に" height="24" align="absmiddle"> ephemeral セッション化。タイムアウトは従来の `URLSession.shared` デフォルトと同値の 60 秒を明示(ローカル LLM の長い推論を<img src="https://mojiemoji.jozo.beer/emoji/%E8%80%83%E6%85%AE?font=mincho&amp;color=fbbf24&amp;animation=patapata&amp;speed=slow&amp;background=transparent&amp;outline=24fbbf&amp;outline_width=2" alt="考慮" height="24" align="absmiddle">) - `try?` によるエラー握りつぶしを<img src="https://mojiemoji.jozo.beer/emoji/%E5%BB%83%E6%AD%A2?font=gothic-bold&amp;color=fb923c&amp;animation=zanzo&amp;speed=slow&amp;background=transparent&amp;outline=3cfb92&amp;outline_width=2" alt="廃止" height="24" align="absmiddle">し、取得<img src="https://mojiemoji.jozo.beer/emoji/%E5%A4%B1%E6%95%97?font=hachimaru&amp;color=f472b6&amp;animation=mozaiku&amp;background=transparent&amp;outline=b6f472&amp;outline_width=2" alt="失敗" height="20" align="absmiddle">を stderr に<img src="https://mojiemoji.jozo.beer/emoji/%E3%83%AD%E3%82%B0?font=maru-bold&amp;color=10b981&amp;animation=tatemoya&amp;background=transparent&amp;outline=8110b9&amp;outline_width=2" alt="ログ" height="20" align="absmiddle"><img src="https://mojiemoji.jozo.beer/emoji/%E5%87%BA%E5%8A%9B?font=gothic-bold&amp;color=fb7185&amp;animation=yokomoya&amp;background=transparent&amp;outline=85fb71&amp;outline_width=2" alt="出力" height="20" align="absmiddle">。LRCLIB の 404(歌詞なしの<img src="https://mojiemoji.jozo.beer/emoji/%E6%AD%A3%E5%B8%B8?font=gothic&amp;color=34d399&amp;animation=zanzo&amp;background=transparent&amp;outline=9934d3&amp;outline_width=2" alt="正常" height="20" align="absmiddle"><img src="https://mojiemoji.jozo.beer/emoji/%E5%BF%9C%E7%AD%94?font=gothic-bold&amp;color=c084fc&amp;animation=ekken&amp;background=transparent&amp;outline=fcc084&amp;outline_width=2" alt="応答" height="20" align="absmiddle">)のみ<img src="https://mojiemoji.jozo.beer/emoji/%E3%83%AD%E3%82%B0?font=akzk&amp;color=60a5fa&amp;animation=bure&amp;background=transparent&amp;outline=fa60a5&amp;outline_width=2" alt="ログ" height="20" align="absmiddle"><img src="https://mojiemoji.jozo.beer/emoji/%E5%AF%BE%E8%B1%A1?font=akzk&amp;color=facc15&amp;animation=disco&amp;background=transparent&amp;outline_width=0" alt="対象" height="20" align="absmiddle">外とし、「歌詞なし」と「取得<img src="https://mojiemoji.jozo.beer/emoji/%E5%A4%B1%E6%95%97?font=maru-bold&amp;color=3b82f6&amp;animation=yokomoya&amp;background=transparent&amp;outline=f63b82&amp;outline_width=2" alt="失敗" height="20" align="absmiddle">」を<img src="https://mojiemoji.jozo.beer/emoji/%E5%8C%BA%E5%88%A5?font=maru-bold&amp;color=34d399&amp;animation=kirari&amp;speed=slow&amp;background=transparent&amp;outline=9934d3&amp;outline_width=2" alt="区別" height="24" align="absmiddle"><img src="https://mojiemoji.jozo.beer/emoji/%E5%8F%AF%E8%83%BD?font=kurobara&amp;color=a78bfa&amp;animation=kira&amp;background=transparent&amp;outline_width=0" alt="可能" height="20" align="absmiddle">に - <img src="https://mojiemoji.jozo.beer/emoji/%E3%83%86%E3%82%B9%E3%83%88?font=zero&amp;color=a78bfa&amp;animation=chirichiri&amp;background=transparent&amp;outline=ed7c3a&amp;outline_width=2" alt="テスト" height="20" align="absmiddle">: 404 サイレント分岐の<img src="https://mojiemoji.jozo.beer/emoji/%E3%83%86%E3%82%B9%E3%83%88?font=maru-bold&amp;color=fbbf24&amp;animation=yokomoya&amp;background=transparent&amp;outline=06d977&amp;outline_width=2" alt="テスト" height="20" align="absmiddle">を<img src="https://mojiemoji.jozo.beer/emoji/%E8%BF%BD%E5%8A%A0?font=pixel&amp;color=22d3ee&amp;animation=yatta&amp;speed=normal&amp;background=transparent&amp;outline=ee22d3&amp;outline_width=2" alt="追加" height="24" align="absmiddle">。LLM のプロダクション配線<img src="https://mojiemoji.jozo.beer/emoji/%E3%83%86%E3%82%B9%E3%83%88?font=zero&amp;color=a78bfa&amp;animation=chirichiri&amp;background=transparent&amp;outline=ed7c3a&amp;outline_width=2" alt="テスト" height="20" align="absmiddle">は、ephemeral セッションがグローバル `URLProtocol.registerClass` を経由しなくなったため、refused port (`http://127.0.0.1:1`) での graceful degradation <img src="https://mojiemoji.jozo.beer/emoji/%E6%A4%9C%E8%A8%BC?font=gothic&amp;color=22c55e&amp;animation=nami&amp;background=transparent&amp;outline=5e22c5&amp;outline_width=2" alt="検証" height="20" align="absmiddle">に<img src="https://mojiemoji.jozo.beer/emoji/%E5%A4%89%E6%9B%B4?font=pixel&amp;color=c084fc&amp;animation=disco&amp;background=transparent&amp;outline_width=0" alt="変更" height="20" align="absmiddle">(`OpenAICompatibleHealthCheckDefaultBackendTests` の<img src="https://mojiemoji.jozo.beer/emoji/%E6%97%A2%E5%AD%98?font=maru-bold&amp;color=c084fc&amp;animation=ekken&amp;background=transparent&amp;outline=fcc084&amp;outline_width=2" alt="既存" height="20" align="absmiddle">パターンを<img src="https://mojiemoji.jozo.beer/emoji/%E8%B8%8F%E8%A5%B2?font=mincho&amp;color=c084fc&amp;animation=tatemoya&amp;speed=slow&amp;background=transparent&amp;outline=fcc084&amp;outline_width=2" alt="踏襲" height="24" align="absmiddle">) - CLAUDE.md: Key Design Decisions に「Network session strategy」を<img src="https://mojiemoji.jozo.beer/emoji/%E8%BF%BD%E8%A8%98?font=gothic-bold&amp;color=67e8f9&amp;animation=ekken&amp;speed=slow&amp;background=transparent&amp;outline=f967e8&amp;outline_width=2" alt="追記" height="24" align="absmiddle"> - version 2.21.0 → 2.21.1 ## 背景・<img src="https://mojiemoji.jozo.beer/emoji/%E5%8B%95%E6%A9%9F?font=tamanegi&amp;color=3b82f6&amp;animation=ekken&amp;background=transparent&amp;outline=f63b82&amp;outline_width=2" alt="動機" height="20" align="absmiddle"> 症状は「<img src="https://mojiemoji.jozo.beer/emoji/%E5%90%8C%E3%81%98?font=mincho&amp;color=a78bfa&amp;animation=yatta&amp;background=transparent&amp;outline=ed7c3a&amp;outline_width=2" alt="同じ" height="20" align="absmiddle">メタデータなのに再起動<img src="https://mojiemoji.jozo.beer/emoji/%E7%9B%B4%E5%BE%8C?font=gothic-bold&amp;color=f472b6&amp;animation=shuchusen&amp;background=transparent&amp;outline=77db27&amp;outline_width=2" alt="直後" height="20" align="absmiddle">だけ歌詞がヒットする」=プロセス内に時間経過で腐る状態がある、を示唆していた。<img src="https://mojiemoji.jozo.beer/emoji/%E8%AA%BF%E6%9F%BB?font=chikara&amp;color=f43f5e&amp;animation=bane&amp;background=transparent&amp;outline=5ef43f&amp;outline_width=2" alt="調査" height="20" align="absmiddle">の<img src="https://mojiemoji.jozo.beer/emoji/%E7%B5%90%E6%9E%9C?font=zero&amp;color=c084fc&amp;animation=mozaiku&amp;background=transparent&amp;outline=fcc084&amp;outline_width=2" alt="結果" height="20" align="absmiddle">、LRCLIB 呼び出しが `static let liveValue` シングルトン + `URLSession.shared` + `try?` 全握りつぶしという<img src="https://mojiemoji.jozo.beer/emoji/%E6%A7%8B%E6%88%90?font=kurobara&amp;color=34d399&amp;animation=tatemoya&amp;background=transparent&amp;outline=9934d3&amp;outline_width=2" alt="構成" height="20" align="absmiddle">で、スリープ復帰やネットワーク<img src="https://mojiemoji.jozo.beer/emoji/%E5%88%87%E6%9B%BF?font=hachimaru&amp;color=fb923c&amp;animation=mabataki&amp;background=transparent&amp;outline=0cea58&amp;outline_width=2" alt="切替" height="20" align="absmiddle">でコネクションプールが死んでも<img src="https://mojiemoji.jozo.beer/emoji/%E6%A4%9C%E7%9F%A5?font=pixel&amp;color=ef4444&amp;animation=bure&amp;background=transparent&amp;outline=44ef44&amp;outline_width=2" alt="検知" height="20" align="absmiddle">も張り替えも<img src="https://mojiemoji.jozo.beer/emoji/%E3%83%AD%E3%82%B0?font=dela&amp;color=facc15&amp;animation=mabataki&amp;background=transparent&amp;outline=04ca8a&amp;outline_width=2" alt="ログ" height="20" align="absmiddle">もされないことが<img src="https://mojiemoji.jozo.beer/emoji/%E5%88%A4%E6%98%8E?font=mincho&amp;color=fde047&amp;animation=mabataki&amp;speed=slow&amp;background=transparent&amp;outline=47fde0&amp;outline_width=2" alt="判明" height="24" align="absmiddle">(<img src="https://mojiemoji.jozo.beer/emoji/%E8%A9%B3%E7%B4%B0?font=chikara&amp;color=a855f7&amp;animation=kira&amp;background=transparent&amp;outline_width=0" alt="詳細" height="20" align="absmiddle">は #318 の仮説 1)。ヘルスチェック (`LRCLibHealthCheck`) だけが ephemeral + 10 秒タイムアウトの<img src="https://mojiemoji.jozo.beer/emoji/%E5%A0%85%E7%89%A2?font=gothic-bold&amp;color=f9a8d4&amp;animation=zairu&amp;speed=slow&amp;background=transparent&amp;outline=d4f9a8&amp;outline_width=2" alt="堅牢" height="24" align="absmiddle">な<img src="https://mojiemoji.jozo.beer/emoji/%E6%A7%8B%E6%88%90?font=kurobara&amp;color=34d399&amp;animation=tatemoya&amp;background=transparent&amp;outline=9934d3&amp;outline_width=2" alt="構成" height="20" align="absmiddle">だったため、本番経路をこれに揃えた。 ## <img src="https://mojiemoji.jozo.beer/emoji/%E3%83%86%E3%82%B9%E3%83%88?font=zero&amp;color=a78bfa&amp;animation=chirichiri&amp;background=transparent&amp;outline=ed7c3a&amp;outline_width=2" alt="テスト" height="20" align="absmiddle">計画 - [x] `swift test` 全 1205 <img src="https://mojiemoji.jozo.beer/emoji/%E3%83%86%E3%82%B9%E3%83%88?font=hachimaru&amp;color=f43f5e&amp;animation=yoko_scroll&amp;background=transparent&amp;outline=5ef43f&amp;outline_width=2" alt="テスト" height="20" align="absmiddle"> pass - [x] `make lint` クリーン - [ ] 実機で daemon を長時間稼働させ、スリープ復帰・ネットワーク<img src="https://mojiemoji.jozo.beer/emoji/%E5%88%87%E6%9B%BF?font=tamanegi&amp;color=f87171&amp;animation=patapata&amp;background=transparent&amp;outline=26dc26&amp;outline_width=2" alt="切替" height="20" align="absmiddle">後も歌詞が取得され続けることを<img src="https://mojiemoji.jozo.beer/emoji/%E7%A2%BA%E8%AA%8D?font=gothic-bold&amp;color=d946ef&amp;animation=zairu&amp;background=transparent&amp;outline=efd946&amp;outline_width=2" alt="確認" height="20" align="absmiddle">(<img src="https://mojiemoji.jozo.beer/emoji/%E7%99%BA%E7%94%9F?font=mincho&amp;color=ec4899&amp;animation=gatagata&amp;background=transparent&amp;outline=99ec48&amp;outline_width=2" alt="発生" height="20" align="absmiddle">条件が長時間のため、リリース<img src="https://mojiemoji.jozo.beer/emoji/%E5%BE%8C?font=chikara&amp;color=10b981&amp;animation=mozaiku&amp;background=transparent&amp;outline=8110b9&amp;outline_width=2" alt="後" height="20" align="absmiddle">の<img src="https://mojiemoji.jozo.beer/emoji/%E7%B5%8C%E9%81%8E?font=maru-bold&amp;color=93c5fd&amp;animation=yoko_scroll&amp;speed=slow&amp;background=transparent&amp;outline=fd93c5&amp;outline_width=2" alt="経過" height="24" align="absmiddle"><img src="https://mojiemoji.jozo.beer/emoji/%E8%A6%B3%E5%AF%9F?font=maru&amp;color=86efac&amp;animation=chirichiri&amp;speed=slow&amp;background=transparent&amp;outline=ac86ef&amp;outline_width=2" alt="観察" height="24" align="absmiddle">) ## 備考 - 呼び出し毎のセッション<img src="https://mojiemoji.jozo.beer/emoji/%E7%94%9F%E6%88%90?font=dela&amp;color=fb923c&amp;animation=ekken&amp;background=transparent&amp;outline=0cea58&amp;outline_width=2" alt="生成" height="20" align="absmiddle">は TLS ハンドシェイクが毎回走るが、歌詞・メタデータ取得はトラック変化毎の低頻度なので<img src="https://mojiemoji.jozo.beer/emoji/%E5%AE%9F%E5%AE%B3?font=mincho&amp;color=f87171&amp;animation=tenmetsu&amp;speed=slow&amp;background=transparent&amp;outline=71f871&amp;outline_width=2" alt="実害" height="24" align="absmiddle">はない - ネガティブキャッシュや `NWPathMonitor` 契機のセッション再<img src="https://mojiemoji.jozo.beer/emoji/%E7%94%9F%E6%88%90?font=maru&amp;color=facc15&amp;animation=tate_scroll&amp;background=transparent&amp;outline=04ca8a&amp;outline_width=2" alt="生成" height="20" align="absmiddle">(#318 <img src="https://mojiemoji.jozo.beer/emoji/%E5%AF%BE%E5%BF%9C?font=maru&amp;color=fdba74&amp;animation=chirichiri&amp;background=transparent&amp;outline=74fdba&amp;outline_width=2" alt="対応" height="20" align="absmiddle">案の残り)は、本<img src="https://mojiemoji.jozo.beer/emoji/%E4%BF%AE%E6%AD%A3?font=gothic-bold&amp;color=ef4444&amp;animation=kirari&amp;background=transparent&amp;outline=44ef44&amp;outline_width=2" alt="修正" height="20" align="absmiddle">で症状が解消するかの経過観察後に<img src="https://mojiemoji.jozo.beer/emoji/%E5%BF%85%E8%A6%81?font=chikara&amp;color=facc15&amp;animation=tate_scroll&amp;background=transparent&amp;outline=04ca8a&amp;outline_width=2" alt="必要" height="20" align="absmiddle">なら別 issue で<img src="https://mojiemoji.jozo.beer/emoji/%E5%AF%BE%E5%BF%9C?font=zero&amp;color=a78bfa&amp;animation=yatta&amp;background=transparent&amp;outline=ed7c3a&amp;outline_width=2" alt="対応" height="20" align="absmiddle">する
2 parents 618f42a + 7cd3dc4 commit 28239b7

11 files changed

Lines changed: 216 additions & 45 deletions

File tree

.claude/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,8 @@ Cache is Repository's responsibility, not DataSource's. DataSources are pure API
349349

350350
**MetadataRepository cache strategy**: Priority order: LLM cache → LLM DataSource → MusicBrainz cache → MusicBrainz DataSource → Regex DataSource. LLM/MusicBrainz results are cached on success. Regex results are not cached.
351351

352+
**Network session strategy — ephemeral session per call (#318)**: The API DataSources (LRCLIB lyrics, MusicBrainz, OpenAI-compatible LLM) must NOT hold a process-lifetime `URLSession`. In a long-running daemon, pooled connections go silently stale after sleep/wake or network changes, and with every request error swallowed by `try?` the only visible symptom was "lyrics never appear until `lyra restart`" (#318). The shared mechanism is the generic `ScopedAPISession<API>` (its own `Sources/ScopedAPISession` module, Foundation-only): `withAPI { … }` builds a `URLSessionConfiguration.ephemeral` session (request timeout is a per-service init arg — 10 s for LRCLIB/MusicBrainz, 60 s for the LLM, which matches the old `URLSession.shared` default since local LLMs can take tens of seconds per completion), runs the call, and `finishTasksAndInvalidate()`s the session in a `defer`. The session is deliberately **function-local**, not stored behind a class that invalidates in `deinit`: a stored-session/`deinit` design is ARC-fragile — once the caller copies the wrapped client out, the owner (and its session) can be released *before* the request is issued, killing an in-flight or not-yet-started call. Each DataSource holds a `ScopedAPISession<any XxxAPI>` and keeps its `init(api:)` test seam (which wraps a constant `{ _ in stub }` factory — the throwaway session the stub ignores). Per-API differences (base URL, Bearer auth) live in the `makeAPI` closure, so one generic type replaces the three former per-protocol wrapper classes. `LRCLibHealthCheck` already used this exact ephemeral+timeout construction; the daemon path now matches it. Failures are logged to stderr (`lyra: LRCLIB get failed: …`), except LRCLIB 404 which is the regular "no lyrics for this track" answer — so "no lyrics" and "fetch broken" stay distinguishable in the daemon log. Test gotcha: a per-call ephemeral session ignores `URLProtocol.registerClass` (that hook only applies to `URLSession.shared`), so production-wiring tests assert graceful degradation against a refused local port (`http://127.0.0.1:1`) instead of a global URL-protocol mock.
353+
352354
**ColorStyle**: Domain-level enum (`.solid(hex)`, `.gradient([hex])`) enabling any text style to use either solid colors or gradients. Polymorphic TOML decoding supports both `color = "#FFF"` and `color = ["#AAA", "#BBB"]`.
353355

354356
**DI with swift-dependencies**: Protocol definitions + `TestDependencyKey` in `Domain`, all Domain-facing `liveValue` registrations centralized in `DependencyInjection` module (`InteractorRegistration`, `UseCaseRegistration`, `RepositoryRegistration`, `DataSourceRegistration`, `DataStoreRegistration`, `HealthCheckRegistration`). No direct instantiation — everything through `@Dependency`. AppKit foreground lifecycle is an App-module bootstrap dependency because the live implementation owns `NSApplication` and `AppDelegate` setup. UI-test mode is the other wiring-location exception: `AppRouter` may override a small fixture graph at launch based on environment variables, but feature code still reads dependencies normally through `@Dependency`.

Package.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,17 +380,20 @@ let package = Package(
380380
.product(name: "TOMLKit", package: "TOMLKit"),
381381
]
382382
),
383+
.target(name: "ScopedAPISession"),
383384
.target(
384385
name: "LyricsDataSource",
385386
dependencies: [
386387
"Domain",
388+
"ScopedAPISession",
387389
.product(name: "Papyrus", package: "papyrus"),
388390
]
389391
),
390392
.target(
391393
name: "MetadataDataSource",
392394
dependencies: [
393395
"Domain",
396+
"ScopedAPISession",
394397
.product(name: "Papyrus", package: "papyrus"),
395398
.product(name: "Dependencies", package: "swift-dependencies"),
396399
]
@@ -447,6 +450,12 @@ let package = Package(
447450

448451
// ══ Tests ══
449452

453+
.testTarget(
454+
name: "ScopedAPISessionTests",
455+
dependencies: [
456+
"ScopedAPISession"
457+
]
458+
),
450459
.testTarget(
451460
name: "TrackInteractorTests",
452461
dependencies: [
Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,60 @@
11
import Domain
22
import Foundation
3-
import Papyrus
3+
@preconcurrency import Papyrus
4+
import ScopedAPISession
45

56
public struct LyricsDataSourceImpl {
6-
private let api: any LRCLib
7+
private let apiSession: ScopedAPISession<any LRCLib>
78

89
public init() {
9-
self.init(api: LRCLibAPI(provider: Provider(baseURL: LRCLibAPI.baseURL)))
10+
self.init(
11+
apiSession: ScopedAPISession(timeout: 10) {
12+
LRCLibAPI(provider: Provider(baseURL: LRCLibAPI.baseURL, urlSession: $0))
13+
}
14+
)
1015
}
1116

1217
init(api: any LRCLib) {
13-
self.api = api
18+
self.init(apiSession: ScopedAPISession(timeout: 10) { _ in api })
19+
}
20+
21+
init(apiSession: ScopedAPISession<any LRCLib>) {
22+
self.apiSession = apiSession
1423
}
1524
}
1625

17-
// Safe: `api` is set at init and never mutated; Papyrus's Provider is configured during construction only.
18-
extension LyricsDataSourceImpl: @unchecked Sendable {}
26+
extension LyricsDataSourceImpl: Sendable {}
1927

2028
extension LyricsDataSourceImpl: LyricsDataSource {
2129
public func get(title: String, artist: String, duration: TimeInterval?) async -> LyricsResult? {
22-
guard let result = try? await api.get(trackName: title, artistName: artist, duration: duration.map(Int.init)),
23-
result.plainLyrics != nil || result.syncedLyrics != nil
24-
else { return nil }
25-
return result
30+
do {
31+
let result = try await apiSession.withAPI {
32+
try await $0.get(trackName: title, artistName: artist, duration: duration.map(Int.init))
33+
}
34+
guard result.plainLyrics != nil || result.syncedLyrics != nil else { return nil }
35+
return result
36+
} catch {
37+
log(error, operation: "get")
38+
return nil
39+
}
2640
}
2741

2842
public func search(query: String) async -> [LyricsResult]? {
29-
try? await api.search(q: query)
43+
do {
44+
return try await apiSession.withAPI { try await $0.search(q: query) }
45+
} catch {
46+
log(error, operation: "search")
47+
return nil
48+
}
49+
}
50+
}
51+
52+
extension LyricsDataSourceImpl {
53+
// 404 is LRCLIB's regular "no lyrics for this track" answer; only transport
54+
// and server failures are worth surfacing so "no lyrics" and "fetch broken"
55+
// stay distinguishable in the daemon log (#318).
56+
private func log(_ error: some Error, operation: String) {
57+
if let papyrusError = error as? PapyrusError, papyrusError.response?.statusCode == 404 { return }
58+
fputs("lyra: LRCLIB \(operation) failed: \(error)\n", stderr)
3059
}
3160
}

Sources/MetadataDataSource/API/OpenAICompatibleAPI.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ public protocol OpenAICompatible {
1010
}
1111

1212
extension OpenAICompatible {
13-
public static func provider(for config: AIEndpoint) -> Provider {
13+
/// Builds the auth-attaching Provider. The session comes from the caller's
14+
/// `ScopedAPISession` in production (#318); the `nil` default gives a
15+
/// throwaway ephemeral session for construction-only tests that never make a
16+
/// network call.
17+
public static func provider(for config: AIEndpoint, urlSession: URLSession? = nil) -> Provider {
1418
let endpoint =
1519
config.endpoint.hasSuffix("/")
1620
? String(config.endpoint.dropLast())
1721
: config.endpoint
18-
return Provider(baseURL: endpoint).modifyRequests { req in
22+
return Provider(baseURL: endpoint, urlSession: urlSession ?? URLSession(configuration: .ephemeral)).modifyRequests { req in
1923
req.addHeader("Authorization", value: "Bearer \(config.apiKey)")
2024
}
2125
}

Sources/MetadataDataSource/LLMMetadataDataSourceImpl.swift

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,31 @@ import Dependencies
22
import Domain
33
import Foundation
44
@preconcurrency import Papyrus
5+
import ScopedAPISession
56

67
public struct LLMMetadataDataSourceImpl {
78
@Dependency(\.configDataSource) private var configDataSource
8-
private let apiFactory: @Sendable (AIEndpoint) -> any OpenAICompatible
9+
private let sessionFactory: @Sendable (AIEndpoint) -> ScopedAPISession<any OpenAICompatible>
910

1011
public init() {
11-
self.init { config in
12-
OpenAICompatibleAPI(provider: OpenAICompatibleAPI.provider(for: config))
13-
}
12+
self.init(sessionFactory: { config in
13+
ScopedAPISession(timeout: 60) {
14+
OpenAICompatibleAPI(provider: OpenAICompatibleAPI.provider(for: config, urlSession: $0))
15+
}
16+
})
1417
}
1518

19+
// Test seam: inject a plain API factory (a stub). The scoped session it is
20+
// wrapped in creates and tears down a throwaway ephemeral session the stub
21+
// ignores, so the production call path is exercised without a network call.
1622
init(apiFactory: @escaping @Sendable (AIEndpoint) -> any OpenAICompatible) {
17-
self.apiFactory = apiFactory
23+
self.init(sessionFactory: { config in
24+
ScopedAPISession(timeout: 60) { _ in apiFactory(config) }
25+
})
26+
}
27+
28+
init(sessionFactory: @escaping @Sendable (AIEndpoint) -> ScopedAPISession<any OpenAICompatible>) {
29+
self.sessionFactory = sessionFactory
1830
}
1931
}
2032

@@ -32,13 +44,12 @@ extension LLMMetadataDataSourceImpl: MetadataDataSource {
3244

3345
extension LLMMetadataDataSourceImpl {
3446
fileprivate func callAPI(config: AIEndpoint, rawTitle: String, rawArtist: String) async -> ExtractedMetadata? {
35-
let api = apiFactory(config)
3647
let request = MetadataExtractionPrompt(rawTitle: rawTitle, rawArtist: rawArtist)
3748
.request(model: config.model)
3849

3950
let response: ChatCompletionResponse
4051
do {
41-
response = try await api.chatCompletion(request: request)
52+
response = try await sessionFactory(config).withAPI { try await $0.chatCompletion(request: request) }
4253
} catch {
4354
fputs("lyra: AI extraction failed: \(error)\n", stderr)
4455
return nil

Sources/MetadataDataSource/MusicBrainzMetadataDataSourceImpl.swift

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
import Domain
22
import Foundation
33
@preconcurrency import Papyrus
4+
import ScopedAPISession
45

56
public struct MusicBrainzMetadataDataSourceImpl {
6-
private let api: any MusicBrainz
7+
private let apiSession: ScopedAPISession<any MusicBrainz>
78

89
public init() {
9-
self.init(api: MusicBrainzAPI(provider: Provider(baseURL: MusicBrainzAPI.baseURL)))
10+
self.init(
11+
apiSession: ScopedAPISession(timeout: 10) {
12+
MusicBrainzAPI(provider: Provider(baseURL: MusicBrainzAPI.baseURL, urlSession: $0))
13+
}
14+
)
1015
}
1116

1217
init(api: any MusicBrainz) {
13-
self.api = api
18+
self.init(apiSession: ScopedAPISession(timeout: 10) { _ in api })
19+
}
20+
21+
init(apiSession: ScopedAPISession<any MusicBrainz>) {
22+
self.apiSession = apiSession
1423
}
1524
}
1625

17-
// Safe: `api` is set at init and never mutated; Papyrus's Provider is configured during construction only.
18-
extension MusicBrainzMetadataDataSourceImpl: @unchecked Sendable {}
26+
extension MusicBrainzMetadataDataSourceImpl: Sendable {}
1927

2028
extension MusicBrainzMetadataDataSourceImpl: MetadataDataSource {
2129
public func resolve(track: Track) async -> [MusicBrainzMetadata] {
@@ -26,10 +34,14 @@ extension MusicBrainzMetadataDataSourceImpl: MetadataDataSource {
2634

2735
for (title, artist) in [(normalized, normalizedArtist), (normalized, nil as String?)] {
2836
let query = MusicBrainzAPI.luceneQuery(title: title, artist: artist, duration: nil)
29-
guard let response = try? await api.searchRecording(query: query, fmt: "json", limit: 5) else { continue }
30-
let candidates = matchRecordings(from: response, regex: regex)
31-
guard !candidates.isEmpty else { continue }
32-
return candidates
37+
do {
38+
let response = try await apiSession.withAPI { try await $0.searchRecording(query: query, fmt: "json", limit: 5) }
39+
let candidates = matchRecordings(from: response, regex: regex)
40+
guard !candidates.isEmpty else { continue }
41+
return candidates
42+
} catch {
43+
fputs("lyra: MusicBrainz search failed: \(error)\n", stderr)
44+
}
3345
}
3446

3547
return []
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Foundation
2+
3+
/// Runs a single API call against a fresh, per-call ephemeral `URLSession` that is
4+
/// finished and invalidated when the body completes (#318).
5+
///
6+
/// A process-lifetime `URLSession` can silently go stale after sleep/wake or
7+
/// network changes, leaving requests failing until the daemon restarts. Building
8+
/// an ephemeral session per call sidesteps that, and invalidating it right after
9+
/// the call releases its resources so per-call sessions don't accumulate in the
10+
/// long-lived daemon.
11+
///
12+
/// The session is kept **function-local** inside ``withAPI(_:)`` — not stored
13+
/// behind a class that invalidates in `deinit`. A stored-session/`deinit` design
14+
/// is fragile: once the caller copies the wrapped client out, ARC may release the
15+
/// owner (and invalidate the session) before the request is even issued. Keeping
16+
/// the session local to the call, with a `defer`-based invalidation, makes the
17+
/// lifetime unambiguous and the invalidation point explicit.
18+
///
19+
/// `makeAPI` receives the scoped session and builds the (Papyrus) client bound to
20+
/// it; every per-API difference — base URL, auth headers, request shaping — lives
21+
/// in that closure, so this type stays API-agnostic. Only the request timeout,
22+
/// which legitimately varies per service, is a stored parameter.
23+
public struct ScopedAPISession<API> {
24+
private let timeout: TimeInterval
25+
private let makeAPI: (URLSession) -> API
26+
27+
public init(timeout: TimeInterval, makeAPI: @escaping (URLSession) -> API) {
28+
self.timeout = timeout
29+
self.makeAPI = makeAPI
30+
}
31+
32+
public func withAPI<T>(_ body: (API) async throws -> T) async rethrows -> T {
33+
let configuration = URLSessionConfiguration.ephemeral
34+
configuration.timeoutIntervalForRequest = timeout
35+
let session = URLSession(configuration: configuration)
36+
defer { session.finishTasksAndInvalidate() }
37+
return try await body(makeAPI(session))
38+
}
39+
}
40+
41+
// Safe: value type holding only an immutable timeout and an immutable factory
42+
// closure; `withAPI` creates and tears down its own session with no shared state.
43+
extension ScopedAPISession: @unchecked Sendable {}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.21.0
1+
2.21.1

Tests/LyricsDataSourceTests/LyricsDataSourceImplTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Domain
22
import Foundation
3+
@preconcurrency import Papyrus
34
import Testing
45

56
@testable import LyricsDataSource
@@ -68,6 +69,27 @@ struct LyricsDataSourceImplTests {
6869
#expect(result == nil)
6970
}
7071

72+
@Test("get returns nil on HTTP 404 — LRCLIB's regular no-lyrics answer")
73+
func getReturnsNilOn404() async {
74+
let dataSource = LyricsDataSourceImpl(
75+
api: LRCLibStub(get: { _, _, _ in
76+
throw PapyrusError(
77+
"Unsuccessful status code: 404.",
78+
nil,
79+
TestResponse(
80+
request: URLRequest(url: URL(string: "https://lrclib.net/api/get")!),
81+
statusCode: 404,
82+
body: Data()
83+
)
84+
)
85+
})
86+
)
87+
88+
let result = await dataSource.get(title: "Song", artist: "Artist", duration: nil)
89+
90+
#expect(result == nil)
91+
}
92+
7193
@Test("get forwards arguments verbatim to the API")
7294
func getForwardsArguments() async {
7395
let captured = ArgumentRecorder<(String, String, Int?)>()

Tests/MetadataDataSourceTests/LLMMetadataDataSourceImplTests.swift

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,30 +19,24 @@ struct LLMMetadataDataSourceImplTests {
1919
#expect(result.isEmpty)
2020
}
2121

22-
@Test("Default init() wires the production OpenAICompatibleAPI factory")
22+
@Test("Default init() wires the production OpenAICompatibleAPI factory (degrades to empty on refused port)")
2323
func defaultFactoryWiresProductionAPI() async throws {
2424
// Cover the default `apiFactory` closure body which builds
2525
// `OpenAICompatibleAPI(provider: OpenAICompatibleAPI.provider(for:))`.
26-
// We route URLSession.shared through URLProtocolMock so the production
27-
// factory's resulting API hits a stubbed endpoint instead of the network.
28-
URLProtocolMock.register(host: "llm.invalid") { _ in
29-
let body = #"{"choices":[{"message":{"content":"{\"title\":\"Brave Shine\",\"artist\":\"Aimer\"}"}}]}"#
30-
return (
31-
HTTPURLResponse(url: URL(string: "http://llm.invalid/")!, statusCode: 200, httpVersion: nil, headerFields: nil)!,
32-
Data(body.utf8)
33-
)
34-
}
35-
URLProtocol.registerClass(URLProtocolMock.self)
36-
defer { URLProtocolMock.unregister(host: "llm.invalid") }
37-
38-
let config = try makeLLMConfig(endpoint: "http://llm.invalid")
26+
// The provider owns a private ephemeral URLSession (#318), so a globally
27+
// registered URLProtocol mock cannot intercept it; point the endpoint at
28+
// a refused local port instead — same pattern as
29+
// OpenAICompatibleHealthCheckDefaultBackendTests — and assert the
30+
// graceful empty fallback. The success path through the API is covered
31+
// by the apiFactory-injected resolve tests.
32+
let config = try makeLLMConfig(endpoint: "http://127.0.0.1:1")
3933
let result = await withDependencies {
4034
$0.configDataSource = StubConfigDataSource(loadResult: config)
4135
} operation: {
4236
await LLMMetadataDataSourceImpl().resolve(track: Track(title: "x", artist: "y"))
4337
}
4438

45-
#expect(result == [Track(title: "Brave Shine", artist: "Aimer")])
39+
#expect(result.isEmpty)
4640
}
4741
}
4842

0 commit comments

Comments
 (0)