ArgumentException when adding objects in Session: Item has already been added to the dictionary

Did you ever see this ArgumentException when you innocently tried to add something to session? I recently saw an instance when someone got this exact error. Basically, they had a couple of pages that ran just fine most of the times; but under high load, one page threw this error at random times:

[ArgumentException]: Item has already been added. Key in dictionary: 'Total' Key being added: 'Total'
at System.Collections.Hashtable.Insert(Object key, Object nvalue, Boolean add)
at System.Collections.Hashtable.Add(Object key, Object value)
at System.Collections.Specialized.NameObjectCollectionBase.BaseAdd(String name, Object value)
at System.Collections.Specialized.NameObjectCollectionBase.BaseSet(String name, Object value)
at System.Web.SessionState.SessionStateItemCollection.set_Item(String name, Object value)
at System.Web.SessionState.HttpSessionStateContainer.set_Item(String name, Object value)
at System.Web.SessionState.HttpSessionState.set_Item(String name, Object value)

Naturally, they complained that ASP.NET allows two threads to share the same session and we get race conditions when the threads simultaneously add the same key to the session collection. System.Collections.Hashtable is not thread safe, so it is session’s job to make sure everything it’s protected.

ASP.NET session works like this: first, the Init method of SessionStateModule is called. Init looks at session configuration options (specified in web.config) and adds event handlers for:

- AcquireRequestState event: BeginAcquireState, EndAcquireState event handlers

- ReleaseRequestState event: OnReleaseState event handler

- EndRequest event: OnEndRequest

Session state is actually acquired when AcquireRequestState event is raised (and not before). A reader-writer lock is used to protect the session from being accessed by multiple requests at the same time. Over the life cycle of the page, session items can be added / removed / edited, but they will be saved back (in SQL Server database for SQLServer session mode, in aspnet_state service for StateServer mode and in cache for in proc) in OnReleaseState (an optimization).

Like I said, when session is acquired, we are taking a lock to make sure the operations are thread safe. But a page can have session

- Disabled (specified with EnableSessionState="false" in the @ Page directive or with <sessionState mode=”Off”/> in web.config). No session is acquired, so we cannot use session.

- Enabled (specified with EnableSessionState="true" in the @ Page directive or with <sessionState mode=”InProc|StateServer|SQLServer|Custom”/> in web.config). In this case we can read and write items, so we acquire the writer lock.

- Read only (specified with EnableSessionState="ReadOnly" in the @ Page directive). In this case, session won’t be saved back in OnReleaseState. We acquire the reader lock.

The reader-writer lock allows multiple readers at the same time and only one writer. Therefore, it’s impossible for 2 threads to modify the same session at the same time. Or is it?

After collecting a lot of logs, turns out the problem was not a session bug. It was caused by Server.Transfer. Their website involved 2 pages, Page1.aspx and Page2.aspx. Page1.aspx called Server.Transfer(“Page2.aspx”). The problem was that Page1.aspx has read-only session, but Page2.aspx requires read-write session. Page1.aspx acquires the reader lock for the session. When running under load, it’s possible to have multiple requests coming at the same time for the same session, and because multiple readers are allowed at the same time, they all get access. Then Page1.aspx transfers to Page2.aspx (with Server.Transfer). After Server.Transfer, the second page uses the session acquired by the previous page. Therefore, the currently executing requests that have the reader lock are trying to write to session => race conditions. That’s why we get the ArgumentException for “object has already been added to the dictionary”: 2 threads try to put the same item in session, the first succeeds, and the second finds out that the item is already there.

The issue has been difficult to investigate, because the exception happens under load. There is a simpler scenario that repros all the time. Imagine we have the same 2 pages. Page1.aspx doesn’t require session – so the session is not acquired at all. But Page2.aspx requires session (read or read write). The first time we are trying to access a session item (to read or write it) from Page2.aspx, we will throw an exception because Session doesn’t exist (it wasn’t acquired for the first page).

The morale of the story: when calling Server.Transfer, be careful how session was acquired and how it is used.