Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [Issue20] Add WeakReference to store reference types in a default provider #21

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading