This article demonstrates a subtle problem that can occur when using ActiveX controls in a managed application.
I first ran into this problem while using a third party ActiveX control in one of my own C# programs. Occasionally, when I attempted to access one of my unmanaged resources via a COM object, I found that it was in a locked state. Based on former experience with the control, the components, and several unmanaged VB6 programs that used them, I knew that the existing unmanaged code was unlikely to be the cause of the problem. In fact, I wrote several test programs in VB6 to try and reproduce the problem, but the problem did not show up until I moved to a C# Windows Forms application.
After careful investigation, I found that extra references where being held on the COM objects by the .NET runtime callable wrappers (RCW) for the objects. This occurred even though I never directly accessed the COM object in the managed .NET code.
The demo project included with this article demonstrates when the problem occurs, and some workarounds.
This article assumes that you have basic familiarity with ActiveX controls. In addition, it is assumed that you understand how to create and work with a simple C# Windows Forms application that uses an ActiveX control. Although the ActiveX control included with the demo project is written in C++ using ATL, the underlying code is kept as simple as possible.
The Demo Project
The project included with this article gives a simple example that demonstrates the problem. The project consists of three pieces:
- An unmanaged DLL that houses an ActiveX control and a COM component. (InfoSource)
- A managed C# Windows Forms application that makes use of the ActiveX control. (ClientApp)
- A container solution to build the two projects. (BuildAll)
The managed C# program, ClientApp, uses the ActiveX control
CDataViewer uses a
CDataInfo COM object to manage the underlying data. To keep the demo program straightforward, the ActiveX control simply exposes an edit control, while the underlying
CDataInfo object represents the contents of a text file. The
CDataViewer control and the
CDataInfo object are housed in the InfoSource DLL project.
When the viewer is opened, it creates an instance of a
CDataInfo COM object with the file name passed in by the user. The
CDataInfo object attempts to open the file for exclusive access. If the open fails, the viewer returns an error and a message is displayed.
To keep the logic simple, the viewer does not attempt to read the existing file contents when opened. This logic could be added, but it does not affect the concepts I'm demonstrating here.
Whenever the user types into the edit control, the
CDataInfo object is updated to reflect the current state of the viewer. This update is performed in memory to avoid constantly writing the data to disk. In addition, whenever a change occurs in the control, a
DataChanged event is fired off to any client that has connected to the ActiveX control's event source. The argument to the event handler routine,
newVal, is a reference to the internal
CDataInfo object. This is an important point to remember.
[id(1), helpstring("method DataChanged")]
HRESULT DataChanged([in] IDispatch* newVal);
When the viewer is closed, it releases its reference on the underlying
CDataInfo object. When the reference count goes to zero and the
CDataInfo object is destroyed, it flushes its contents to disk and the file is closed.
To build the project, you'll need Visual Studio 2005. Open the BuildAll project and rebuild the entire solution. This will automatically build and register the COM component, the ActiveX control, and the C# application. You can then run the ClientApp application.
This project was built and tested on Windows XP Service Pack 2.
Demonstrating the Problem
To see the problem in action, start up the client application and select "Open" to open a file. Type something into the edit control and click on the "Close" button. Then, quickly attempt to open the same file again. Every so often, you'll get an error as shown in the figure below. You won't see the error every time, so try a few times if you don't see it immediately. Remember to type into the edit control each time, or the ActiveX control data change events will not be fired.
When this error occurs, it's an indication that the underlying file could not be reopened. The question is, why can't it be opened? What's locking the file?
Tracking Down the Problem
By using the Sysinternals.com "Process Explorer" tool, I quickly realized that the file was not always closed when the "Close" button was pressed. Sometimes the file would remain open for several seconds after "Close". Turning to Process Explorer once again, it was apparent that the file was not closed until the application went through an additional .NET garbage collection. Clearly, the .NET runtime was creating a reference to the underlying
CDataInfo object and not releasing it until the next garbage collection. I verified this by adding debug code to force a garbage collection after every close. With the forced garbage collection code in place, the file locking problem went away.
Armed with this information, I tried to figure out what could be causing the reference to the underlying data object. The object was not used or referenced anywhere in the managed code. Eventually, I focused in on the data changed event. This was the only place where the C# code could interact with the underlying
CDataInfo object. I still had serious doubts though; I had not hooked up an event handler for the data changed event.
After further testing, I concluded that it didn't matter that there was no explicit event handler. The Windows Forms application must be hooking up to the event source, even though my application didn't use the event. I verified this by explicitly adding an event handler with code to free the COM object. The event handler routine is shown below:
private void CDataViewer_DataChanged(object sender,
int i = System.Runtime.InteropServices.
ReleaseComObject call in place, the file was never locked after the "Close" operation. The additional references to the COM object in the managed code were eliminated. It was no longer necessary to wait for a garbage collection.
In the demo project, you can experiment with the different scenarios by using the checkboxes for the data-changed event handler. You can also force a .NET garbage collection by using the "Force GC" button.
The Technical Details
Once I understood the problem, I decided to investigate further to understand the cause. Since the problem was clearly occurring in the interop layer between the ActiveX control and the .NET form, I modified the projects to explicitly create the interop assemblies using Aximp.exe instead of using the Toolbox in Visual Studio.
Aximp.exe is a utility that generates interop assemblies for ActiveX controls. It is included with Visual Studio. One of the benefits of using Aximp.exe is that you have the option of generating the C# source code for the Windows Forms wrapper. The demo project makes use of Aximp.exe.
With the source code and a debugger, it's easy to see what's happening. Use the debugger to put a breakpoint in the functions
RaiseOnDataChanged. These functions are members of the form's control wrapper class
AxCDataViewer. This class is used by the managed application to interact with the ActiveX control
CDataViewer. When you run the program with the debugger, you'll see that
CreateSink is called immediately upon the form creation (in the method
CreateSink hooks up to the COM event source so that the COM events can be forwarded to any .NET client. What's important to notice is that
CreateSink is always called, whether or not any .NET clients are subscribing to the events.
The code to determine whether or not to forward the events to a .NET client is in the
RaiseOnDataChanged function. You can experiment with the
RaiseOnDataChanged logic by using the "Use Event Handler" checkbox.
Since the COM events are always handled, even when they are not forwarded to .NET clients, a runtime callable wrapper (RCW) is used to manage the
CDataInfo COM object that is passed as an argument to the event handler routine. It's this RCW that lingers until the next garbage collection.
The figure below illustrates the concept:
AxCDataViewer.CreateSink function sets up the event sink for the
CDataViewer ActiveX control events. The COM event sink is always connected at the form initialization time.
AxCDataViewer.RaiseOnDataChanged function forwards the COM events as .NET events if there are any subscribing clients.
Let's consider some options to work around the problem.
- Add an explicit event handler with code to release any COM objects that hold critical resources. Repeat this for every event handler that is causing a problem.
- Force a garbage collection when required. (Not necessarily recommended.)
- Redesign your unmanaged components to provide explicit methods to free resources, instead of waiting for the reference count to drop to zero.
- Redesign your ActiveX controls so the event handler routine arguments don't include COM components that lock resources until the final destruction. Better yet, don't pass COM components at all. Let the client request a component reference when necessary.
- Create your own custom ActiveX control wrapper class. You can start with the output source from Aximp.exe.
Obviously, options 3 and 4 are not always practical. If you are using a third party control without access to the source, code modification is impossible.
This article demonstrated how ActiveX events in Windows Forms applications can cause COM objects to linger while waiting for a .NET garbage collection.
Obviously, this situation does not apply to all ActiveX controls. However, if your ActiveX control exposes events to .NET clients, and those events pass along COM objects that lock critical resources, you might find yourself tracking down the same problem presented in this article. As mentioned above, you might encounter this problem even if you have not explicitly hooked up an event handler for the control.
Hopefully, the concepts presented in this article will help you to understand the problem and avoid it in your own code.