System Panic with IOUserSCSIParallelInterfaceController during Dispatch Queue Configuration

Hello everyone,

We are in the process of migrating a high-performance storage KEXT to DriverKit. During our initial validation phase, we noticed a performance gap between the DEXT and the KEXT, which prompted us to try and optimize our I/O handling process.

Background and Motivation:

Our test hardware is a RAID 0 array of two HDDs. According to AJA System Test, our legacy KEXT achieves a write speed of about 645 MB/s on this hardware, whereas the new DEXT reaches about 565 MB/s. We suspect the primary reason for this performance gap might be that the DEXT, by default, uses a serial work-loop to submit I/O commands, which fails to fully leverage the parallelism of the hardware array.

Therefore, to eliminate this bottleneck and improve performance, we configured a dedicated parallel dispatch queue (MyParallelIOQueue) for the UserProcessParallelTask method.

However, during our implementation attempt, we encountered a critical issue that caused a system-wide crash.

The Operation Causing the Panic:

We configured MyParallelIOQueue using the following combination of methods:

  1. In the .iig file: We appended the QUEUENAME(MyParallelIOQueue) macro after the override keyword of the UserProcessParallelTask method declaration.
  2. In the .cpp file: We manually created a queue with the same name by calling the IODispatchQueue::Create() function within our UserInitializeController method.

The Result:

This results in a macOS kernel panic during the DEXT loading process, forcing the user to perform a hard reboot.

After the reboot, checking with the systemextensionsctl list command reveals the DEXT's status as [activated waiting for user], which indicates that it encountered an unrecoverable, fatal error during its initialization.

Key Code Snippets to Reproduce the Panic:

  1. In .iig file - this was our exact implementation:

    class DRV_MAIN_CLASS_NAME: public IOUserSCSIParallelInterfaceController
    {
    public:
        virtual kern_return_t UserProcessParallelTask(...) override
            QUEUENAME(MyParallelIOQueue);
    };
    
  2. In .h file:

    struct DRV_MAIN_CLASS_NAME_IVars {
        // ...
        IODispatchQueue*    MyParallelIOQueue;
    };
    
  3. In UserInitializeController implementation:

    kern_return_t
    IMPL(DRV_MAIN_CLASS_NAME, UserInitializeController)
    {
        // ...
        // We also included code to manually create the queue.
        kern_return_t ret = IODispatchQueue::Create("MyParallelIOQueue",
                                                    kIODispatchQueueReentrant,
                                                    0,
                                                    &ivars->MyParallelIOQueue);
        if (ret != kIOReturnSuccess) {
            // ... error handling ...
        }
        // ...
        return kIOReturnSuccess;
    }
    

Our Question:

What is the officially recommended and most stable method for configuring UserProcessParallelTask_Impl() to use a parallel I/O queue?

Clarifying this is crucial for all developers pursuing high-performance storage solutions with DriverKit. Any explanation or guidance would be greatly appreciated.

Best Regards,

Charles

Answered by DTS Engineer in 865478022

Therefore, to eliminate this bottleneck and improve performance, we configured a dedicated parallel dispatch queue (MyParallelIOQueue) for the UserProcessParallelTask method.

Yeah... that won't work. UserProcessParallelTask is an OSAction target, which is already targeting a queue. I'd be curious to see how the panic() played out*, but I'm not surprised that you panicked.

*I suspect you deadlocked command submission long enough that the SCSI stack gave up and panicked, but that's purely a guess.

That leads to here:

What is the officially recommended and most stable method for configuring UserProcessParallelTask_Impl() to use a parallel I/O queue?

The answer here is to shift to UserProcessBundledParallelTasks. The architecture is a bit more complex, but it allows you to asynchronously receive and complete tasks in parallel while also reusing command and response buffers to minimize wiring cost. Take a look at the header files from SCSIControllerDriverKit for details on how this architecture works.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

I believe you filed a bug asking about this earlier, and I have my own bug on this (r.169737319). It fell off my radar for a bit, but I've asked the team for some guidance on the "right" way to handle this today.

Following up on myself after talking with the team, here is how I would suggest handling this:

In the first call to UserProcessBundledParallelTasks, you should:

  1. Store the OSAction you receive into your DEXTs own ivars.

  2. Intentionally retain() that OSAction. This retain will NOT be balanced, so you're intentionally over-retaining the OSAction (it will be destroyed when your DEXT is). You can actually retain it a few times if you want.

  3. On all future calls to UserProcessBundledParallelTasks, assert that the OSAction you receive is the same as the action you received in #1, intentionally crashing if it changes.

Note that the point of #3 is NOT to detect a valid state you should anticipate or "handle". It's purely there as an overall "safety" check that will either never trigger or will trigger years from now for some totally reason. On that second point, I'll also note that this is an EXCELLENT place to add extended comments to save the poor fellow[1] who is lost trying to figure out why you did this years from now.

In any case, if that assert ever triggers, that either indicates that we've changed something fundamental to the overall architecture or a bug in your DEXT has damaged/altered the action you're supposed to be using. Either way, it's better to crash at that point instead of attempting to function in a totally unexpected state.

At some point I hope we'll address and document this (I've suggested making the action a singleton object that disables retain/release) but I'd expect anything we do here to work fine with the flow above.

[1] Always remember, the poor fellow you save might just be you.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Hi Kevin,

Thank you for the guidance. Based on your suggestions, we have implemented architectural modifications. Below is the status of implementation and the current issue.

We store and retain IOMemoryDescriptor and IOMemoryMap objects in ivars. The ISR accesses the shared buffer address, which resolved the issue where GetAddress() returned NULL.

The ISR differentiates between command sources. In Bundled mode, the DEXT calls BundledParallelTaskCompletion without calling release(). In Legacy mode, it calls ParallelTaskCompletion followed by release(). These changes eliminated the 0x92000006 Kernel Panic and DEXT Corpse crashes.

The kernel dispatches Bundled commands immediately after UserInitializeTargetForID returns, but before UserCreateTargetForID completes. We found that reporting command completion while UserCreateTargetForID is still executing causes the UserCreateTargetForID call to hang.

Based on this behavior, we infer a re-entrancy deadlock. The registration thread waits for the SAM target object to be fully instantiated, while the DEXT simultaneously reports an I/O completion for that same target. We believe this causes a locking conflict within the kernel state machine. Since UserCreateTargetForID does not return, the serial discovery queue is blocked, stopping all subsequent target registrations.

Is our understanding correct? How should we resolve the issue we are currently facing?

Best Regards,

Charles

Hi Kevin,

Following up on my previous update regarding the registration hang and SAM layer panic. We performed further experiments using the Selection Timeout (SERVICE_DELIVERY_FAILURE) approach as you suggested. Below are the results:

1. Selection Timeout Experiment Results

We modified the DEXT to report SERVICE_DELIVERY_FAILURE immediately for Bundled commands arriving before the registration returns. We confirmed fControllerTaskIdentifier matches the request.

  • Stability Improvement: With this change, any attempt to unplug the hardware or deactivate the DEXT no longer triggers a Kernel Panic. Resource lifecycle management (retaining/releasing descriptors) is now functioning correctly.
  • Persistent Hang: Despite reporting the timeout, UserCreateTargetForID remains hung indefinitely and never returns on its own.

2. Log Evidence: The "Unlock" Mechanism

The logs show that the kernel registration thread is blocked until a termination signal is received.

Log A: Hang after Selection Timeout

default 14:00:07.773080  kernel  [AsyncCreateTargetForID_Impl] Calling UserCreateTargetForID for LUN 0
default 14:00:07.773519  kernel  [UserInitializeTargetForID_Impl] Target 0 callback success.

// DEXT reports Selection Timeout via Bundled API
default 14:00:07.774781  kernel  [UserProcessBundledParallelTasks_Impl] [Guard] Target 0 not ready. Reporting Selection Timeout.
default 14:00:07.774789  kernel  [UserProcessBundledParallelTasks_Impl] // --- } UserProcess Return

// DEADLOCK: No further output. AsyncCreateTargetForID_Impl execution is interrupted.

Log B: Stop Sequence Unblocking the Queue

Upon issuing the Deactivate command, the original UserCreateTargetForID for LUN 0 returns success immediately after Stop() starts. The serial queue then proceeds to subsequent LUNs.

default 14:02:33.557678  kernel  [Stop_Impl] // { ---
default 14:02:33.558307  kernel  [Stop_Impl_block_invoke] All cancels finished. Calling super::Stop.

// Registration for LUN 0 finally returns success
default 14:02:33.558402  kernel  [AsyncCreateTargetForID_Impl] Target 0 fully registered.
default 14:02:33.558406  kernel  [AsyncCreateTargetForID_Impl] // --- } LUN 0 Exit

// Serial queue proceeds; LUN 1 and LUN 2 fail with kIOReturnAborted during termination
default 14:02:33.558460  kernel  [AsyncCreateTargetForID_Impl] Calling UserCreateTargetForID for LUN 1
default 14:02:33.558484  kernel  [AsyncCreateTargetForID_Impl] Target 1 registration failed: 0xe00002bc
default 14:02:33.558559  kernel  [AsyncCreateTargetForID_Impl] Calling UserCreateTargetForID for LUN 2
default 14:02:33.558634  kernel  [AsyncCreateTargetForID_Impl] Target 2 registration failed: 0xe00002bc

3. Request for Guidance

Our data confirms that reporting either SUCCESS or FAILURE during registration does not allow UserCreateTargetForID to return under normal conditions. It only returns when the DEXT enters its termination phase.

Is there a specific signal, barrier, or status required in Bundled mode to notify the SAM layer that the probe is complete and allow UserCreateTargetForID to return normally? Or should we handle these initial commands differently to avoid this deadlock?

Best Regards,

Charles

First off, revisiting my own comments:

No, I don't think any special synchronization is required. If you're tracking the buffer through a direct pointer, then the relative gap between

Please check your email and the bug site for some specific feedback from the engineering team, as they have some comments specific to your code they wanted to pass back. I don't think they'll change the immediate issue but they're worth noting and integrating.

Is there a specific signal, barrier, or status required in Bundled mode to notify the SAM layer that the probe is complete and allow UserCreateTargetForID to return normally?

No. You're creating the deadlock, not the SAM layer.

Or should we handle these initial commands differently to avoid this deadlock?

What else is attached to the queue UserCreateTargetForID is called on? The answer should be "nothing", as my guess is that you're deadlocking on whatever call looped "back" to UserCreateTargetForID().

In terms of the expected call chain, looking at our code, I'd expect:

  1. UserInitializeTargetForID
  2. UserDoesHBASupportMultiPathing
  3. SAM stack issue INQUIRY

In terms of #3, our code has multiple retries (~8) on most of the code paths I've seen, so if you're only seeing one call, then I think the issue is actually that you're unable to complete the command, presumably because you deadlocked yourself.

The logs show that the kernel registration thread is blocked until a termination signal is received.

This is somewhat misleading. I think what's actually happening here is that kernel side tear down is severing your DEXT connections to the kernel, which basically ends up failing "everything".

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Hi Kevin,

Thank you for your feedback and the feedback from the engineering team. We have integrated all suggestions from your forum posts (ID: 875288022, 875587022) and the Bug Report, and have conducted a full round of testing. Below is our current status.

We now perform a memset on SCSIUserParallelResponse in the ISR and correctly populate the version, fControllerTaskIdentifier, and fBytesTransferred fields. The ISR now differentiates between Bundled/Legacy modes, calling BundledParallelTaskCompletion (without release()) and ParallelTaskCompletion (with release()) accordingly.

The aforementioned fixes have resolved all 0x92000006 Panics and DEXT Corpse crashes. Unplugging the hardware or deactivating the DEXT while the driver is in a hung state no longer triggers a panic.

We are in a logical deadlock. The kernel dispatches a probe command before UserCreateTargetForID returns, and both of our methods for handling this command result in a permanent hang of the registration process:

Scenario A (Reporting SUCCESS): We mark the target as "Ready" during the UserInitializeTargetForID phase, allowing the command to be processed by the hardware and completed as SUCCESS by the ISR (with a fully initialized data structure).

  • Result: UserCreateTargetForID hangs indefinitely. Logs confirm the ISR successfully calls BundledParallelTaskCompletion, but the registration call never returns.

Scenario B (Reporting Selection Timeout): We mark the target as "Ready" only after UserCreateTargetForID returns and intercept the preemptive command in UserProcess... to report SERVICE_DELIVERY_FAILURE.

  • Result: UserCreateTargetForID also hangs indefinitely.

In your latest reply, you suggested the deadlock might be caused by our queue configuration. We have reviewed our architecture:

  • Our UserCreateTargetForID runs on a dedicated serial queue (AuxiliaryQueue).
  • All I/O completions (BundledParallelTaskCompletion) occur on the InterruptQueue or DefaultQueue.
  • There are no shared IOLocks between these execution paths.

Based on this, a queue deadlock within the DEXT seems unlikely.

In both Scenarios A and B, we observed the exact same behavior: the hung UserCreateTargetForID call returns success immediately only when we manually deactivate the driver, triggering the Stop() sequence.

It appears the kernel's registration thread is waiting for a signal that the DEXT has not yet sent, and this signal is only triggered when the driver terminates.

We have now ruled out data structure corruption, API misuse, and DEXT-level queue deadlocks. Is there anything else we should be aware of that we might have missed?

Best Regards,

Charles

First off, I want to start with a clarification here:

We are in a logical deadlock. The kernel dispatches a probe command before UserCreateTargetForID returns, and both of our methods for handling this command result in a permanent hang of the registration process:

Calling "UserCreateTargetForID" means "please create the storage stack for this target". Returning from it means "I've finished creating the storage stack for this target". I'm not sure how far up the stack you'll actually get, but it's conceivable that we'd get all the way through partition map interpretation and (possibly) volume format detection BEFORE UserCreateTargetForID returns. You're basically guaranteed to get I/O request before UserCreateTargetForID returns.

[1] I think the upper levels of the SAM stack prevent this by returning from state before calling registerForService on their IOStorage family nubs, but there's no technical reason why they'd HAVE to work this way.

That leads to here:

We mark the target as "Ready" during the UserInitializeTargetForID phase, allowing the command to be processed by the hardware and completed as SUCCESS by the ISR (with a fully initialized data structure).

I'm still confused by this. Calling "UserCreateTargetForID" means "I'm ready this device to start working", which means your entire I/O "chain" for that device should be "ready" before you call it. In any case, the point here is that you shouldn't call UserCreateTargetForID until you’re ready to handle "arbitrary" I/O requests, just like you would once the controller is fully active.

Moving to here:

Our UserCreateTargetForID runs on a dedicated serial queue (AuxiliaryQueue).

Just to clarify, how does your larger code "around" UserCreateTargetForID actually work?

For reference, our controller does some basic configuration in UserStartController(), calls into AsyncEventHandler, then immediately returns. That handler does some synchronous I/O and DMA configuration, eventually calling UserCreateTargetForID(). However, the critical point here is that "nothing" else is happening on the default queue (where UserStartController() was called) once the creation process for UserCreateTargetForID starts.

Also, just to be clear, ONLY two methods should be tied to that method. Those are the declarations of UserCreateTargetForID which you inherit:

virtual kern_return_t
UserCreateTargetForID ( SCSIDeviceIdentifier    targetID,
						OSDictionary *          targetDict ) QUEUENAME ( AuxiliaryQueue );

And the HandleAsyncEvent method you set up to call it through:

virtual void
AsyncEventHandler     ( OSAction *					action TARGET,
						kern_return_t				status ) = 0;

virtual void
HandleAsyncEvent ( OSAction *				action,
				   kern_return_t			status )
				   TYPE ( ExampleSCSIDext::AsyncEventHandler ) QUEUENAME ( AuxiliaryQueue );

Covering the other details, the setup code for this looks like this:

kern_return_t
IMPL ( ExampleSCSIDext, UserInitializeController )
{
	kern_return_t ret = kIOReturnSuccess;
...	
	ret = IODispatchQueue::Create ( "FCQ", 0, 0, &ivars->fQueue1 );
	assert(kIOReturnSuccess == ret);
	
	ret = SetDispatchQueue ( "AuxiliaryQueue", ivars->fQueue1 );
	assert ( kIOReturnSuccess == ret );
		
	ret = CreateActionHandleAsyncEvent ( sizeof ( void * ), &ivars->fAsyncEventHandler );
	assert ( kIOReturnSuccess == ret );
...
}

And the call to it like this:

kern_return_t
IMPL ( AppleLSIFusionFC, UserStartController )
{
	kern_return_t ret = kIOReturnSuccess;
...

	AsyncEventHandler ( ivars->fAsyncEventHandler, kIOReturnSuccess );

	return ret;
}

It appears the kernel's registration thread is waiting for a signal that the DEXT has not yet sent, and this signal is only triggered when the driver terminates.

Sort of. I think what's actually happening is that the kernel is issuing an I/O request, then blocking because that I/O request isn't returning properly. In terms of direct investigation, the main thing to look at here is spindump while your DEXT is hung:

sudo spindump -o <destination path>

The spindump file will include the kernel frames which should, in theory, show what the kernel is actually hung on. However, if you want me to look into this, then please do that following:

  • Make sure your DEXT is logging as much as it possibly can. I'd log every method entry and exit, along with additional logging every time you call into the kernel. Basically, you want your DEXT to be as visible as possible in the system console.

  • Reproduce the hang, then trigger a sysdiagnose.

  • Pull the device to trigger driver teardown, let everything finish, then trigger another sysdiagnose.

Label both sysdiagnoses so I know which is which, then upload both of them to your bug. FYI, I'm asking for two sysdiagnoses (instead of just doing one after the test finishes) because I want to see the spindump and registry state at the point of the hang plus the log data from the hang. I don't necessarily "need" the second sysdiagnose, but it's possible that logging from teardown will show me something interesting.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

System Panic with IOUserSCSIParallelInterfaceController during Dispatch Queue Configuration
 
 
Q