Windows System Software -- Consulting, Training, Development -- Unique Expertise, Guaranteed Results

Home NTDEV

Before Posting...

Please check out the Community Guidelines in the Announcements and Administration Category.

More Info on Driver Writing and Debugging


The free OSR Learning Library has more than 50 articles on a wide variety of topics about writing and debugging device drivers and Minifilters. From introductory level to advanced. All the articles have been recently reviewed and updated, and are written using the clear and definitive style you've come to expect from OSR over the years.


Check out The OSR Learning Library at: https://www.osr.com/osr-learning-library/


When can I call WdfObjectDelete with memory received in WdfUsbTargetDeviceCreateIsochUrb

tuple_cattuple_cat Member Posts: 37

I am looking at the documentation for WdfObjectDelete, which says:

The WdfObjectDelete method must be called at IRQL <= DISPATCH_LEVEL. If your driver is deleting a control device object, WdfObjectDelete must be called at IRQL = PASSIVE_LEVEL. Similarly, if your driver is deleting a common buffer, WdfObjectDelete must be called at IRQL = PASSIVE_LEVEL.

I am wondering what a common buffer is?

Is there any thing I need to keep in mind when calling WdfObjectDelete with memory received by WdfUsbTargetDeviceCreateIsochUrb?

I am experiencing some issues (stack trace below) when WdfObjectDelete is called in my EvtDestroyCallback. The object that I pass to WdfObjectDelete is memory received in WdfUsbTargetDeviceCreateIsochUrb.

00 fffffb89`bed69748 fffff805`2bb162f2     nt!DbgBreakPointWithStatus
01 fffffb89`bed69750 fffff805`2bb158d6     nt!KiBugCheckDebugBreak+0x12
02 fffffb89`bed697b0 fffff805`2b9fbda7     nt!KeBugCheck2+0x946
03 fffffb89`bed69ec0 fffff805`2ba17dab     nt!KeBugCheckEx+0x107
04 fffffb89`bed69f00 fffff805`2b9cfbc2     nt!PspSystemThreadStartup$filt$0+0x44
05 fffffb89`bed69f40 fffff805`2ba06392     nt!_C_specific_handler+0xa2
06 fffffb89`bed69fb0 fffff805`2b8e58c7     nt!RtlpExecuteHandlerForException+0x12
07 fffffb89`bed69fe0 fffff805`2b8e7896     nt!RtlDispatchException+0x297
08 fffffb89`bed6a700 fffff805`2ba0fe6c     nt!KiDispatchException+0x186
09 fffffb89`bed6adc0 fffff805`2ba0b45a     nt!KiExceptionDispatch+0x12c
0a fffffb89`bed6afa0 fffff805`2f643f74     nt!KiGeneralProtectionFault+0x31a
0b fffffb89`bed6b130 fffff805`2f643eee     Wdf01000!FxObjectHandleGetPtrQI+0x40 [minkernel\wdf\framework\shared\object\handleapi.cpp @ 376] 
0c (Inline Function) --------`--------     Wdf01000!FxObjectHandleGetPtr+0x39 [minkernel\wdf\framework\shared\inc\private\common\fxhandle.h @ 345] 
0d (Inline Function) --------`--------     Wdf01000!FxObjectHandleGetPtrAndGlobals+0x39 [minkernel\wdf\framework\shared\inc\private\common\fxhandle.h @ 449] 
0e fffffb89`bed6b1a0 fffff805`3eddeffe     Wdf01000!imp_WdfObjectDelete+0x4e [minkernel\wdf\framework\shared\object\fxobjectapi.cpp @ 299] 

Comments

  • Doron_HolanDoron_Holan Member - All Emails Posts: 10,795
    via Email
    Common buffer is related to DMA. Post the code that creates the WDFMEMORY. Maybe you are setting the parent object and it has already been deleted when the parent is deleted.

    Bent from my phone.
    ________________________________
    From: tuple_cat
    Sent: Friday, June 16, 2023 7:16:42 AM
    To: Doron_Holan
    Subject: [NTDEV] When can I call WdfObjectDelete with memory received in WdfUsbTargetDeviceCreateIsochUrb

    OSR https://na01.safelinks.protection.outlook.com/?url=https://community.osr.com/&amp;data=05|01||da61efa1a34242b3faf508db6e745012|84df9e7fe9f640afb435aaaaaaaaaaaa|1|0|638225218058229565|Unknown|TWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0=|3000|||&amp;sdata=uKO8c42ZdKYKZSpftxSbCPB7BBoqrLRzf44GjR+Oe+c=&amp;reserved=0

    tuple_cat Started a new discussion. When can I call WdfObjectDelete with memory received in WdfUsbTargetDeviceCreateIsochUrb

    I am looking at the documentation for WdfObjectDelete, which says:

    The WdfObjectDelete method must be called at IRQL <= DISPATCH_LEVEL. If your driver is deleting a control device object, WdfObjectDelete must be called at IRQL = PASSIVE_LEVEL. Similarly, if your driver is deleting a common buffer, WdfObjectDelete must be called at IRQL = PASSIVE_LEVEL.

    I am wondering what a common buffer is?

    Is there any thing I need to keep in mind when calling WdfObjectDelete with memory received by WdfUsbTargetDeviceCreateIsochUrb?

    I am experiencing some issues (stack trace below) when WdfObjectDelete is called in my EvtDestroyCallback. The object that I pass to WdfObjectDelete is memory received in WdfUsbTargetDeviceCreateIsochUrb.

    00 fffffb89`bed69748 fffff805`2bb162f2 nt!DbgBreakPointWithStatus
    01 fffffb89`bed69750 fffff805`2bb158d6 nt!KiBugCheckDebugBreak+0x12
    02 fffffb89`bed697b0 fffff805`2b9fbda7 nt!KeBugCheck2+0x946
    03 fffffb89`bed69ec0 fffff805`2ba17dab nt!KeBugCheckEx+0x107
    04 fffffb89`bed69f00 fffff805`2b9cfbc2 nt!PspSystemThreadStartup$filt$0+0x44
    05 fffffb89`bed69f40 fffff805`2ba06392 nt!_C_specific_handler+0xa2
    06 fffffb89`bed69fb0 fffff805`2b8e58c7 nt!RtlpExecuteHandlerForException+0x12
    07 fffffb89`bed69fe0 fffff805`2b8e7896 nt!RtlDispatchException+0x297
    08 fffffb89`bed6a700 fffff805`2ba0fe6c nt!KiDispatchException+0x186
    09 fffffb89`bed6adc0 fffff805`2ba0b45a nt!KiExceptionDispatch+0x12c
    0a fffffb89`bed6afa0 fffff805`2f643f74 nt!KiGeneralProtectionFault+0x31a
    0b fffffb89`bed6b130 fffff805`2f643eee Wdf01000!FxObjectHandleGetPtrQI+0x40 [minkernel\wdf\framework\shared\object\handleapi.cpp @ 376]
    0c (Inline Function) --------`-------- Wdf01000!FxObjectHandleGetPtr+0x39 [minkernel\wdf\framework\shared\inc\private\common\fxhandle.h @ 345]
    0d (Inline Function) --------`-------- Wdf01000!FxObjectHandleGetPtrAndGlobals+0x39 [minkernel\wdf\framework\shared\inc\private\common\fxhandle.h @ 449]
    0e fffffb89`bed6b1a0 fffff805`3eddeffe Wdf01000!imp_WdfObjectDelete+0x4e [minkernel\wdf\framework\shared\object\fxobjectapi.cpp @ 299]
    d
  • Tim_RobertsTim_Roberts Member - All Emails Posts: 14,718

    The object that I pass to WdfObjectDelete is memory received in WdfUsbTargetDeviceCreateIsochUrb.

    That sentence raises huge alarm bells. Can you describe the exact circumstances here? If this is memory you received from some other source that you did not allocate, then you have no business deleting it.

    Tim Roberts, [email protected]
    Providenza & Boekelheide, Inc.

  • tuple_cattuple_cat Member Posts: 37

    @Doron_Holan

    I create the memory like this:

        WDFMEMORY urbMemory;
        PURB urb = nullptr;
        WDF_OBJECT_ATTRIBUTES attributes{};
        WDF_OBJECT_ATTRIBUTES_INIT(&attributes);
        WdfUsbTargetDeviceCreateIsochUrb(usbDevice_, &attributes,   numPackets, &urbMemory, &urb);
    

    Maybe I need to set WDF_OBJECT_ATTRIBUTES::ParentObject to NULL if I want to call WdfObjectDelete with urbMemory at a later stage. Is that correct?

    @Tim_Roberts
    The memory that I pass to WdfObjectDelete is the urbMemory in the above code. The documentation says "A pointer to a WDFMEMORY-typed location that receives a handle to a framework memory object".

    If I can get the parent oject correctly set, shouldn't I be able to delete this with WdfObjectDelete?

  • Tim_RobertsTim_Roberts Member - All Emails Posts: 14,718

    You're right, that is all legitimate. So, backing up, where are you deleting this? Is it in a callback? ARE you at a raised IRQL?

    Tim Roberts, [email protected]
    Providenza & Boekelheide, Inc.

  • tuple_cattuple_cat Member Posts: 37

    I am deleting in the EvtDestroyCallback for my USB device... so in the below stacktrace, USBDriverDeviceDestroy is the EvtDestroyCallback that I have specified in the WDF_OBJECT_ATTRIBUTES passed to WdfDeviceCreate.

    Reading about this it says that:

    Typically, the framework calls the EvtDestroyCallback callback function at IRQL <= DISPATCH_LEVEL. However, the framework calls the callback function at IRQL = PASSIVE_LEVEL in the following situations:

    • The object's handle type is WDFDEVICE, WDFDRIVER, WDFDPC, WDFINTERRUPT, WDFIOTARGET, WDFQUEUE, WDFSTRING, WDFTIMER, or WDFWORKITEM.
    • The object's handle type is WDFMEMORY or WDFLOOKASIDE, and the driver has specified PagedPool for the PoolType parameter to WdfMemoryCreate or WdfLookasideListCreate.

    ... so the IRQL should be Passive level.

    One stacktrace looks like this:

    nt!RtlDispatchException+0x297
    nt!KiDispatchException+0x186
    nt!KiExceptionDispatch+0x12c
    nt!KiGeneralProtectionFault+0x31a
    Wdf01000!FxObjectHandleGetPtrQI+0x40 [minkernel\wdf\framework\shared\object\handleapi.cpp @ 376] 
    Wdf01000!FxObjectHandleGetPtr+0x39 [minkernel\wdf\framework\shared\inc\private\common\fxhandle.h @ 345] 
    Wdf01000!FxObjectHandleGetPtrAndGlobals+0x39 [minkernel\wdf\framework\shared\inc\private\common\fxhandle.h @ 449] 
    Wdf01000!imp_WdfObjectDelete+0x4e [minkernel\wdf\framework\shared\object\fxobjectapi.cpp @ 299] 
    USBDriver!WdfObjectDelete+0x1e [C:\Program Files (x86)\Windows Kits\10\Include\wdf\kmdf\1.15\wdfobject.h @ 743] 
    USBDriver!USBDriverDeviceDestroy+0x88 [C:\Users\WDKRemoteUser.DESKTOP-1ODDJ59\code\suite\lib\usb\windows\USBDriver\USBDriver\Device.cpp @ 142] 
    Wdf01000!FxObject::ProcessDestroy+0x1b95b [minkernel\wdf\framework\shared\object\fxobjectstatemachine.cpp @ 326] 
    Wdf01000!FxObject::FinalRelease+0x1b97e [minkernel\wdf\framework\shared\object\fxobject.cpp @ 249] 
    Wdf01000!FxObject::Release+0x1b9b1 [minkernel\wdf\framework\shared\inc\private\common\fxobject.hpp @ 881] 
    Wdf01000!FxObject::DeletedAndDisposedWorkerLocked+0x50 [minkernel\wdf\framework\shared\object\fxobjectstatemachine.cpp @ 1246] 
    Wdf01000!FxObject::DeleteObject+0x1d4dc [minkernel\wdf\framework\shared\object\fxobjectstatemachine.cpp @ 148] 
    Wdf01000!FxDevice::DeleteObject+0x116 [minkernel\wdf\framework\shared\core\fxdevice.cpp @ 1285] 
    Wdf01000!FxPkgPnp::DeleteDevice+0x22 [minkernel\wdf\framework\shared\irphandlers\pnp\fxpkgpnp.cpp @ 2450] 
    Wdf01000!FxPkgFdo::ProcessRemoveDeviceOverload+0x94 [minkernel\wdf\framework\shared\irphandlers\pnp\fxpkgfdo.cpp @ 1273] 
    Wdf01000!FxPkgPnp::_PnpRemoveDevice+0x11a [minkernel\wdf\framework\shared\irphandlers\pnp\fxpkgpnp.cpp @ 2532] 
    Wdf01000!FxPkgPnp::Dispatch+0xaf [minkernel\wdf\framework\shared\irphandlers\pnp\fxpkgpnp.cpp @ 765] 
    Wdf01000!DispatchWorker+0x6a [minkernel\wdf\framework\shared\core\fxdevice.cpp @ 1589] 
    Wdf01000!FxDevice::Dispatch+0x88 [minkernel\wdf\framework\shared\core\fxdevice.cpp @ 1603] 
    Wdf01000!FxDevice::DispatchWithLock+0x156 [minkernel\wdf\framework\shared\core\fxdevice.cpp @ 1447] 
    nt!IofCallDriver+0x55
    nt!IopSynchronousCall+0xf8
    nt!IopRemoveDevice+0x108
    nt!PnpRemoveLockedDeviceNode+0x1ac
    nt!PnpDeleteLockedDeviceNode+0x4e
    nt!PnpDeleteLockedDeviceNodes+0xf7
    nt!PipRemoveDevicesInRelationList+0x8d
    nt!PnpDelayedRemoveWorker+0x114
    nt!PnpChainDereferenceComplete+0xfd
    nt!IopCompleteUnloadOrDelete+0x219ed4
    nt!IopDecrementDeviceObjectRef+0x162
    nt!IopDeleteFile+0x210
    nt!ObpRemoveObjectRoutine+0x80
    nt!ObpProcessRemoveObjectQueue+0x204
    nt!ExpWorkerThread+0x105
    nt!PspSystemThreadStartup+0x55
    nt!KiStartSystemThread+0x28
    
  • Tim_RobertsTim_Roberts Member - All Emails Posts: 14,718

    Why aren't you deleting this memory in the completion routine for the URB you submit? Is this memory you use over and over?

    Tim Roberts, [email protected]
    Providenza & Boekelheide, Inc.

  • Doron_HolanDoron_Holan Member - All Emails Posts: 10,795

    As with almost all WDF objects where you don't specify the parent explicitly, the framework will parent the object to the most appropriate object. Usually the default parent is the first object (handle) passed in to the function. In this case, the WDFMEMORY/Urb will be parented to the WDFUSBDEVICE (the sample code on the doc page for WdfUsbTargetDeviceCreateIsochUrb explicitly sets the parent to WDFUSBDEVICE). So why is the delete call blowing up in EvtDestroy? it is too late with respect to the automatic cleanup. When WDFDEVICE is cleaned up, first all of its children will be cleaned up. WDFUSBDEVICE is one of the children. The tree walk is recursive, so when WDFUSBDEVICE is cleaned, all of its children (including the URB WDFMEMORY) are cleaned up. Once the tree is cleaned up, the tree is destroyed.

    So either you trust WDF and let it auto cleanup the URB WDFMEMORY as WDF's object lifetime management matches how you use the URB (you reuse it repeatedly until the WDFDEVICE is removed) or delete it earlier in its lifetime based on a different usage pattern.

    d
  • tuple_cattuple_cat Member Posts: 37

    @Tim_Roberts Yes, I use the memory again.

    @Doron_Holan Ok, thanks! Can the same thing be said about any object that is passed to WdfObjectDelete, i.e that the object should have it's parent set to NULL.

  • tuple_cattuple_cat Member Posts: 37

    Or maybe I misunderstood you @Doron_Holan ... would it be fine to delete the memory in the destroy callback if I set the memorys parent to NULL?

    I am beginning to think that that is not the case. Even if I set the memorys parent to NULL, I got another bug check with stack trace:

    nt!DbgBreakPointWithStatus
    nt!KiBugCheckDebugBreak+0x12
    nt!KeBugCheck2+0x946
    nt!KeBugCheckEx+0x107
    nt!guard_icall_bugcheck+0x1b
    Wdf01000!imp_WdfObjectDelete+0x6e [minkernel\wdf\framework\shared\object\fxobjectapi.cpp @ 317] 
    USBDriver!USBDriverDeviceDestroy+0x88 [C:\Users\WDKRemoteUser.DESKTOP-1ODDJ59\code\suite\lib\usb\windows\USBDriver\USBDriver\Device.cpp @ 158] 
    Wdf01000!FxObject::Release+0x1b9b1 [minkernel\wdf\framework\shared\inc\private\common\fxobject.hpp @ 881] 
    Wdf01000!FxObject::DeletedAndDisposedWorkerLocked+0x50 [minkernel\wdf\framework\shared\object\fxobjectstatemachine.cpp @ 1246] 
    Wdf01000!FxObject::DeleteObject+0x1d4dc [minkernel\wdf\framework\shared\object\fxobjectstatemachine.cpp @ 148] 
    Wdf01000!FxDevice::DeleteObject+0x116 [minkernel\wdf\framework\shared\core\fxdevice.cpp @ 1285] 
    Wdf01000!FxPkgPnp::DeleteDevice+0x22 [minkernel\wdf\framework\shared\irphandlers\pnp\fxpkgpnp.cpp @ 2450] 
    Wdf01000!FxPkgFdo::ProcessRemoveDeviceOverload+0x94 [minkernel\wdf\framework\shared\irphandlers\pnp\fxpkgfdo.cpp @ 1273] 
    Wdf01000!FxPkgPnp::_PnpRemoveDevice+0x11a [minkernel\wdf\framework\shared\irphandlers\pnp\fxpkgpnp.cpp @ 2532] 
    Wdf01000!FxPkgPnp::Dispatch+0xaf [minkernel\wdf\framework\shared\irphandlers\pnp\fxpkgpnp.cpp @ 765] 
    Wdf01000!FxDevice::DispatchWithLock+0x156 [minkernel\wdf\framework\shared\core\fxdevice.cpp @ 1447] 
    nt!IofCallDriver+0x55
    nt!IopSynchronousCall+0xf8
    nt!IopRemoveDevice+0x108
    nt!PnpRemoveLockedDeviceNode+0x1ac
    nt!PnpDeleteLockedDeviceNode+0x4e
    nt!PnpDeleteLockedDeviceNodes+0xf7
    nt!PipRemoveDevicesInRelationList+0x8d
    nt!PnpDelayedRemoveWorker+0x114
    nt!PnpChainDereferenceComplete+0xfd
    nt!IopCompleteUnloadOrDelete+0x21a1d4
    nt!IopDecrementDeviceObjectRef+0x162
    nt!IopDeleteFile+0x210
    nt!ObpRemoveObjectRoutine+0x80
    nt!ObpProcessRemoveObjectQueue+0x204
    nt!ExpWorkerThread+0x105
    nt!PspSystemThreadStartup+0x55
    nt!KiStartSystemThread+0x28
    
    
  • Doron_HolanDoron_Holan Member - All Emails Posts: 10,795
    If the parent is null you get the default behavior, the parent will be set to the WDFUSBDEVICE. If the urb and memory should be as valid as long as the WDFUSBDEVICE is valid, you don’t have to delete the WDFMEMORY, kmdf will delete it for you when the WDFUSBDEVICE (and urb) are deleted by kmdf.
    d
  • tuple_cattuple_cat Member Posts: 37

    Ok, so there is no way to have no parent set for an object?

    I am reading here:

    When a driver creates an object, it sometimes allocates object-specific memory buffers and stores the buffer pointers in the object's context space. The driver's EvtCleanupCallback or EvtDestroyCallback callback function can deallocate these memory buffers.

    My problem appears in the EvtDestroyCallback for a device (not for the drivers, which is mentioned in the above text). Is the above true only for memory that does not have a parent. For example memory that is allocated with ExAllocatePoolZero?

    @Doron_Holan Is it correct to say that no object that has the WDFUSBDEVICE as parent should be deleted in the EvtDestroyCallback for the WDFUSBDEVICE or the EvtDestroyCallback of the parent of the WDFUSBDEVICE?

  • Doron_HolanDoron_Holan Member - All Emails Posts: 10,795

    you are working on incomplete knowledge. Every object has a parent. If you don't specify a parent object, kmdf assigns the default. This will almost always root up to the WDFDEVICE, the ones that default to the WDFDRIVER as a parent do not have a KMDF parent handle in the WdfXxxCreate call (like WdfStringCreate).

    So, again, I will ask the question: what is the usage lifetime of the isoch URB and the associated WDFMEMORY? if it is the lifetime of the parent WDFDEVICE you DO NOT NEEED TO MANUALLY DELETE THE WDFMEMORY, KMDF will delete the WDFMEMORY when it deletes the WDFDEVICE (which will delete the WDFUSBDEVICE which will delete the WDFMEMORY).

    Is the above true only for memory that does not have a parent. For example memory that is allocated with ExAllocatePoolZero?

    Memory allocated with ExAllocatePoolZero is not a KMDF object. KMDF has no idea it exists nor how to track it. There is no WDFMEMORY for it by default. You must manage this memory on its own and free it at the appropriate time. This is why WDFMEMORY exists, to provide you with a way for KMDF to manage the lifetime of the buffer on your driver's behalf and remove the need for your code to track it's lifetime.

    d
  • tuple_cattuple_cat Member Posts: 37

    I have a class named AudioTransfer. I pass the URB and the associated WDFMEMORY to this class. I also pass the WDFMEMORY that is used as a data buffer, and also the WDFREQUEST that is used to send the urb. This class is used to send audio data from and to the USB device.

    I create this class when a user mode app sends a certain message to the driver to start audio streaming. When the user mode app no longer want to receive data, then the user mode app can send another message, which will stop the streaming from the device.

    The user mode app can configure the driver to ask the USB device for a different number of audio channels. This means that the data buffer that is used to send/receive data from the device needs to be changed to accomodate the new number of bytes that corresponds to the amount of audio channels that the user want to send/receive.

    If the user mode app sends another message to start audio streaming, then the previous instance of the AudioTransfer is deleted, and I recreate a new one , corresponding to the amount of channels that the user mode app want to use. When the AudioTransfer instance is deleted, then WdfObjectDelete is called with the urb memory, the wdfrequest and the wdf memory that holds the data buffers.

    I want to delete the old instance of AudioTransfer before I create a new one, otherwise the memory wont be given back until the USB device is disconnected.

    Does this describe the lifetime of the urb? please let me know if you want more info!

    I'm thinking about if I should delete the AudioTransfer instance in USBDriverEvtDeviceSelfManagedIoSuspend instead, after I have made sure that no transfer is active to/from the device. WdfObjectDelete needs to the called at Passive level and USBDriverEvtDeviceSelfManagedIoSuspend is called at passive level.

    Another solution could be to refactor so that I only replace the data buffer when the number of audio channels is changed, and then let KMDF delete all the objects when the WDFDEVICE goes out of scope.

  • Tim_RobertsTim_Roberts Member - All Emails Posts: 14,718

    You can set the parent when you call WdfUsbTargetDeviceCreateIsochUrb. If you have already created the WDFREQUEST you will use, then just make that the parent of the URB memory. When the request is reclaimed, the URB will also be reclaimed.

    Tim Roberts, [email protected]
    Providenza & Boekelheide, Inc.

  • tuple_cattuple_cat Member Posts: 37

    Great idea!

    WdfRequestCreate does not have a KMDF parent handle that one needs to pass so it will get the driver as parent as default.

    One thing that I still don't understand is why I don't get the bugcheck everytime I detach the USB device. The bug check appears very rarely (I have a programmable/automatable USB Hub that switches devices on and off, and it can run for days without this happening).

    Would it be possible to add a check to Driver verifier that tells you if you call WdfObjectDelete on some object that has the WDFDEVICE as parent, and that this call is coming from the EvtDestroyCallback of the WDFDEVICE.

  • Doron_HolanDoron_Holan Member - All Emails Posts: 10,795
    With the stack you provided, I would guess it was a surprise remove and you didn’t go down the normal app clean up path.
    d
Sign In or Register to comment.

Howdy, Stranger!

It looks like you're new here. Sign in or register to get started.

Upcoming OSR Seminars
OSR has suspended in-person seminars due to the Covid-19 outbreak. But, don't miss your training! Attend via the internet instead!
Kernel Debugging 13-17 May 2024 Live, Online
Developing Minifilters 1-5 Apr 2024 Live, Online
Internals & Software Drivers 11-15 Mar 2024 Live, Online
Writing WDF Drivers 26 Feb - 1 Mar 2024 Live, Online