Fix CallbackSource registrations outliving their sources

Callback-based sound sources are kept alive in a static registry while the
C side may still invoke their trampoline, but nothing ever removed them:
every callback source leaked its wrapper and closure permanently, and a
source retained only by a transient Channel.default wrapper relied on that
leak for safety. The registry is now the single source of truth and is
purged on Sound.removeSource, Channel.removeSource, and when an owned
channel is freed (its sources can no longer be pulled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 07:09:30 +02:00
co-authored by Claude Fable 5
parent 8cf0715cdc
commit 61fb06e45e
3 changed files with 18 additions and 89 deletions
+9 -2
View File
@@ -66,10 +66,17 @@ extension Sound {
let callback: Callback
/// Sources created through the top-level `Sound.addSource` are kept
/// alive here until removed with `Sound.removeSource`.
/// Every callback source is kept alive here while the C side may
/// still invoke its trampoline: from creation until it is removed
/// with `Sound.removeSource`/`Channel.removeSource`, or until its
/// owning channel is freed.
nonisolated(unsafe) static var live: [CallbackSource] = []
/// Releases the registration added by `adopt(pointer:)`.
static func release(_ source: Source) {
live.removeAll { $0 === source }
}
init(callback: @escaping Callback) {
self.callback = callback
super.init(pointer: nil, isOwned: false)