fix(sdk): generation-gate + .error state in engine-config recovery#101
Conversation
PR #99 re-review (Greptile, 3 P1s): handleAudioEngineConfigurationChange's recovery Task was missing the supersession discipline that resumeAfterInterruption() already uses. Two issues, same fixes as resume: - Generation gate: snapshot playGeneration (with stationToRecover) before restartEngine(); if a host stop()/play() bumps it during the restart, abandon the recovery instead of calling play(stationToRecover) — which would override the host's explicit station switch / revive a stopped station. - State on failure: if restartEngine() throws, publish state = .error(...) (gated on generation) so a host driving UI from $state isn't left on a stale .playing with dead audio. Previously the failure only went to Sentry. The recovery path itself calls restartEngine()/play() (real CoreAudio / resolves .shared), so per the no-CoreAudio test rule it stays inspection-verified; the synchronous guard (!isSuspended/isPlaying/stationId) is covered by the existing engine-config tests.
Greptile SummaryPorts the supersession-gate and
Confidence Score: 4/5Safe to merge after fixing the missing The block-based observer token is stored in Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift — specifically the Important Files Changed
Sequence DiagramsequenceDiagram
participant NC as NotificationCenter
participant H as handleAudioEngineConfigurationChange
participant T as Task @MainActor
participant M as mainMixer
participant P as play()
participant S as state / errorReporter
NC->>H: AVAudioEngineConfigurationChange (on main queue)
H->>H: guard !isSuspended, isPlaying, stationId
H->>H: "let generation = playGeneration"
H->>T: "spawn Task @MainActor"
T->>M: restartEngine()
alt restartEngine throws
M-->>T: throw error
T->>T: "if generation == playGeneration"
T->>S: "state = .error(...)"
T->>S: reportError(...)
T->>T: return
else restartEngine succeeds
M-->>T: ok
T->>T: "guard generation == playGeneration"
alt generation stale
T->>T: return (silent abandon)
else generation current
T->>P: play(stationId: stationToRecover)
alt play throws
P-->>T: throw error
T->>S: reportError(...) no generation guard
else play succeeds
P-->>T: ok
end
end
end
Reviews (2): Last reviewed commit: "fix(sdk): deliver engine-config recovery..." | Re-trigger Greptile |
… on generation PR #101 review (Greptile P2s): - Thread-safety: AVAudioEngineConfigurationChange fires on an unspecified thread and @objc dispatch bypasses @mainactor, so the handler's synchronous @mainactor reads (isSuspended/isPlaying/stationId/playGeneration) could race. Register the observer block-based with queue: .main and assumeIsolated, so the handler runs on the main actor; drop @objc (no longer a #selector target). - Sentry noise: the restart-failure path now reports only when still current (generation == playGeneration) — a superseded recovery is moot, matching the state gate. Not addressed (tracked follow-up): a PlayolaMainMixing protocol to inject a mock mixer would unblock unit-testing the recovery Task's .error / generation-stale branches (currently inspection-verified, like all restartEngine()/play() paths). That's the same .shared-coupling follow-up already noted for SpinPlayer; it touches SpinPlayer too and is out of scope for this fix.
|
Re: the outside-diff note about a |
|
@greptile review this |
Addresses the 3 P1 threads from the 0.20.0 release PR (#99) re-review — all on
handleAudioEngineConfigurationChange. They're the same supersession + state-consistency patterns already applied toresumeAfterInterruption(); this carries them into the engine-recovery handler.Fixes
Taskcalledplay(stationId: stationToRecover)afterrestartEngine()with no supersession check. A hoststop()/play(other)during the ~100–200ms restart would be clobbered: the recovery would revive a stopped station or override the host's explicit station switch. Now snapshotsplayGenerationwithstationToRecoverandguard generation == playGenerationbeforeplay()— identical toresumeAfterInterruption()..erroron restart failure (line 963 thread). ArestartEngine()throw previously only went to Sentry;statestayed.playingwith dead audio. Now publishesstate = .error(...)(gated on generation) so a host driving UI from$statesees the failure — mirrorsresumeAfterInterruption().Testing
swift test: 99 tests pass (local +CI=true). Build / SwiftLint strict / swift-format clean.restartEngine()/play()(real CoreAudio / resolves.shared), so per the no-CoreAudio test rule it's inspection-verified; the synchronous guard is covered by the existing engine-config tests.Merges to
develop; release PR #99 (develop→main) picks it up before the0.20.0tag.🤖 Generated with Claude Code