From 8c97a63a82729efe56d73928e9afb436dbffea56 Mon Sep 17 00:00:00 2001
From: Jesse Plamondon-Willard <github@jplamondonw.com>
Date: Mon, 23 Oct 2017 03:24:53 -0400
Subject: improve content manager thread safety, create content cache wrapper
 (#373)

---
 src/SMAPI/Framework/Content/ContentCache.cs | 150 ++++++++++++++++++
 src/SMAPI/Framework/SContentManager.cs      | 234 ++++++++++++++++------------
 src/SMAPI/Framework/SGame.cs                |   2 +-
 src/SMAPI/StardewModdingAPI.csproj          |   1 +
 4 files changed, 284 insertions(+), 103 deletions(-)
 create mode 100644 src/SMAPI/Framework/Content/ContentCache.cs

diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs
new file mode 100644
index 00000000..10c41d08
--- /dev/null
+++ b/src/SMAPI/Framework/Content/ContentCache.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Contracts;
+using System.Linq;
+using Microsoft.Xna.Framework;
+using StardewModdingAPI.Framework.ModLoading;
+using StardewModdingAPI.Framework.Reflection;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.Content
+{
+    /// <summary>A low-level wrapper around the content cache which handles reading, writing, and invalidating entries in the cache. This doesn't handle any higher-level logic like localisation, loading content, etc. It assumes all keys passed in are already normalised.</summary>
+    internal class ContentCache
+    {
+        /*********
+        ** Properties
+        *********/
+        /// <summary>The underlying asset cache.</summary>
+        private readonly IDictionary<string, object> Cache;
+
+        /// <summary>The possible directory separator characters in an asset key.</summary>
+        private readonly char[] PossiblePathSeparators;
+
+        /// <summary>The preferred directory separator chaeacter in an asset key.</summary>
+        private readonly string PreferredPathSeparator;
+
+        /// <summary>Applies platform-specific asset key normalisation so it's consistent with the underlying cache.</summary>
+        private readonly Func<string, string> NormaliseAssetNameForPlatform;
+
+
+        /*********
+        ** Accessors
+        *********/
+        /// <summary>Get or set the value of a raw cache entry.</summary>
+        /// <param name="key">The cache key.</param>
+        public object this[string key]
+        {
+            get => this.Cache[key];
+            set => this.Cache[key] = value;
+        }
+
+        /// <summary>The current cache keys.</summary>
+        public IEnumerable<string> Keys => this.Cache.Keys;
+
+
+        /*********
+        ** Public methods
+        *********/
+        /****
+        ** Constructor
+        ****/
+        /// <summary>Construct an instance.</summary>
+        /// <param name="contentManager">The underlying content manager whose cache to manage.</param>
+        /// <param name="reflection">Simplifies access to private game code.</param>
+        /// <param name="possiblePathSeparators">The possible directory separator characters in an asset key.</param>
+        /// <param name="preferredPathSeparator">The preferred directory separator chaeacter in an asset key.</param>
+        public ContentCache(LocalizedContentManager contentManager, Reflector reflection, char[] possiblePathSeparators, string preferredPathSeparator)
+        {
+            // init
+            this.Cache = reflection.GetPrivateField<Dictionary<string, object>>(contentManager, "loadedAssets").GetValue();
+            this.PossiblePathSeparators = possiblePathSeparators;
+            this.PreferredPathSeparator = preferredPathSeparator;
+
+            // get key normalisation logic
+            if (Constants.TargetPlatform == Platform.Windows)
+            {
+                IPrivateMethod method = reflection.GetPrivateMethod(typeof(TitleContainer), "GetCleanPath");
+                this.NormaliseAssetNameForPlatform = path => method.Invoke<string>(path);
+            }
+            else
+                this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load<T> logic
+        }
+
+        /****
+        ** Fetch
+        ****/
+        /// <summary>Get whether the cache contains a given key.</summary>
+        /// <param name="key">The cache key.</param>
+        public bool ContainsKey(string key)
+        {
+            return this.Cache.ContainsKey(key);
+        }
+
+
+        /****
+        ** Normalise
+        ****/
+        /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseKey"/> instead.</summary>
+        /// <param name="path">The file path to normalise.</param>
+        [Pure]
+        public string NormalisePathSeparators(string path)
+        {
+            string[] parts = path.Split(this.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries);
+            string normalised = string.Join(this.PreferredPathSeparator, parts);
+            if (path.StartsWith(this.PreferredPathSeparator))
+                normalised = this.PreferredPathSeparator + normalised; // keep root slash
+            return normalised;
+        }
+
+        /// <summary>Normalise a cache key so it's consistent with the underlying cache.</summary>
+        /// <param name="key">The asset key.</param>
+        [Pure]
+        public string NormaliseKey(string key)
+        {
+            key = this.NormalisePathSeparators(key);
+            return key.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase)
+                ? key.Substring(0, key.Length - 4)
+                : this.NormaliseAssetNameForPlatform(key);
+        }
+
+        /****
+        ** Remove
+        ****/
+        /// <summary>Remove an asset with the given key.</summary>
+        /// <param name="key">The cache key.</param>
+        /// <param name="dispose">Whether to dispose the entry value, if applicable.</param>
+        /// <returns>Returns the removed key (if any).</returns>
+        public bool Remove(string key, bool dispose)
+        {
+            // get entry
+            if (!this.Cache.TryGetValue(key, out object value))
+                return false;
+
+            // dispose & remove entry
+            if (dispose && value is IDisposable disposable)
+                disposable.Dispose();
+
+            return this.Cache.Remove(key);
+        }
+
+        /// <summary>Purge matched assets from the cache.</summary>
+        /// <param name="predicate">Matches the asset keys to invalidate.</param>
+        /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
+        /// <returns>Returns the removed keys (if any).</returns>
+        public IEnumerable<string> Remove(Func<string, Type, bool> predicate, bool dispose = false)
+        {
+            List<string> removed = new List<string>();
+            foreach (string key in this.Cache.Keys.ToArray())
+            {
+                Type type = this.Cache[key].GetType();
+                if (predicate(key, type))
+                {
+                    this.Remove(key, dispose);
+                    removed.Add(key);
+                }
+            }
+            return removed;
+        }
+    }
+}
diff --git a/src/SMAPI/Framework/SContentManager.cs b/src/SMAPI/Framework/SContentManager.cs
index 2f5d104f..0b6daaa6 100644
--- a/src/SMAPI/Framework/SContentManager.cs
+++ b/src/SMAPI/Framework/SContentManager.cs
@@ -1,13 +1,13 @@
 using System;
 using System.Collections.Generic;
+using System.Diagnostics.Contracts;
 using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Reflection;
-using Microsoft.Xna.Framework;
+using System.Threading;
 using Microsoft.Xna.Framework.Content;
 using StardewModdingAPI.Framework.Content;
-using StardewModdingAPI.Framework.ModLoading;
 using StardewModdingAPI.Framework.Reflection;
 using StardewModdingAPI.Framework.Utilities;
 using StardewModdingAPI.Metadata;
@@ -15,7 +15,17 @@ using StardewValley;
 
 namespace StardewModdingAPI.Framework
 {
-    /// <summary>SMAPI's implementation of the game's content manager which lets it raise content events.</summary>
+    /// <summary>A thread-safe content manager which intercepts assets being loaded to let SMAPI mods inject or edit them.</summary>
+    /// <remarks>
+    /// This is the centralised content manager which manages all game assets. The game and mods don't use this class
+    /// directly; instead they use one of several <see cref="ContentManagerShim"/> instances, which proxy requests to
+    /// this class. That ensures that when the game disposes one content manager, the others can continue unaffected.
+    /// That notably requires this class to be thread-safe, since the content managers can be disposed asynchronously.
+    /// 
+    /// Note that assets in the cache have two identifiers: the asset name (like "bundles") and key (like "bundles.pt-BR").
+    /// For English and non-translatable assets, these have the same value. The underlying cache only knows about asset
+    /// keys, and the game and mods only know about asset names. The content manager handles resolving them.
+    /// </remarks>
     internal class SContentManager : LocalizedContentManager
     {
         /*********
@@ -27,11 +37,8 @@ namespace StardewModdingAPI.Framework
         /// <summary>Encapsulates monitoring and logging.</summary>
         private readonly IMonitor Monitor;
 
-        /// <summary>The underlying content manager's asset cache.</summary>
-        private readonly IDictionary<string, object> Cache;
-
-        /// <summary>Applies platform-specific asset key normalisation so it's consistent with the underlying cache.</summary>
-        private readonly Func<string, string> NormaliseAssetNameForPlatform;
+        /// <summary>The underlying asset cache.</summary>
+        private readonly ContentCache Cache;
 
         /// <summary>The private <see cref="LocalizedContentManager"/> method which generates the locale portion of an asset name.</summary>
         private readonly IPrivateMethod GetKeyLocale;
@@ -46,10 +53,10 @@ namespace StardewModdingAPI.Framework
         private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>();
 
         /// <summary>A lookup of the content managers which loaded each asset.</summary>
-        private readonly IDictionary<string, HashSet<ContentManager>> AssetLoaders = new Dictionary<string, HashSet<ContentManager>>();
+        private readonly IDictionary<string, HashSet<ContentManager>> ContentManagersByAssetKey = new Dictionary<string, HashSet<ContentManager>>();
 
-        /// <summary>An object locked to prevent concurrent changes to the underlying assets.</summary>
-        private readonly object Lock = new object();
+        /// <summary>A lock used to prevents concurrent changes to the cache while data is being read.</summary>
+        private readonly ReaderWriterLockSlim Lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
 
 
         /*********
@@ -77,30 +84,15 @@ namespace StardewModdingAPI.Framework
         /// <param name="currentCulture">The current culture for which to localise content.</param>
         /// <param name="languageCodeOverride">The current language code for which to localise content.</param>
         /// <param name="monitor">Encapsulates monitoring and logging.</param>
-        public SContentManager(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride, IMonitor monitor)
+        /// <param name="reflection">Simplifies access to private code.</param>
+        public SContentManager(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride, IMonitor monitor, Reflector reflection)
             : base(serviceProvider, rootDirectory, currentCulture, languageCodeOverride)
         {
-            // validate
-            if (monitor == null)
-                throw new ArgumentNullException(nameof(monitor));
-
-            // initialise
-            var reflection = new Reflector();
-            this.Monitor = monitor;
-
-            // get underlying fields for interception
-            this.Cache = reflection.GetPrivateField<Dictionary<string, object>>(this, "loadedAssets").GetValue();
+            // init
+            this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
+            this.Cache = new ContentCache(this, reflection, SContentManager.PossiblePathSeparators, SContentManager.PreferredPathSeparator);
             this.GetKeyLocale = reflection.GetPrivateMethod(this, "languageCode");
 
-            // get asset key normalisation logic
-            if (Constants.TargetPlatform == Platform.Windows)
-            {
-                IPrivateMethod method = reflection.GetPrivateMethod(typeof(TitleContainer), "GetCleanPath");
-                this.NormaliseAssetNameForPlatform = path => method.Invoke<string>(path);
-            }
-            else
-                this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load<T> logic
-
             // get asset data
             this.CoreAssets = new CoreAssets(this.NormaliseAssetName);
             this.KeyLocales = this.GetKeyLocales(reflection);
@@ -108,34 +100,26 @@ namespace StardewModdingAPI.Framework
 
         /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseAssetName"/> instead.</summary>
         /// <param name="path">The file path to normalise.</param>
+        [Pure]
         public string NormalisePathSeparators(string path)
         {
-            string[] parts = path.Split(SContentManager.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries);
-            string normalised = string.Join(SContentManager.PreferredPathSeparator, parts);
-            if (path.StartsWith(SContentManager.PreferredPathSeparator))
-                normalised = SContentManager.PreferredPathSeparator + normalised; // keep root slash
-            return normalised;
+            return this.Cache.NormalisePathSeparators(path);
         }
 
         /// <summary>Normalise an asset name so it's consistent with the underlying cache.</summary>
         /// <param name="assetName">The asset key.</param>
+        [Pure]
         public string NormaliseAssetName(string assetName)
         {
-            assetName = this.NormalisePathSeparators(assetName);
-            if (assetName.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase))
-                return assetName.Substring(0, assetName.Length - 4);
-            return this.NormaliseAssetNameForPlatform(assetName);
+            return this.Cache.NormaliseKey(assetName);
         }
 
         /// <summary>Get whether the content manager has already loaded and cached the given asset.</summary>
         /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
         public bool IsLoaded(string assetName)
         {
-            lock (this.Lock)
-            {
-                assetName = this.NormaliseAssetName(assetName);
-                return this.IsNormalisedKeyLoaded(assetName);
-            }
+            assetName = this.Cache.NormaliseKey(assetName);
+            return this.WithReadLock(() => this.IsNormalisedKeyLoaded(assetName));
         }
 
         /// <summary>Load an asset that has been processed by the content pipeline.</summary>
@@ -152,10 +136,9 @@ namespace StardewModdingAPI.Framework
         /// <param name="instance">The content manager instance for which to load the asset.</param>
         public T LoadFor<T>(string assetName, ContentManager instance)
         {
-            lock (this.Lock)
+            assetName = this.NormaliseAssetName(assetName);
+            return this.WithWriteLock(() =>
             {
-                assetName = this.NormaliseAssetName(assetName);
-
                 // skip if already loaded
                 if (this.IsNormalisedKeyLoaded(assetName))
                 {
@@ -186,7 +169,7 @@ namespace StardewModdingAPI.Framework
                 this.Cache[assetName] = data;
                 this.TrackAssetLoader(assetName, instance);
                 return data;
-            }
+            });
         }
 
         /// <summary>Inject an asset into the cache.</summary>
@@ -195,12 +178,12 @@ namespace StardewModdingAPI.Framework
         /// <param name="value">The asset value.</param>
         public void Inject<T>(string assetName, T value)
         {
-            lock (this.Lock)
+            this.WithWriteLock(() =>
             {
                 assetName = this.NormaliseAssetName(assetName);
                 this.Cache[assetName] = value;
                 this.TrackAssetLoader(assetName, this);
-            }
+            });
         }
 
         /// <summary>Get the current content locale.</summary>
@@ -212,19 +195,11 @@ namespace StardewModdingAPI.Framework
         /// <summary>Get the cached asset keys.</summary>
         public IEnumerable<string> GetAssetKeys()
         {
-            lock (this.Lock)
-            {
-                IEnumerable<string> GetAllAssetKeys()
-                {
-                    foreach (string cacheKey in this.Cache.Keys)
-                    {
-                        this.ParseCacheKey(cacheKey, out string assetKey, out string _);
-                        yield return assetKey;
-                    }
-                }
-
-                return GetAllAssetKeys().Distinct();
-            }
+            return this.WithReadLock(() =>
+                this.Cache.Keys
+                    .Select(this.GetAssetName)
+                    .Distinct()
+            );
         }
 
         /// <summary>Purge assets from the cache that match one of the interceptors.</summary>
@@ -239,11 +214,12 @@ namespace StardewModdingAPI.Framework
             // get CanEdit/Load methods
             MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit));
             MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad));
+            if (canEdit == null || canLoad == null)
+                throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen
 
             // invalidate matching keys
             return this.InvalidateCache((assetName, assetType) =>
             {
-                // get asset metadata
                 IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, assetType, this.NormaliseAssetName);
 
                 // check loaders
@@ -263,48 +239,44 @@ namespace StardewModdingAPI.Framework
         /// <returns>Returns whether any cache entries were invalidated.</returns>
         public bool InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
         {
-            lock (this.Lock)
+            return this.WithWriteLock(() =>
             {
-                // find matching asset keys
-                HashSet<string> purgeCacheKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
-                HashSet<string> purgeAssetKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
-                foreach (string cacheKey in this.Cache.Keys)
+                // invalidate matching keys
+                HashSet<string> removeKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+                HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+                this.Cache.Remove((key, type) =>
                 {
-                    this.ParseCacheKey(cacheKey, out string assetKey, out _);
-                    Type type = this.Cache[cacheKey].GetType();
-                    if (predicate(assetKey, type))
+                    this.ParseCacheKey(key, out string assetName, out _);
+                    if (removeAssetNames.Contains(assetName) || predicate(assetName, type))
                     {
-                        purgeAssetKeys.Add(assetKey);
-                        purgeCacheKeys.Add(cacheKey);
+                        removeAssetNames.Add(assetName);
+                        removeKeys.Add(key);
+                        return true;
                     }
-                }
+                    return false;
+                });
 
-                // purge assets
-                foreach (string key in purgeCacheKeys)
-                {
-                    if (dispose && this.Cache[key] is IDisposable disposable)
-                        disposable.Dispose();
-                    this.Cache.Remove(key);
-                    this.AssetLoaders.Remove(key);
-                }
+                // update reference tracking
+                foreach (string key in removeKeys)
+                    this.ContentManagersByAssetKey.Remove(key);
 
                 // reload core game assets
                 int reloaded = 0;
-                foreach (string key in purgeAssetKeys)
+                foreach (string key in removeAssetNames)
                 {
                     if (this.CoreAssets.ReloadForKey(this, key))
                         reloaded++;
                 }
 
                 // report result
-                if (purgeCacheKeys.Any())
+                if (removeKeys.Any())
                 {
-                    this.Monitor.Log($"Invalidated {purgeCacheKeys.Count} cache entries for {purgeAssetKeys.Count} asset keys: {string.Join(", ", purgeCacheKeys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
+                    this.Monitor.Log($"Invalidated {removeAssetNames.Count} asset names: {string.Join(", ", removeKeys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
                     return true;
                 }
                 this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
                 return false;
-            }
+            });
         }
 
         /// <summary>Dispose assets for the given content manager shim.</summary>
@@ -313,15 +285,26 @@ namespace StardewModdingAPI.Framework
         {
             this.Monitor.Log($"Content manager '{shim.Name}' disposed, disposing assets that aren't needed by any other asset loader.", LogLevel.Trace);
 
-            foreach (var entry in this.AssetLoaders)
-                entry.Value.Remove(shim);
-            this.InvalidateCache((key, type) => !this.AssetLoaders[key].Any(), dispose: true);
+            this.WithWriteLock(() =>
+            {
+                foreach (var entry in this.ContentManagersByAssetKey)
+                    entry.Value.Remove(shim);
+                this.InvalidateCache((key, type) => !this.ContentManagersByAssetKey[key].Any(), dispose: true);
+            });
         }
 
 
         /*********
         ** Private methods
         *********/
+        /// <summary>Dispose held resources.</summary>
+        /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param>
+        protected override void Dispose(bool disposing)
+        {
+            this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace);
+            base.Dispose(disposing);
+        }
+
         /// <summary>Get whether an asset has already been loaded.</summary>
         /// <param name="normalisedAssetName">The normalised asset name.</param>
         private bool IsNormalisedKeyLoaded(string normalisedAssetName)
@@ -335,8 +318,8 @@ namespace StardewModdingAPI.Framework
         /// <param name="manager">The content manager that loaded the asset.</param>
         private void TrackAssetLoader(string key, ContentManager manager)
         {
-            if (!this.AssetLoaders.TryGetValue(key, out HashSet<ContentManager> hash))
-                hash = this.AssetLoaders[key] = new HashSet<ContentManager>();
+            if (!this.ContentManagersByAssetKey.TryGetValue(key, out HashSet<ContentManager> hash))
+                hash = this.ContentManagersByAssetKey[key] = new HashSet<ContentManager>();
             hash.Add(manager);
         }
 
@@ -367,11 +350,19 @@ namespace StardewModdingAPI.Framework
             return map;
         }
 
+        /// <summary>Get the asset name from a cache key.</summary>
+        /// <param name="cacheKey">The input cache key.</param>
+        private string GetAssetName(string cacheKey)
+        {
+            this.ParseCacheKey(cacheKey, out string assetName, out string _);
+            return assetName;
+        }
+
         /// <summary>Parse a cache key into its component parts.</summary>
         /// <param name="cacheKey">The input cache key.</param>
-        /// <param name="assetKey">The original asset key.</param>
+        /// <param name="assetName">The original asset name.</param>
         /// <param name="localeCode">The asset locale code (or <c>null</c> if not localised).</param>
-        private void ParseCacheKey(string cacheKey, out string assetKey, out string localeCode)
+        private void ParseCacheKey(string cacheKey, out string assetName, out string localeCode)
         {
             // handle localised key
             if (!string.IsNullOrWhiteSpace(cacheKey))
@@ -382,7 +373,7 @@ namespace StardewModdingAPI.Framework
                     string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
                     if (this.KeyLocales.ContainsKey(suffix))
                     {
-                        assetKey = cacheKey.Substring(0, lastSepIndex);
+                        assetName = cacheKey.Substring(0, lastSepIndex);
                         localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
                         return;
                     }
@@ -390,7 +381,7 @@ namespace StardewModdingAPI.Framework
             }
 
             // handle simple key
-            assetKey = cacheKey;
+            assetName = cacheKey;
             localeCode = null;
         }
 
@@ -519,12 +510,51 @@ namespace StardewModdingAPI.Framework
             }
         }
 
-        /// <summary>Dispose held resources.</summary>
-        /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param>
-        protected override void Dispose(bool disposing)
+        /// <summary>Acquire a read lock which prevents concurrent writes to the cache while it's open.</summary>
+        /// <typeparam name="T">The action's return value.</typeparam>
+        /// <param name="action">The action to perform.</param>
+        private T WithReadLock<T>(Func<T> action)
         {
-            this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace);
-            base.Dispose(disposing);
+            try
+            {
+                this.Lock.EnterReadLock();
+                return action();
+            }
+            finally
+            {
+                this.Lock.ExitReadLock();
+            }
+        }
+
+        /// <summary>Acquire a write lock which prevents concurrent reads or writes to the cache while it's open.</summary>
+        /// <param name="action">The action to perform.</param>
+        private void WithWriteLock(Action action)
+        {
+            try
+            {
+                this.Lock.EnterWriteLock();
+                action();
+            }
+            finally
+            {
+                this.Lock.ExitWriteLock();
+            }
+        }
+
+        /// <summary>Acquire a write lock which prevents concurrent reads or writes to the cache while it's open.</summary>
+        /// <typeparam name="T">The action's return value.</typeparam>
+        /// <param name="action">The action to perform.</param>
+        private T WithWriteLock<T>(Func<T> action)
+        {
+            try
+            {
+                this.Lock.EnterReadLock();
+                return action();
+            }
+            finally
+            {
+                this.Lock.ExitReadLock();
+            }
         }
     }
 }
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index ca19d726..c62c1393 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -179,7 +179,7 @@ namespace StardewModdingAPI.Framework
 
             // override content manager
             this.Monitor?.Log("Overriding content manager...", LogLevel.Trace);
-            this.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor);
+            this.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor, reflection);
             this.Content = new ContentManagerShim(this.SContentManager, "SGame.Content");
             Game1.content = new ContentManagerShim(this.SContentManager, "Game1.content");
             reflection.GetPrivateField<LocalizedContentManager>(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager
diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj
index 6f7c2b3f..605292b2 100644
--- a/src/SMAPI/StardewModdingAPI.csproj
+++ b/src/SMAPI/StardewModdingAPI.csproj
@@ -89,6 +89,7 @@
     <Compile Include="..\..\build\GlobalAssemblyInfo.cs">
       <Link>Properties\GlobalAssemblyInfo.cs</Link>
     </Compile>
+    <Compile Include="Framework\Content\ContentCache.cs" />
     <Compile Include="Framework\Models\ModCompatibility.cs" />
     <Compile Include="Framework\ModLoading\Finders\EventFinder.cs" />
     <Compile Include="Framework\ModLoading\Finders\FieldFinder.cs" />
-- 
cgit