Skip to content

Commit

Permalink
Merge pull request #21 from CoreyAlexander/20-default-providers-creat…
Browse files Browse the repository at this point in the history
…e-strong-references

feat: [Issue20] Add WeakReference to store reference types in a default provider
  • Loading branch information
jolexxa authored Dec 21, 2024
2 parents 68c490d + 466ec87 commit 7b877fa
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 21 deletions.
2 changes: 1 addition & 1 deletion Chickensoft.AutoInject.Tests/coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ dotnet build --no-restore

coverlet \
"./.godot/mono/temp/bin/Debug" --verbosity detailed \
--target $GODOT \
--target "$GODOT" \
--targetargs "--headless --run-tests --coverage --quit-on-finish" \
--format "opencover" \
--output "./coverage/coverage.xml" \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,9 @@ public static TValue DependOn<TValue>(
// First, check dependency fakes. Using a faked value takes priority over
// all the other dependency resolution methods.
var state = dependent.MixinState.Get<DependentState>();
if (state.ProviderFakes.TryGetValue(typeof(TValue), out var fakeProvider)) {
return fakeProvider.Value();
if (state.ProviderFakes.TryGetValue(typeof(TValue), out var fakeProvider)
&& fakeProvider is DefaultProvider<TValue> faker) {
return faker.Value();
}

// Lookup dependency, per usual, respecting any fallback values if there
Expand All @@ -145,15 +146,16 @@ public static TValue DependOn<TValue>(
if (providerNode is IProvide<TValue> provider) {
return provider.Value();
}
else if (providerNode is DefaultProvider defaultProvider) {
else if (providerNode is DefaultProvider<TValue> defaultProvider) {
return defaultProvider.Value();
}
}
else if (fallback is not null) {
// See if we were given a fallback.
var provider = new DefaultProvider(fallback());
var value = fallback();
var provider = new DefaultProvider<TValue>(value, fallback);
state.Dependencies.Add(typeof(TValue), provider);
return (TValue)provider.Value();
return provider.Value();
}

throw new ProviderNotFoundException(typeof(TValue));
Expand Down Expand Up @@ -283,15 +285,45 @@ void onProviderInitialized(IBaseProvider provider) {
// for fallback values.
}

public class DefaultProvider : IBaseProvider {
private readonly dynamic _value;
public class DefaultProvider<TValue> : IBaseProvider {
internal object _value;
private readonly Func<TValue?> _fallback;
public ProviderState ProviderState { get; }

public DefaultProvider(dynamic value) {
_value = value;
// When working with reference types, we must wrap the value in a
// WeakReference() to allow the garbage collection to work when the
// assembly is being unloaded or reloaded; such as in the case of
// rebuilding within the Godot Editor if you've instantiated a node
// and run it as a tool script.
public DefaultProvider(object value, Func<TValue?>? fallback = default) {
_fallback = fallback ?? (() => (TValue?)value);

_value = value.GetType().IsValueType
? value
: new WeakReference(value);

ProviderState = new() { IsInitialized = true };
}

public dynamic Value() => _value;
public TValue Value() {
if (_value is WeakReference weakReference) {
// Try to return a reference type.
if (weakReference.Target is TValue target) {
return target;
}

var value = _fallback() ??
throw new InvalidOperationException(
"Fallback cannot create a null value"
);

_value = new WeakReference(value);

return value;

}
// Return a value type.
return (TValue)_value;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public class DependentState {
/// allows nodes being unit-tested to provide fake providers during unit tests
/// that return mock or faked values.
/// </summary>
public Dictionary<Type, DependencyResolver.DefaultProvider> ProviderFakes {
public DependencyResolver.DependencyTable ProviderFakes {
get;
} = [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,6 @@ this is IIntrospective introspective &&
public void FakeDependency<T>(T value) where T : notnull {
AddStateIfNeeded();
MixinState.Get<DependentState>().ProviderFakes[typeof(T)] =
new DependencyResolver.DefaultProvider(value);
new DependencyResolver.DefaultProvider<T>(value);
}
}
38 changes: 37 additions & 1 deletion Chickensoft.AutoInject.Tests/test/fixtures/Dependents.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Chickensoft.AutoInject.Tests.Subjects;

using System;
using Chickensoft.Introspection;
using Godot;

Expand Down Expand Up @@ -56,15 +57,50 @@ public void OnResolved() {
}
}

[Meta(typeof(IAutoOn), typeof(IDependent))]
public partial class WeakReferenceDependent : Node {
public override void _Notification(int what) => this.Notify(what);

[Dependency]
public object MyDependency => this.DependOn(Fallback);

public Func<object>? Fallback { get; set; }
public bool OnResolvedCalled { get; private set; }

public void OnReady() { }

public void OnResolved() => OnResolvedCalled = true;
}

[Meta(typeof(IAutoOn), typeof(IDependent))]
public partial class ReferenceDependentFallback : Node {
public override void _Notification(int what) => this.Notify(what);

[Dependency]
public object MyDependency => this.DependOn(() => FallbackValue);

public object FallbackValue { get; set; } = new Resource();
public bool OnResolvedCalled { get; private set; }
public object ResolvedValue { get; set; } = null!;

public void OnReady() { }

public void OnResolved() {
OnResolvedCalled = true;
ResolvedValue = MyDependency;
}
}

[Meta(typeof(IAutoOn), typeof(IDependent))]
public partial class IntDependent : Node {
public override void _Notification(int what) => this.Notify(what);

[Dependency]
public int MyDependency => this.DependOn<int>();
public int MyDependency => this.DependOn(FallbackValue);

public bool OnResolvedCalled { get; private set; }
public int ResolvedValue { get; set; }
public Func<int>? FallbackValue { get; set; }

public void OnReady() { }

Expand Down
2 changes: 1 addition & 1 deletion Chickensoft.AutoInject.Tests/test/src/MiscTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public void IDependentOnResolvedDoesNothing() {

[Test]
public void DefaultProviderState() {
var defaultProvider = new DependencyResolver.DefaultProvider("hi");
var defaultProvider = new DependencyResolver.DefaultProvider<string>("hi");
defaultProvider.ProviderState.ShouldNotBeNull();
}
}
92 changes: 89 additions & 3 deletions Chickensoft.AutoInject.Tests/test/src/ResolutionTest.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
namespace Chickensoft.AutoInject.Tests;

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Chickensoft.AutoInject.Tests.Subjects;
using Chickensoft.GoDotTest;
Expand Down Expand Up @@ -131,9 +133,9 @@ public void ThrowsWhenNoProviderFound() {
}

[Test]
public void UsesFallbackValueWhenNoProviderFound() {
var fallback = "Hello, world!";
var dependent = new StringDependentFallback {
public void UsesReferenceFallbackValueWhenNoProviderFound() {
var fallback = new Resource();
var dependent = new ReferenceDependentFallback {
FallbackValue = fallback
};

Expand All @@ -142,6 +144,77 @@ public void UsesFallbackValueWhenNoProviderFound() {
dependent.ResolvedValue.ShouldBe(fallback);
dependent.MyDependency.ShouldBe(fallback);
}

[Test]
public void DependsOnValueType() {
var value = 10;
var depObj = new IntDependent() { FallbackValue = () => value };
var dependent = depObj as IDependent;

depObj._Notification((int)Node.NotificationReady);


depObj.OnResolvedCalled.ShouldBeTrue();
depObj.ResolvedValue.ShouldBe(value);

depObj._Notification((int)Node.NotificationExitTree);

dependent.DependentState.Pending.ShouldBeEmpty();

depObj.QueueFree();
}

[Test]
public void ThrowsIfFallbackProducesNullAfterPreviousValueIsGarbageCollected(
) {
var currentFallback = 0;
var replacementValue = new object();
var fallbacks = new List<object?>() { new(), null, replacementValue };

var value = Utils.CreateWeakReference();

// Fallback will be called 3 times in this test. First will be non-null,
// second will be null, third will be non-null and different from the first.
var depObj = new WeakReferenceDependent() {
Fallback = () => fallbacks[currentFallback++]!
};

var dependent = depObj as IDependent;

depObj._Notification((int)Node.NotificationReady);

// Let's access the fallback value to ensure the default provider is setup.
depObj.MyDependency.ShouldNotBeNull();

// Simulate a garbage collected object. We support weak references to
// dependencies to avoid causing build issues when reloading the scene.
Utils.ClearWeakReference(value);

// To test this highly specific scenario, we have to clear ALL
// weak references to the object, including the one in the default provider
// that's generated behind-the-scenes for us.

// Let's dig out the weak ref used in the default provider from the
// dependent's internal state...
var underlyingDefaultProvider =
(DependencyResolver.DefaultProvider<object>)
depObj.MixinState.Get<DependentState>().Dependencies[typeof(object)];

var actualWeakRef = (WeakReference)underlyingDefaultProvider._value;

Utils.ClearWeakReference(actualWeakRef);

var e = Should.Throw<InvalidOperationException>(
() => depObj.MyDependency
);

e.Message.ShouldContain("cannot create a null value");

// Now that the fallback returns a valid value, the dependency should
// be resolved once again.
depObj.MyDependency.ShouldBeSameAs(replacementValue);
}

[Test]
public void ThrowsOnDependencyTableThatWasTamperedWith() {
var fallback = "Hello, world!";
Expand Down Expand Up @@ -232,4 +305,17 @@ public BadProvider() {
};
}
}

public static class Utils {
public static WeakReference CreateWeakReference() => new(new object());

public static void ClearWeakReference(WeakReference weakReference) {
weakReference.Target = null;

while (weakReference.Target is not null) {
GC.Collect();
GC.WaitForPendingFinalizers();
}
}
}
}
7 changes: 4 additions & 3 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@
"Chickensoft.AutoInject/nupkg/**/*.*"
],
"words": [
"devbuild",
"mktemp",
"skipautoprops",
"assemblyfilters",
"automerge",
"branchcoverage",
Expand All @@ -25,7 +22,9 @@
"Chickensoft",
"classfilters",
"CYGWIN",
"devbuild",
"endregion",
"Finalizers",
"globaltool",
"godotengine",
"godotpackage",
Expand All @@ -35,6 +34,7 @@
"Metatype",
"methodcoverage",
"missingall",
"mktemp",
"msbuild",
"MSYS",
"nameof",
Expand All @@ -52,6 +52,7 @@
"reportgenerator",
"reporttypes",
"Shouldly",
"skipautoprops",
"subfolders",
"targetargs",
"targetdir",
Expand Down

0 comments on commit 7b877fa

Please sign in to comment.