Fixed some wrapper lifetime and leak issues found in PlayDate bindings target.

This commit is contained in:
2026-07-25 10:28:24 +02:00
parent 06bd50ea83
commit 1d9504c225
6 changed files with 55 additions and 19 deletions
+10 -1
View File
@@ -118,10 +118,19 @@ extension Graphics {
} }
/// The player used for the stream's audio track. Owned by the stream. /// The player used for the stream's audio track. Owned by the stream.
/// The same wrapper is returned on every access, so callbacks
/// registered on it stay valid for the stream's lifetime.
public var filePlayer: Sound.FilePlayer? { public var filePlayer: Sound.FilePlayer? {
guard let player = streamAPI.pointee.getFilePlayer.unsafelyUnwrapped(pointer) else { return nil } guard let player = streamAPI.pointee.getFilePlayer.unsafelyUnwrapped(pointer) else { return nil }
return Sound.FilePlayer(pointer: player, isOwned: false) if let cached = cachedFilePlayer, cached.pointer == player {
return cached
} }
let wrapper = Sound.FilePlayer(pointer: player, isOwned: false)
cachedFilePlayer = wrapper
return wrapper
}
private var cachedFilePlayer: Sound.FilePlayer?
/// The player used for the stream's video track. Owned by the stream. /// The player used for the stream's video track. Owned by the stream.
public var videoPlayer: VideoPlayer? { public var videoPlayer: VideoPlayer? {
+6
View File
@@ -116,6 +116,9 @@ extension JSON {
} }
} }
guard ok else { guard ok else {
// A completed root container may already have been written to
// outval before the failure; consume it so its box is not leaked.
_ = convert(outval)
throw decodeError(context) throw decodeError(context)
} }
return convert(outval) return convert(outval)
@@ -145,6 +148,9 @@ extension JSON {
} }
} }
guard ok else { guard ok else {
// A completed root container may already have been written to
// outval before the failure; consume it so its box is not leaked.
_ = convert(outval)
throw decodeError(context) throw decodeError(context)
} }
return convert(outval) return convert(outval)
+7 -7
View File
@@ -70,21 +70,21 @@ extension Network {
} }
/// Turns the wifi radio on or off. The completion receives `nil` on /// Turns the wifi radio on or off. The completion receives `nil` on
/// success. /// success. Completions of overlapping calls are delivered in call order.
public static func setEnabled(_ enabled: Bool, completion: ((NetError?) -> Void)? = nil) { public static func setEnabled(_ enabled: Bool, completion: ((NetError?) -> Void)? = nil) {
setEnabledCompletion = completion if let completion {
if completion != nil { setEnabledCompletions.append(completion)
networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, { error in networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, { error in
let completion = Network.setEnabledCompletion guard !Network.setEnabledCompletions.isEmpty else { return }
Network.setEnabledCompletion = nil let completion = Network.setEnabledCompletions.removeFirst()
completion?(Network.optionalError(error)) completion(Network.optionalError(error))
}) })
} else { } else {
networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, nil) networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, nil)
} }
} }
nonisolated(unsafe) private static var setEnabledCompletion: ((NetError?) -> Void)? nonisolated(unsafe) private static var setEnabledCompletions: [(NetError?) -> Void] = []
/// Requests permission to connect to `server`. Shared by HTTP and TCP. /// Requests permission to connect to `server`. Shared by HTTP and TCP.
fileprivate static func requestAccess( fileprivate static func requestAccess(
+19 -4
View File
@@ -207,11 +207,13 @@ extension Sound {
} }
/// The default channel, which sources are added to unless otherwise /// The default channel, which sources are added to unless otherwise
/// specified. /// specified. A single shared wrapper, so resources retained through
public static var `default`: Channel { /// it (sources, effects, modulators) stay alive.
public static var `default`: Channel { defaultChannel }
nonisolated(unsafe) private static let defaultChannel =
Channel(pointer: snd.pointee.getDefaultChannel.unsafelyUnwrapped().unsafelyUnwrapped, Channel(pointer: snd.pointee.getDefaultChannel.unsafelyUnwrapped().unsafelyUnwrapped,
isOwned: false) isOwned: false)
}
nonisolated(unsafe) private static var addedChannels: [Channel] = [] nonisolated(unsafe) private static var addedChannels: [Channel] = []
@@ -246,8 +248,12 @@ extension Sound {
@discardableResult @discardableResult
public func removeSource(_ source: Source) -> Bool { public func removeSource(_ source: Source) -> Bool {
let removed = Channel.api.pointee.removeSource.unsafelyUnwrapped(pointer, source.pointer) != 0 let removed = Channel.api.pointee.removeSource.unsafelyUnwrapped(pointer, source.pointer) != 0
// Only drop the retentions if the source was actually on this
// channel; otherwise another channel may still be pulling it.
if removed {
retainedSources.removeAll { $0 === source } retainedSources.removeAll { $0 === source }
CallbackSource.release(source) CallbackSource.release(source)
}
return removed return removed
} }
@@ -322,12 +328,21 @@ extension Sound {
} }
/// The channel's output as a source, for feeding into another channel. /// The channel's output as a source, for feeding into another channel.
/// The same wrapper is returned on every access, so callbacks
/// registered on it stay valid for the channel's lifetime.
public var outputAsSource: Source? { public var outputAsSource: Source? {
guard let source = Channel.api.pointee.getOutputAsSource.unsafelyUnwrapped(pointer) else { guard let source = Channel.api.pointee.getOutputAsSource.unsafelyUnwrapped(pointer) else {
return nil return nil
} }
return Source(pointer: source, isOwned: false) if let cached = cachedOutputSource, cached.pointer == source {
return cached
} }
let wrapper = Source(pointer: source, isOwned: false)
cachedOutputSource = wrapper
return wrapper
}
private var cachedOutputSource: Source?
private func retain(_ modulator: SignalValue?) { private func retain(_ modulator: SignalValue?) {
if let modulator, !retainedModulators.contains(where: { $0 === modulator }) { if let modulator, !retainedModulators.contains(where: { $0 === modulator }) {
+5
View File
@@ -55,8 +55,13 @@ public final class Sprite {
init(pointer: OpaquePointer, isOwned: Bool) { init(pointer: OpaquePointer, isOwned: Bool) {
self.pointer = pointer self.pointer = pointer
self.isOwned = isOwned self.isOwned = isOwned
// Transient wrappers for sprites created outside the binding must not
// store a back-reference: it would dangle once the wrapper is
// deallocated, and only owned wrappers clear it in `deinit`.
if isOwned {
spriteAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque()) spriteAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque())
} }
}
/// Allocates a new sprite. /// Allocates a new sprite.
public convenience init() { public convenience init() {
+5 -4
View File
@@ -71,16 +71,17 @@ extension String {
#if hasFeature(Embedded) && !os(macOS) #if hasFeature(Embedded) && !os(macOS)
/// The Embedded Swift runtime allocates through `posix_memalign(3)`, which /// The Embedded Swift runtime allocates through `posix_memalign(3)`, which
/// the Playdate device C library does not provide. Memory comes from /// the Playdate device C library does not provide. Memory comes from
/// `malloc`, which the SDK's setup code routes to the firmware allocator; /// `malloc`, which the SDK's setup code routes to the firmware allocator.
/// that allocator hands out sufficiently aligned blocks, so alignment is /// The pointer is later released with plain `free`, so it cannot be offset
/// only asserted, not adjusted. /// to adjust alignment; the firmware allocator's natural alignment has to
/// satisfy the request, which the precondition asserts.
@_cdecl("posix_memalign") @_cdecl("posix_memalign")
public func posix_memalign( public func posix_memalign(
_ memptr: UnsafeMutablePointer<UnsafeMutableRawPointer?>, _ memptr: UnsafeMutablePointer<UnsafeMutableRawPointer?>,
_ alignment: Int, _ alignment: Int,
_ size: Int _ size: Int
) -> CInt { ) -> CInt {
guard let allocation = malloc(size + alignment - 1) else { fatalError() } guard let allocation = malloc(size) else { fatalError() }
precondition(Int(bitPattern: allocation) % alignment == 0) precondition(Int(bitPattern: allocation) % alignment == 0)
memptr.pointee = allocation memptr.pointee = allocation
return 0 return 0