Fixed the double-free in the "deinit" method of the Sound Effect type in the Playdate bindings target and cover ownership.
This commit is contained in:
@@ -38,10 +38,16 @@ extension Sound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
if isOwned {
|
// Subclasses free the C object in their own deinit with the
|
||||||
effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer)
|
// subsystem's type-specific free (freeDelayLine, freeOverdrive,
|
||||||
|
// ...); freeing here as well would double-free. The base class
|
||||||
|
// owns only the custom-processor effects it creates itself.
|
||||||
|
if let processorBox {
|
||||||
|
if isOwned {
|
||||||
|
effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer)
|
||||||
|
}
|
||||||
|
processorBox.release()
|
||||||
}
|
}
|
||||||
processorBox?.release()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wet/dry mix: 1 is fully processed, 0 fully dry.
|
/// The wet/dry mix: 1 is fully processed, 0 fully dry.
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ enum Mock {
|
|||||||
nonisolated(unsafe) static let spriteAPI = UnsafeMutablePointer<playdate_sprite>.allocate(capacity: 1)
|
nonisolated(unsafe) static let spriteAPI = UnsafeMutablePointer<playdate_sprite>.allocate(capacity: 1)
|
||||||
nonisolated(unsafe) static let soundAPI = UnsafeMutablePointer<playdate_sound>.allocate(capacity: 1)
|
nonisolated(unsafe) static let soundAPI = UnsafeMutablePointer<playdate_sound>.allocate(capacity: 1)
|
||||||
nonisolated(unsafe) static let channelAPI = UnsafeMutablePointer<playdate_sound_channel>.allocate(capacity: 1)
|
nonisolated(unsafe) static let channelAPI = UnsafeMutablePointer<playdate_sound_channel>.allocate(capacity: 1)
|
||||||
|
nonisolated(unsafe) static let synthAPI = UnsafeMutablePointer<playdate_sound_synth>.allocate(capacity: 1)
|
||||||
|
nonisolated(unsafe) static let soundEffectAPI = UnsafeMutablePointer<playdate_sound_effect>.allocate(capacity: 1)
|
||||||
|
nonisolated(unsafe) static let lfoAPI = UnsafeMutablePointer<playdate_sound_lfo>.allocate(capacity: 1)
|
||||||
|
nonisolated(unsafe) static let delayLineAPI = UnsafeMutablePointer<playdate_sound_effect_delayline>.allocate(capacity: 1)
|
||||||
nonisolated(unsafe) static let fileAPI = UnsafeMutablePointer<playdate_file>.allocate(capacity: 1)
|
nonisolated(unsafe) static let fileAPI = UnsafeMutablePointer<playdate_file>.allocate(capacity: 1)
|
||||||
nonisolated(unsafe) static let jsonAPI = UnsafeMutablePointer<playdate_json>.allocate(capacity: 1)
|
nonisolated(unsafe) static let jsonAPI = UnsafeMutablePointer<playdate_json>.allocate(capacity: 1)
|
||||||
nonisolated(unsafe) static let apiStruct = UnsafeMutablePointer<PlaydateAPI>.allocate(capacity: 1)
|
nonisolated(unsafe) static let apiStruct = UnsafeMutablePointer<PlaydateAPI>.allocate(capacity: 1)
|
||||||
@@ -39,6 +43,19 @@ enum Mock {
|
|||||||
nonisolated(unsafe) static var spriteUpdateCallback: (@convention(c) (OpaquePointer?) -> Void)?
|
nonisolated(unsafe) static var spriteUpdateCallback: (@convention(c) (OpaquePointer?) -> Void)?
|
||||||
nonisolated(unsafe) static var audioCallback: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer<Int16>?, UnsafeMutablePointer<Int16>?, Int32) -> Int32)?
|
nonisolated(unsafe) static var audioCallback: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer<Int16>?, UnsafeMutablePointer<Int16>?, Int32) -> Int32)?
|
||||||
nonisolated(unsafe) static var audioContext: UnsafeMutableRawPointer?
|
nonisolated(unsafe) static var audioContext: UnsafeMutableRawPointer?
|
||||||
|
/// Generator registrations per synth. The OS deallocs a generator's
|
||||||
|
/// userdata when the generator is replaced or its synth is freed; the
|
||||||
|
/// mock mirrors that contract.
|
||||||
|
struct SynthGenerator {
|
||||||
|
var render: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer<Int32>?, UnsafeMutablePointer<Int32>?, Int32, UInt32, Int32) -> Int32)?
|
||||||
|
var dealloc: (@convention(c) (UnsafeMutableRawPointer?) -> Void)?
|
||||||
|
var userdata: UnsafeMutableRawPointer?
|
||||||
|
}
|
||||||
|
nonisolated(unsafe) static var synthGenerators: [OpaquePointer: SynthGenerator] = [:]
|
||||||
|
/// The last custom-effect proc handed to `newEffect`, plus each
|
||||||
|
/// effect's userdata, for re-invocation by tests.
|
||||||
|
nonisolated(unsafe) static var effectProc: (@convention(c) (OpaquePointer?, UnsafeMutablePointer<Int32>?, UnsafeMutablePointer<Int32>?, Int32, Int32) -> Int32)?
|
||||||
|
nonisolated(unsafe) static var effectUserdata: [OpaquePointer: UnsafeMutableRawPointer] = [:]
|
||||||
|
|
||||||
static func record(_ event: String) {
|
static func record(_ event: String) {
|
||||||
events.append(event)
|
events.append(event)
|
||||||
@@ -72,6 +89,9 @@ enum Mock {
|
|||||||
spriteUpdateCallback = nil
|
spriteUpdateCallback = nil
|
||||||
audioCallback = nil
|
audioCallback = nil
|
||||||
audioContext = nil
|
audioContext = nil
|
||||||
|
synthGenerators = [:]
|
||||||
|
effectProc = nil
|
||||||
|
effectUserdata = [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func install() {
|
private static func install() {
|
||||||
@@ -282,6 +302,86 @@ enum Mock {
|
|||||||
Mock.record("channelRemoveSource")
|
Mock.record("channelRemoveSource")
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
channelAPI.pointee.addSource = { _, _ in
|
||||||
|
Mock.record("channelAddSource")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
soundAPI.pointee.synth = UnsafePointer(synthAPI)
|
||||||
|
synthAPI.initialize(to: playdate_sound_synth())
|
||||||
|
synthAPI.pointee.newSynth = {
|
||||||
|
Mock.record("newSynth")
|
||||||
|
return Mock.fakePointer()
|
||||||
|
}
|
||||||
|
synthAPI.pointee.freeSynth = { synth in
|
||||||
|
Mock.record("freeSynth")
|
||||||
|
// Freeing a synth deallocs its generator's userdata.
|
||||||
|
if let synth, let generator = Mock.synthGenerators.removeValue(forKey: synth) {
|
||||||
|
generator.dealloc?(generator.userdata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synthAPI.pointee.setGenerator = { synth, _, render, _, _, _, dealloc, _, userdata in
|
||||||
|
Mock.record("setGenerator")
|
||||||
|
guard let synth else { return }
|
||||||
|
// Replacing a generator deallocs the previous one first.
|
||||||
|
if let previous = Mock.synthGenerators[synth] {
|
||||||
|
previous.dealloc?(previous.userdata)
|
||||||
|
}
|
||||||
|
Mock.synthGenerators[synth] = SynthGenerator(render: render, dealloc: dealloc,
|
||||||
|
userdata: userdata)
|
||||||
|
}
|
||||||
|
synthAPI.pointee.setFrequencyModulator = { _, _ in
|
||||||
|
Mock.record("setFrequencyModulator")
|
||||||
|
}
|
||||||
|
|
||||||
|
soundAPI.pointee.lfo = UnsafePointer(lfoAPI)
|
||||||
|
lfoAPI.initialize(to: playdate_sound_lfo())
|
||||||
|
lfoAPI.pointee.newLFO = { _ in
|
||||||
|
Mock.record("newLFO")
|
||||||
|
return Mock.fakePointer()
|
||||||
|
}
|
||||||
|
lfoAPI.pointee.freeLFO = { _ in
|
||||||
|
Mock.record("freeLFO")
|
||||||
|
}
|
||||||
|
|
||||||
|
soundAPI.pointee.effect = UnsafePointer(soundEffectAPI)
|
||||||
|
soundEffectAPI.initialize(to: playdate_sound_effect())
|
||||||
|
soundEffectAPI.pointee.newEffect = { proc, userdata in
|
||||||
|
Mock.record("newEffect")
|
||||||
|
let pointer = Mock.fakePointer()
|
||||||
|
Mock.effectProc = proc
|
||||||
|
if let userdata {
|
||||||
|
Mock.effectUserdata[pointer] = userdata
|
||||||
|
}
|
||||||
|
return pointer
|
||||||
|
}
|
||||||
|
soundEffectAPI.pointee.freeEffect = { effect in
|
||||||
|
Mock.record("freeEffect")
|
||||||
|
if let effect {
|
||||||
|
Mock.effectUserdata[effect] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
soundEffectAPI.pointee.getUserdata = { effect in
|
||||||
|
guard let effect else { return nil }
|
||||||
|
return Mock.effectUserdata[effect]
|
||||||
|
}
|
||||||
|
|
||||||
|
soundEffectAPI.pointee.delayline = UnsafePointer(delayLineAPI)
|
||||||
|
delayLineAPI.initialize(to: playdate_sound_effect_delayline())
|
||||||
|
delayLineAPI.pointee.newDelayLine = { _, _ in
|
||||||
|
Mock.record("newDelayLine")
|
||||||
|
return Mock.fakePointer()
|
||||||
|
}
|
||||||
|
delayLineAPI.pointee.freeDelayLine = { _ in
|
||||||
|
Mock.record("freeDelayLine")
|
||||||
|
}
|
||||||
|
delayLineAPI.pointee.addTap = { _, _ in
|
||||||
|
Mock.record("addTap")
|
||||||
|
return Mock.fakePointer()
|
||||||
|
}
|
||||||
|
delayLineAPI.pointee.freeTap = { _ in
|
||||||
|
Mock.record("freeTap")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - File
|
// MARK: - File
|
||||||
|
|||||||
@@ -182,6 +182,148 @@ struct WrapperTests {
|
|||||||
#expect(Sound.CallbackSource.live.count == baseline)
|
#expect(Sound.CallbackSource.live.count == baseline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Sound ownership graph
|
||||||
|
|
||||||
|
@Test func channelRetainsAddedSourceUntilRemoved() {
|
||||||
|
let channel = Sound.Channel()
|
||||||
|
weak var weakSynth: Sound.Synth?
|
||||||
|
do {
|
||||||
|
let synth = Sound.Synth()
|
||||||
|
weakSynth = synth
|
||||||
|
channel.addSource(synth)
|
||||||
|
}
|
||||||
|
#expect(weakSynth != nil) // the channel keeps the source alive
|
||||||
|
#expect(Mock.eventCount("freeSynth") == 0)
|
||||||
|
|
||||||
|
if let synth = weakSynth {
|
||||||
|
channel.removeSource(synth)
|
||||||
|
}
|
||||||
|
#expect(weakSynth == nil)
|
||||||
|
#expect(Mock.eventCount("freeSynth") == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func synthRetainsItsModulatorWhileAlive() {
|
||||||
|
weak var weakLFO: Sound.LFO?
|
||||||
|
do {
|
||||||
|
let synth = Sound.Synth()
|
||||||
|
do {
|
||||||
|
let lfo = Sound.LFO()
|
||||||
|
weakLFO = lfo
|
||||||
|
synth.frequencyModulator = lfo
|
||||||
|
}
|
||||||
|
#expect(weakLFO != nil) // the synth retains the modulator
|
||||||
|
#expect(Mock.eventCount("freeLFO") == 0)
|
||||||
|
}
|
||||||
|
#expect(weakLFO == nil)
|
||||||
|
#expect(Mock.eventCount("freeLFO") == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func synthGeneratorDispatchesAndIsReleasedWithTheSynth() {
|
||||||
|
final class Token {}
|
||||||
|
weak var weakToken: Token?
|
||||||
|
var rendered = 0
|
||||||
|
|
||||||
|
do {
|
||||||
|
let synth = Sound.Synth()
|
||||||
|
let token = Token()
|
||||||
|
weakToken = token
|
||||||
|
synth.setGenerator(stereo: false, .init(render: { left, _, _, _ in
|
||||||
|
_ = token
|
||||||
|
rendered += 1
|
||||||
|
return left.count
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Simulate the audio engine rendering through the trampoline.
|
||||||
|
let generator = try! #require(Mock.synthGenerators[synth.pointer])
|
||||||
|
var samples = [Int32](repeating: 0, count: 8)
|
||||||
|
let frames = samples.withUnsafeMutableBufferPointer { buffer in
|
||||||
|
generator.render?(generator.userdata, buffer.baseAddress, nil,
|
||||||
|
Int32(buffer.count), 0, 0)
|
||||||
|
}
|
||||||
|
#expect(frames == 8)
|
||||||
|
#expect(rendered == 1)
|
||||||
|
#expect(weakToken != nil)
|
||||||
|
}
|
||||||
|
// freeSynth deallocs the generator userdata, releasing the box and
|
||||||
|
// the closure's captures with it.
|
||||||
|
#expect(Mock.eventCount("freeSynth") == 1)
|
||||||
|
#expect(weakToken == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func replacingASynthGeneratorReleasesThePreviousOne() {
|
||||||
|
final class Token {}
|
||||||
|
weak var firstToken: Token?
|
||||||
|
|
||||||
|
let synth = Sound.Synth()
|
||||||
|
do {
|
||||||
|
let token = Token()
|
||||||
|
firstToken = token
|
||||||
|
synth.setGenerator(stereo: false, .init(render: { left, _, _, _ in
|
||||||
|
_ = token
|
||||||
|
return left.count
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
#expect(firstToken != nil)
|
||||||
|
|
||||||
|
synth.setGenerator(stereo: false, .init(render: { left, _, _, _ in left.count }))
|
||||||
|
#expect(firstToken == nil) // the OS deallocs the replaced generator
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func effectProcessorDispatchesAndIsReleasedOnDeinit() {
|
||||||
|
final class Token {}
|
||||||
|
weak var weakToken: Token?
|
||||||
|
var processed = 0
|
||||||
|
|
||||||
|
do {
|
||||||
|
let token = Token()
|
||||||
|
weakToken = token
|
||||||
|
let effect = Sound.Effect(processor: { left, right, _ in
|
||||||
|
_ = token
|
||||||
|
processed += left.count
|
||||||
|
#expect(right == nil)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drive the effect the way the OS would: through the registered
|
||||||
|
// proc, which recovers the box from the effect's userdata.
|
||||||
|
var samples = [Int32](repeating: 0, count: 4)
|
||||||
|
let active = samples.withUnsafeMutableBufferPointer { buffer in
|
||||||
|
Mock.effectProc?(effect.pointer, buffer.baseAddress, nil,
|
||||||
|
Int32(buffer.count), 1)
|
||||||
|
}
|
||||||
|
#expect(active == 1)
|
||||||
|
#expect(processed == 4)
|
||||||
|
}
|
||||||
|
#expect(Mock.eventCount("freeEffect") == 1)
|
||||||
|
#expect(weakToken == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func effectSubclassIsFreedExactlyOnceWithItsOwnFree() {
|
||||||
|
do {
|
||||||
|
let line = Sound.DelayLine(length: 256)
|
||||||
|
_ = line
|
||||||
|
}
|
||||||
|
#expect(Mock.eventCount("freeDelayLine") == 1)
|
||||||
|
// The base Effect deinit must not also free the subclass's object.
|
||||||
|
#expect(Mock.eventCount("freeEffect") == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func delayLineTapKeepsItsDelayLineAlive() {
|
||||||
|
weak var weakLine: Sound.DelayLine?
|
||||||
|
var tap: Sound.DelayLineTap?
|
||||||
|
do {
|
||||||
|
let line = Sound.DelayLine(length: 256)
|
||||||
|
weakLine = line
|
||||||
|
tap = line.addTap(delay: 128)
|
||||||
|
}
|
||||||
|
#expect(weakLine != nil) // the tap retains its delay line
|
||||||
|
#expect(Mock.eventCount("freeDelayLine") == 0)
|
||||||
|
|
||||||
|
tap = nil
|
||||||
|
#expect(Mock.eventCount("freeTap") == 1)
|
||||||
|
#expect(Mock.eventCount("freeDelayLine") == 1)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: File
|
// MARK: File
|
||||||
|
|
||||||
@Test func fileHandleReadsWritesAndClosesExactlyOnce() throws {
|
@Test func fileHandleReadsWritesAndClosesExactlyOnce() throws {
|
||||||
|
|||||||
Reference in New Issue
Block a user