From 61fb06e45e9209f107848bf2efe0f62914acb93d Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 25 Jul 2026 07:09:30 +0200 Subject: [PATCH] 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 --- .../xcshareddata/xcschemes/play-date.xcscheme | 86 ------------------- Sources/PlayDate/Sound.swift | 10 ++- Sources/PlayDate/SoundSource.swift | 11 ++- 3 files changed, 18 insertions(+), 89 deletions(-) delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/play-date.xcscheme diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/play-date.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/play-date.xcscheme deleted file mode 100644 index 4f0451d..0000000 --- a/.swiftpm/xcode/xcshareddata/xcschemes/play-date.xcscheme +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Sources/PlayDate/Sound.swift b/Sources/PlayDate/Sound.swift index 87dff3c..fcb7b08 100644 --- a/Sources/PlayDate/Sound.swift +++ b/Sources/PlayDate/Sound.swift @@ -78,7 +78,9 @@ extension Sound { /// Removes a source from its channel. @discardableResult public static func removeSource(_ source: Source) -> Bool { - snd.removeSource.unsafelyUnwrapped(source.pointer) != 0 + let removed = snd.removeSource.unsafelyUnwrapped(source.pointer) != 0 + CallbackSource.release(source) + return removed } /// Sets a callback that records microphone input. Return `false` from the @@ -196,6 +198,11 @@ extension Sound { deinit { if isOwned { Channel.api.freeChannel.unsafelyUnwrapped(pointer) + // The freed channel no longer pulls its callback sources, so + // their trampoline registrations can be released too. + for source in retainedSources where source is CallbackSource { + CallbackSource.release(source) + } } } @@ -240,6 +247,7 @@ extension Sound { public func removeSource(_ source: Source) -> Bool { let removed = Channel.api.removeSource.unsafelyUnwrapped(pointer, source.pointer) != 0 retainedSources.removeAll { $0 === source } + CallbackSource.release(source) return removed } diff --git a/Sources/PlayDate/SoundSource.swift b/Sources/PlayDate/SoundSource.swift index ebbda1b..848f761 100644 --- a/Sources/PlayDate/SoundSource.swift +++ b/Sources/PlayDate/SoundSource.swift @@ -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)