sbooth / CAAudioHardware

The Swift-friendly Core Audio HAL
MIT License
3 stars 1 forks source link

Crash in AudioDevice.devices() #2

Open andykent opened 9 months ago

andykent commented 9 months ago

First off, This is a truly amazing project and Herculean effort, so thanks, it's been a great resource and I hope it can continue to grow.

In my experiments I have noticed a fair number of crashes when calling AudioDevice.devices() as it seems an object in the list can't be cast correctly.

This seems to trigger when listing devices on or around the time that there is change to the default route, e.g. when connecting headphones. I am calling AudioDevice.devices() on the main thread in response to a change in the default output device.

Screenshot 2023-11-22 at 10 51 00
sbooth commented 9 months ago

Thanks for trying the project and for this report.

The stack trace is interesting because it appears based on the HAL messages that device 0xab isn't recognized as a valid audio object so AudioObjectBaseClass returns 0 (in lieu of throwing) which ends up in the default case in AudioObject.make. The part that seems odd is that the call to AudioObjectClass succeeds as evidenced by the aagg in the log (which itself is only due to a fortuitous bug in the logging using the object class instead of the base class).

This report also made me realize that make should probably return an optional and that it might also be wise to propagate errors more effectively from AudioObjectClass and AudioObjectBaseClass.

For this report specifically where did the audio object id 0xab come from? Was it from AudioDevice.devices() or somewhere else?

sbooth commented 9 months ago

I've created the throwing-make branch to attempt to better trap the errors you're seeing, although it doesn't address the root cause at this point.

andykent commented 9 months ago

In answer to your question. Yes, I'm pretty sure 0xab came from AudioDevice.devices().

I just switched to the throwing-make branch and it stops the crash so that is good progress at least.

I have included some further logging, the sample code I am using to log it with, and a screenshot of my audio devices. Hopefully it might be helpful?

func logDeviceData() {
  let defaultDevice = try! AudioDevice.defaultOutputDevice()

  for device in try! AudioDevice.devices() {
    let transport = (try? device.transportType().debugDescription) ?? "error"

    if device.objectID == defaultDevice.objectID {
      logger
        .warning(
          "DEFAULT DEVICE: \(device.debugDescription, privacy: .public) - \(transport, privacy: .public)"
        )
    } else {
      logger
        .log(
          "DEVICE: \(device.debugDescription, privacy: .public) - \(transport, privacy: .public)"
        )
    }

    if let layout = try? device.preferredChannelLayout(inScope: .output) {
      logger.log("preferred layout -> \(layout.debugDescription, privacy: .public)")
    }
  }
}
Screenshot 2023-11-22 at 21 53 10 Screenshot 2023-11-22 at 21 57 59

Sidequest Note: I think the ('srnd', 'outp', main)) failed: 'who?' warnings are because I am trying to get preferredChannelLayout on devices that are input not output. What's the best way to check if a device has output here?

sbooth commented 9 months ago

Sidequest Note: I think the ('srnd', 'outp', main)) failed: 'who?' warnings are because I am trying to get preferredChannelLayout on devices that are input not output. What's the best way to check if a device has output here?

I believe hasSelector(.preferredChannelLayout, inScope: .output) is the best way to check for the preferred channel layout.

sbooth commented 9 months ago

This is just a guess, but are you using AVAudioEngine? If I remember correctly it creates private aggregate devices on the fly, at least in certain situations.

andykent commented 9 months ago

Yes, we are using AVAudioEngine. Interesting, that sounds quite likely then. Now I'm just not sure what to do with them or how to ignore them.

Thanks for the tip about using hasSelector, that resolved those pieces. Given it seems invalid to call these selectors if they aren't available does it make sense for the library to check hasSelector in all these functions and either throw or return optionals?

It would allow code like:

if device.hasSelector(.preferredChannelLayout, inScope: .output),
   let layout = try? device.preferredChannelLayout(inScope: .output)

to be simplified to:

if let layout = try? device.preferredChannelLayout(inScope: .output)

and avoid having to know/check the selector at every call-site.

andykent commented 9 months ago

Found another very edgy-edge-case in AudioObject.make().

I am able to trigger the precondition. By:

To be fair, this also causes weirdness in Audio Midi Setup so I think it's Apple's issue at heart but it does illustrate that the precondition can at least trigger in some situations so now that make is throwing maybe raising an error here is more appropriate?

Screenshot 2023-11-24 at 12 24 36

Here's the step to select an invalid channel combo in Audio Midi Setup.

Screenshot 2023-11-24 at 12 33 23
andykent commented 9 months ago

Quick update. I managed to trigger the bcls crash again today, just whilst connecting / disconnecting AirPods with AVAudioEngine running.

To clarify I am using AudioSystemObject.instance.whenSelectorChanges(.defaultOutputDevice, perform:) and then running this code in the perform. So the code gets run when I switch devices. I think it must be some sort of race condition with AVAudioEngine reconfiguring as sometimes it's fine but other times not.

Screenshot 2023-11-24 at 13 11 43
sbooth commented 9 months ago

E.g. trying to select 8 channel output for a hardware device that only supports 6 channels.

The fact that this is allowed at all makes me think this could be a bug in Audio MIDI Setup or even lower down. But I agree that the precondition shouldn't fail. I suppose a question is whether a system will always have a default output device and I'd assumed it always would. From your testing it seems that is an incorrect assumption.

I suppose make could return an optional for invalid object IDs but I throwing for the case of kAudioObjectUnknown also seems reasonable.

sbooth commented 9 months ago

To clarify I am using AudioSystemObject.instance.whenSelectorChanges(.defaultOutputDevice, perform:) and then running this code in the perform. So the code gets run when I switch devices. I think it must be some sort of race condition with AVAudioEngine reconfiguring as sometimes it's fine but other times not.

Thanks for the detail!

It is starting to sound like AVAE's private aggregate device might be changed out from underneath during make. It would be a great data point to know which device is causing the crash. Do you happen to log all the devices at app launch for a baseline?

sbooth commented 9 months ago

It would allow code like:

if device.hasSelector(.preferredChannelLayout, inScope: .output),
   let layout = try? device.preferredChannelLayout(inScope: .output)

to be simplified to:

if let layout = try? device.preferredChannelLayout(inScope: .output)

and avoid having to know/check the selector at every call-site.

If one is using try? to discard errors is there a need to check if the property is supported? I do see your point for the general case, though.

andykent commented 9 months ago

Here's a list of the devices from earlier on before the crash. It's possible it's the AudioAggregateDevice listed there.

Screenshot 2023-11-25 at 20 44 54

If one is using try? to discard errors is there a need to check if the property is supported? I do see your point for the general case, though.

Yes, agreed, the functional result is probably the same but it would avoid the scary HAL warnings in the console. :)

sbooth commented 9 months ago

The CADefaultDeviceAggregate-20036-15 is an aggregate device created by AVAudioEngine although the ID doesn't match the one from the previous crash. But those devices are also created and destroyed somewhat frequently (I think that particular device is the 15th one created in pid 20036) so I'm still leaning toward the race condition/device being destroyed midway through make. Although I am puzzled on what the best resolution would be.

I just tagged release 0.2.0 which should fix the crashes but not the underlying problem.

Are you doing anything special in the audio engine configuration change notification? From the stack trace it looks like the crash is happening on the main thread from a closure executed from within the AVAE configuration change handling. Could there be multiple configuration change notifications in a row which might cause the creed aggregate device to become stale?

< insert rant about AUGraph behaving predictably here >

andykent commented 9 months ago

Our setup is a little exotic here so it's very possible we are triggering this race.

I monitor changes in output devices so we can reconfigure AE to match the output channel count. We do this because we do some custom rendering that supports different output formats, e.g. stereo, binaural, 5.1, 7.1, etc.

We monitor both AVAudioEngineConfigurationChange and defaultOutputDevice and rebuild/restart AE when either of these indicate a change in output formats.

The logging above is the first thing I do when defaultOutputDevice changes but it is possible AVAudioEngineConfigurationChange is firing at a similar time however all this is scheduled on the main thread currently so they shouldn't be happening concurrently. Assuming of course AE.stop/start don't trigger concurrent changes internally.

sbooth commented 9 months ago

So far I haven't been able to reproduce the issue so it's proving a hard one to track down.

I just committed changes that allow the dispatch queue on which property change notifications are invoked to be specified. A nil queue equates to direct block invocation. Perhaps trying that or using the queue for your AVAE instance would provide more information on the order changes occur, assuming they are deterministic.