IOException raised despite IOException catch block

2

We have a Windows Forms application which connects to some web services. It lists the documents in the system, and when the user double-clicks one we download the file to the local computer and open the document for them to edit. Once the user closes the document then we upload it back to the system.

For this process we have been monitoring the file lock on the document. As soon as the file lock is released we upload the document.

The IsFileLocked method looks like this:

private const int ErrorLockViolation = 33;
private const int ErrorSharingViolation = 32;

private static bool IsFileLocked(string fileName)
{
    Debug.Assert(!string.IsNullOrEmpty(fileName));

    try
    {
        if (File.Exists(fileName))
        {
            using (FileStream fs = File.Open(fileName, FileMode.Open, FileAccess.Read, FileShare.None))
            {
                fs.ReadByte();
            }
        }

        return false;
    }
    catch (IOException ex)
    {
        // get the HRESULT for this exception
        int errorCode = Marshal.GetHRForException(ex) & 0xFFFF;

        return errorCode == ErrorSharingViolation || errorCode == ErrorLockViolation;
    }
}

We call this in a loop with a 5 second sleep between attempts. This seems to work great most of the time but occasionally we see an IOException from this method. I cannot see how it is possible for this exception to be thrown.

The exception is:

IOException: The process cannot access the file 'C:\Users\redacted\AppData\Roaming\redacted\Jobs\09c39a4c-c1a3-4bb9-a5b5-54e00bb6c747\4b5c4642-8ede-4881-8fa9-a7944852d93e\CV abcde abcdef.docx' because it is being used by another process.
at System.IO.__Error.WinIOError(Int32 errorCode, String maybeFullPath)
at System.IO.FileStream.Init(String path, FileMode mode, FileAccess access, Int32 rights, Boolean useRights, FileShare share, Int32 bufferSize, FileOptions options, SECURITY_ATTRIBUTES secAttrs, String msgPath, Boolean bFromProxy, Boolean useLongPath, Boolean checkHost)
at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, FileShare share, Int32 bufferSize, FileOptions options, String msgPath, Boolean bFromProxy)
at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, FileShare share)
at redacted.Helpers.IsFileLocked(String fileName)
at System.Runtime.InteropServices.Marshal.GetActiveObject(Guid& rclsid, IntPtr reserved, Object& ppunk)
at System.Runtime.InteropServices.Marshal.GetActiveObject(String progID)
at redacted.OutlookHelper.GetOutlookInternal()
at redacted.OutlookHelper.GetOutlook()
...

The other odd part is the stack trace. This refers to GetOutlook which is a different part of the system entirely (unrelated to document handling). There are two code paths into IsFileLocked and neither are reachable via the GetOutlookInternal method. It is almost as if the stack is getting corrupt.

Why not use a FileSystemWatcher?

As a side-note, we did consider using a FileSystemWatcher to monitor file changes but discounted this approach because the user may keep the document open and continue making further changes to it. Our web services unlock the document as soon as we upload it so we cannot do that until the user has completed finished with it.

We are only concerned with documents that are locked by their application. I appreciate there are some applications which do not lock their files but we do not need to consider them here.

The Outlook methods

Below is the GetOutlookInternal method that appears in the stack - as you can see, it is only dealing with Outlook Interop and is unrelated to the document opening. It does not call into IsFileLocked:

    private static Application GetOutlookInternal()
    {
        Application outlook;

        // Check whether there is an Outlook process running.
        if (Process.GetProcessesByName("OUTLOOK").Length > 0)
        {
            try
            {
                // If so, use the GetActiveObject method to obtain the process and cast it to an Application object.
                outlook = (Application)Marshal.GetActiveObject("Outlook.Application");
            }
            catch (COMException ex)
            {
                if (ex.ErrorCode == -2147221021)    // HRESULT: 0x800401E3 (MK_E_UNAVAILABLE)
                {
                    // Outlook is running but not ready (not in Running Object Table (ROT) - http://support.microsoft.com/kb/238610)
                    outlook = CreateOutlookSingleton();
                }
                else
                {
                    throw;
                }
            }
        }
        else
        {
            // If not running, create a new instance of Outlook and log on to the default profile.
            outlook = CreateOutlookSingleton();
        }
        return outlook;
    }

    private static Application CreateOutlookSingleton()
    {
        Application outlook = new Application();

        NameSpace nameSpace = null;
        Folder folder = null;
        try
        {
            nameSpace = outlook.GetNamespace("MAPI");

            // Create an instance of the Inbox folder. If Outlook is not already running, this has the side
            // effect of initializing MAPI. This is the approach recommended in http://msdn.microsoft.com/en-us/library/office/ff861594(v=office.15).aspx
            folder = (Folder)nameSpace.GetDefaultFolder(OlDefaultFolders.olFolderInbox);
        }
        finally
        {
            Helpers.ReleaseComObject(ref folder);
            Helpers.ReleaseComObject(ref nameSpace);
        }

        return outlook;
    }
c#
.net
asked on Stack Overflow May 9, 2016 by Alsty • edited May 10, 2016 by Alsty

1 Answer

5

I accidentally stumbled across this article which helped to find the cause of my issue: Marshal.GetHRForException does more than just Get-HR-For-Exception

It turns out we had two threads, one was calling Marshal.GetHRForException(...) on an IOException to determine if a file is locked (Win32 error code 32 or 33). Another thread was calling Marshal.GetActiveObject(...) to connect to an Outlook instance using Interop.

If GetHRForException is called first, and then GetActiveObject is called second but throws a COMException, then you get completely the wrong exception and stack trace. This is because GetHRForException is effectively "setting" the exception and GetActiveObject will throw that instead of the real COMException.

Example code to reproduce:

This issue can be reproduced using the following code. Create a new console application, import the Outlook COM reference, and paste in the code. Ensure Outlook is not running when you start the application:

    public static void Main(string[] args)
    {
        bool isLocked = IsFileLocked();
        Console.WriteLine("IsLocked = " + isLocked);
        ShowOutlookWindow();
    }

    private static bool IsFileLocked()
    {
        try
        {
            using (FileStream fs = File.Open(@"C:\path\to\non_existant_file.docx", FileMode.Open, FileAccess.Read, FileShare.None))
            {
                fs.ReadByte();
                return false;
            }
        }
        catch (IOException ex)
        {
            int errorCode = Marshal.GetHRForException(ex) & 0xFFFF;
            return errorCode == 32 || errorCode == 33; // lock or sharing violation
        }
    }

    private static void ShowOutlookWindow()
    {
        try
        {
            Application outlook = (Application)Marshal.GetActiveObject("Outlook.Application"); 
            // ^^ causes COMException because Outlook is not running
            MailItem mailItem = outlook.CreateItem(OlItemType.olMailItem);
            mailItem.Display();
        }
        catch (System.Exception ex)
        {
            Console.WriteLine(ex);
            throw;
        }
    }

You would expect to see the COMException in the console, but this is what you see

IsLocked = False
System.IO.DirectoryNotFoundException: Could not find a part of the path 'C:\path\to\non_existant_file.docx'.
    at System.IO.__Error.WinIOError(Int32 errorCode, String maybeFullPath)
    at System.IO.FileStream.Init(String path, FileMode mode, FileAccess access, Int32 rights, Boolean useRights, FileShare share, Int32 bufferSize, FileOptions options, SECURITY_ATTRIBUTES secAttrs, String msgPath, Boolean bFromProxy, Boolean useLongPath, Boolean checkHost)
    at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, FileShare share)
    at System.IO.File.Open(String path, FileMode mode, FileAccess access, FileShare share)
    at MyProject.Program.IsFileLocked()
    at System.Runtime.InteropServices.Marshal.GetActiveObject(Guid& rclsid, IntPtr reserved, Object& ppunk)
    at System.Runtime.InteropServices.Marshal.GetActiveObject(String progID)
    at MyProject.Program.ShowOutlookWindow()

Note how the exception is DirectoryNotFoundException, and the stack incorrectly suggests GetActiveObject called into IsFileLocked.

Solution:

The solution to this problem was simply to use the Exception.HResult property instead of GetHRForException. Previously this property was protected but it is now accessible since we upgraded the project to .NET 4.5

private static bool IsFileLocked()
{
    try
    {
        using (FileStream fs = File.Open(@"C:\path\to\non_existant_file.docx", FileMode.Open, FileAccess.Read, FileShare.None))
        {
            fs.ReadByte();
            return false;
        }
    }
    catch (IOException ex)
    {
        int errorCode = ex.HResult & 0xFFFF;
        return errorCode == 32 || errorCode == 33; // lock or sharing violation
    }
}

With this change, the behaviour is as expected. The console now shows:

IsLocked = False
System.Runtime.InteropServices.COMException (0x800401E3): Operation unavailable (Exception from HRESULT: 0x800401E3 (MK_E_UNAVAILABLE))
    at System.Runtime.InteropServices.Marshal.GetActiveObject(Guid& rclsid, IntPtr reserved, Object& ppunk)
    at System.Runtime.InteropServices.Marshal.GetActiveObject(String progID)
    at MyProject.Program.ShowOutlookWindow()

TL;DR: Don't use Marshal.GetHRForException if you are also using COM components.

answered on Stack Overflow Oct 25, 2016 by Alsty

User contributions licensed under CC BY-SA 3.0