improve content manager thread safety, create content cache wrapper (#373)

This commit is contained in:
Jesse Plamondon-Willard 2017-10-23 03:24:53 -04:00
parent ed56cb714d
commit 8c97a63a82
4 changed files with 284 additions and 103 deletions

View File

@ -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;
}
}
}

View File

@ -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>
@ -151,11 +135,10 @@ namespace StardewModdingAPI.Framework
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <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(() =>
{
// 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)
this.WithWriteLock(() =>
{
foreach (var entry in this.ContentManagersByAssetKey)
entry.Value.Remove(shim);
this.InvalidateCache((key, type) => !this.AssetLoaders[key].Any(), dispose: true);
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();
}
}
}
}

View File

@ -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

View File

@ -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" />