Skip to content
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
6 changes: 6 additions & 0 deletions CheatEngine.Client.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
Path="tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj"/>
<Project Path="tests/CheatEngine.Client.Fluent.Tests/CheatEngine.Client.Fluent.Tests.csproj"/>
<Project Path="tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj"/>
<Project Path="tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CheatEngine.Client.LivePlugin.Coexistence.PluginA.csproj">
<Platform Project="x64"/>
</Project>
<Project Path="tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CheatEngine.Client.LivePlugin.Coexistence.PluginB.csproj">
<Platform Project="x64"/>
</Project>
<Project
Path="tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngine.Client.SourceGenerators.Lua.Tests.csproj"/>
<Project Path="tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj"/>
Expand Down
7 changes: 6 additions & 1 deletion docs/adr/0002-plugin-activation-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,15 @@ diagnostics. Module callbacks and Client-owned CE resource draining are never ru
provider construction; reloadable runtime configuration would violate the activation boundary.
- A Client scope, cancellation token, SDK handle, Lua reference, or CE-owned resource never crosses an enable/disable
epoch. `ICheatEngineClient.Epoch` and `Stopping` identify the active lifetime.
- Activation-local is the fresh-provider boundary, not a blanket `ServiceLifetime.Scoped` rule. A second scope from
one provider has fresh scoped application/module services but reuses provider singletons, options, codecs, and the
Client graph; it is not another activation and is not supported as a persistent-root hosting model.
- Public Client operations are synchronous. They do not retain Lua state across an `await`, and main-thread work enters
the SDK dispatcher as a bounded operation.
- The DI container owns disposal of services it created. Hosting owns the `ConfigurationManager` instance it created
and releases it once after the scope and provider; it never disposes resolved services individually.
and releases it once after the scope and provider; it never disposes resolved services individually. A disposable
application service has one owning registration; aliases must not make one disposable instance container-owned by
multiple descriptors.

## Consequences and project value

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,21 @@ services.AddCheatEngineClient(configuration)
For an SDK-loaded plugin, use `CheatEngine.Client.Hosting` instead of manually building this collection. The hosting
package creates one validating provider for each enable epoch and resolves `IOptions<CheatEngineClientOptions>`
immediately, so generated and semantic validation run before Client work starts.

## Provider and scope contract

`AddCheatEngineClient` configures one provider; it does not define a persistent application root. The supported plugin
path builds a **fresh provider per enable epoch**, then opens one activation scope. The Core Client graph, options, and
deterministic codecs are intentionally provider-local singleton registrations: that is safe because the provider itself
is discarded at disable. Modules are scoped so they can consume scoped application services, but their scoped lifetime
does not make a second scope in the same provider a fresh Client activation.

Two scopes made from one external provider are ordinary sibling DI scopes. Their scoped services and modules differ,
while provider singletons, options, and Client services remain shared until the provider is disposed. Do not reuse such
a provider across Cheat Engine enable epochs; Client does not offer a persistent-root hosting mode or an activation
factory for it. Any future external-provider model must specify and test its activation-bound registrations separately.

The container disposes services it creates at their scope/provider boundary. Do not dispose services resolved from DI
in a module or plugin callback, and do not register one disposable object through multiple forwarding aliases. Give the
disposable one owning descriptor; expose an additional non-disposable facade when an application needs an alias. The
host itself owns only its `ConfigurationManager`, which it releases after the scope and provider.
22 changes: 22 additions & 0 deletions libs/CheatEngine.Client.Hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ On disable—or if activation fails—the host invokes `OnClientDisabling` and m
Client-owned Cheat Engine resources while the SDK context remains valid, disposes the scope and provider, and finally
disposes activation configuration. Cleanup failures are aggregated after all cleanup opportunities have run.

## Composition lifetime and DI ownership

`CheatEngineClientPlugin` is the supported composition root: every `OnEnable` creates a new
`CheatEnginePluginBuilder`, builds a new provider, and creates one activation scope from that provider. The Core Client
graph intentionally uses provider-local singleton registrations, so **activation-local** means “owned by this new
provider,” not “registered with Microsoft DI's `Scoped` lifetime.” A disable/re-enable cycle therefore constructs a
new Client graph, options cache, deterministic codecs, and module state without mechanically changing their DI
lifetimes.

Creating a second `IServiceScope` from the same provider does not create another Client activation. That second scope
has its own scoped application services and modules, but shares the provider's singleton Client graph, options, and
codecs; scopes are siblings, not nested activation roots. Hosting opens exactly one such scope for an enable epoch.
An integrator that needs an external persistent root must first introduce and qualify an explicit activation-factory
design—repeated `CreateScope()` calls are not a supported substitute.

The DI container owns the objects that it creates. Hosting never disposes resolved modules or services individually:
after lifecycle callbacks and Client resource drain, it disposes the activation scope, then the provider, and finally
its own `ConfigurationManager`. Register an application `IDisposable` under one owning descriptor. If application code
needs another service view of that object, use a non-disposable facade/projection or make ownership explicit; do not
forward the same disposable instance through multiple DI descriptors and expect Hosting to de-duplicate its disposal.
Instances supplied directly by the application remain application-owned.

Construction failure has a narrower rollback: only resources acquired before publication are released, once each, in
reverse construction order (`scope → provider → configuration`). Every stage is attempted even if an earlier disposal
fails. The original configuration, validation, or service-resolution failure remains primary; cleanup failures are
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ this project inside the template instead of maintaining a separate `samples/` co
The project provides a minimal but production-shaped plugin boundary:

- `[CheatEnginePlugin]` is the SDK entry-point annotation recognized by the generated bootstrap.
- `CheatEngineClientPlugin` creates a fresh DI container and Client activation for every enable cycle.
- `CheatEngineClientPlugin` creates a fresh DI container and Client activation for every enable cycle. The Client graph,
options, and codecs are provider-local singletons; the module scope is the one scope inside that new provider, not a
persistent root that can be reused for a later enable.
- `Configure` explicitly loads the optional `appsettings.json` beside the plugin with `reloadOnChange: false` and
registers the generated `PluginLuaModule` through `AddLuaModule<PluginLuaModule>()`, then the application module.
- `PluginClientModule` demonstrates options, logging, a bounded AOB request, typed memory access, an Address List
Expand Down Expand Up @@ -70,6 +72,10 @@ list. Because Windows cannot transactionally swap a non-empty directory, run it
loading a table can execute Lua. Configuration and module registrations are rebuilt at the next plugin enable, not
reloaded while an activation is active.

Let DI dispose objects that it creates. A module receives its disposable dependencies but does not call `Dispose` on
them; Hosting closes the activation scope and provider after module callbacks. Register a disposable implementation
under one owning service descriptor, and use a non-disposable facade if the application needs a second service view.

Before deployment, replace the illustrative AOB pattern and offset in `Modules/PluginClientModule.cs`, and choose an
application-specific Lua global name in `Modules/PluginLuaFunctions.cs`. Keep AOB operations bounded and avoid logging
memory contents or Lua scripts by default.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace CheatEngine.Client.Hosting.Tests;

Expand Down Expand Up @@ -326,6 +327,41 @@ public void FailedModuleEnableRollsBackAndTheSamePluginCanEnableAgain()
events);
}

[Fact]
public void FreshActivationProvidersDisposeOwnedModuleDependenciesOnceWhileAliasesAndOptionsRemainUsable()
{
List<string> events = [];
FakeClient client = new(50);
RecordingCleanup cleanup = new(events);
DisposableModuleState state = new();
TestPlugin plugin = CreatePlugin(events, client, cleanup, builder =>
{
builder.Configuration["CheatEngineClient:AllowedTableRoots:0"] = Path.GetTempPath();
builder.Services.AddSingleton(state);
builder.Services.AddSingleton<ProviderOwnedActivationSingleton>();
builder.Services.AddScoped<ActivationOwnedDisposable>();
builder.Services.AddScoped<IActivationOwnedAlias>(static services => new ActivationOwnedAlias(
services.GetRequiredService<ActivationOwnedDisposable>()));
builder.Client.AddModule<DisposableOptionsModule>();
});

plugin.EnableForTest();
plugin.DisableForTest();
plugin.EnableForTest();
plugin.DisableForTest();

Assert.True(state.AliasReferencedOwnedDisposable);
Assert.Equal(1, state.AllowedTableRootCount);
Assert.Equal(2, state.EnabledModuleIds.Count);
Assert.NotEqual(state.EnabledModuleIds[0], state.EnabledModuleIds[1]);
Assert.Equal(2, state.ProviderSingletonIds.Count);
Assert.NotEqual(state.ProviderSingletonIds[0], state.ProviderSingletonIds[1]);
Assert.Equal(2, state.ModuleDisableCount);
Assert.Equal(2, state.ModuleDisposeCount);
Assert.Equal(2, state.DependencyDisposeCount);
Assert.Equal(2, state.ProviderSingletonDisposeCount);
}

private static void AddFailingConstructionRegistrations(CheatEnginePluginBuilder builder, List<string> events)
{
builder.Configuration.Sources.Add(new ThrowingDisposeConfigurationSource(events));
Expand Down Expand Up @@ -518,6 +554,122 @@ public bool HasFailed
}
}

public sealed class DisposableModuleState
{
internal bool AliasReferencedOwnedDisposable
{
get;
set;
}

internal int AllowedTableRootCount
{
get;
set;
}

internal List<Guid> EnabledModuleIds
{
get;
} = [];

internal List<Guid> ProviderSingletonIds
{
get;
} = [];

internal int ModuleDisableCount
{
get;
set;
}

internal int ModuleDisposeCount
{
get;
set;
}

internal int DependencyDisposeCount
{
get;
set;
}

internal int ProviderSingletonDisposeCount
{
get;
set;
}
}

public sealed class ProviderOwnedActivationSingleton(DisposableModuleState state) : IDisposable
{
internal Guid Id
{
get;
} = Guid.NewGuid();

public void Dispose()
{
state.ProviderSingletonDisposeCount++;
}
}

public sealed class ActivationOwnedDisposable(DisposableModuleState state) : IDisposable
{
public void Dispose()
{
state.DependencyDisposeCount++;
}
}

public interface IActivationOwnedAlias
{
ActivationOwnedDisposable OwnedDisposable
{
get;
}
}

public sealed class ActivationOwnedAlias(ActivationOwnedDisposable ownedDisposable) : IActivationOwnedAlias
{
public ActivationOwnedDisposable OwnedDisposable
{
get;
} = ownedDisposable;
}

public sealed class DisposableOptionsModule(
ProviderOwnedActivationSingleton providerOwnedSingleton,
ActivationOwnedDisposable ownedDisposable,
IActivationOwnedAlias alias,
IOptions<CheatEngineClientOptions> options,
DisposableModuleState state) : ICheatEngineClientModule, IDisposable
{
private readonly Guid _id = Guid.NewGuid();

public void OnEnabled(ICheatEngineClient client)
{
ArgumentNullException.ThrowIfNull(client);
state.AliasReferencedOwnedDisposable = ReferenceEquals(ownedDisposable, alias.OwnedDisposable);
state.AllowedTableRootCount = options.Value.AllowedTableRoots?.Length ?? 0;
state.EnabledModuleIds.Add(_id);
state.ProviderSingletonIds.Add(providerOwnedSingleton.Id);
}

public void OnDisabling(ICheatEngineClient client)
{
ArgumentNullException.ThrowIfNull(client);
state.ModuleDisableCount++;
}

public void Dispose()
{
state.ModuleDisposeCount++;
}
}

private sealed class RecordingCleanup(
List<string> events,
Exception? enterFailure = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,67 @@ public void BuildServiceProviderIsSingleUse()

Assert.Contains("only one provider", exception.Message, StringComparison.Ordinal);
}

[Fact]
public void TwoScopesShareProviderSingletonsButDisposeTheirOwnScopedServices()
{
CheatEnginePluginBuilder builder = new();
builder.Services.AddSingleton<ProviderOwnedDisposable>();
builder.Services.AddScoped<ScopeOwnedDisposable>();
ProviderOwnedDisposable providerOwned;
ScopeOwnedDisposable firstScoped;
ScopeOwnedDisposable secondScoped;

using (ServiceProvider provider = builder.BuildServiceProvider())
{
using (IServiceScope firstScope = provider.CreateScope())
{
providerOwned = firstScope.ServiceProvider.GetRequiredService<ProviderOwnedDisposable>();
firstScoped = firstScope.ServiceProvider.GetRequiredService<ScopeOwnedDisposable>();
}

Assert.Equal(1, firstScoped.DisposeCount);
Assert.Equal(0, providerOwned.DisposeCount);

using (IServiceScope secondScope = provider.CreateScope())
{
Assert.Same(providerOwned, secondScope.ServiceProvider.GetRequiredService<ProviderOwnedDisposable>());
secondScoped = secondScope.ServiceProvider.GetRequiredService<ScopeOwnedDisposable>();
}

Assert.NotSame(firstScoped, secondScoped);
Assert.Equal(1, secondScoped.DisposeCount);
Assert.Equal(0, providerOwned.DisposeCount);
}

Assert.Equal(1, providerOwned.DisposeCount);
}

private sealed class ProviderOwnedDisposable : IDisposable
{
internal int DisposeCount
{
get;
private set;
}

public void Dispose()
{
DisposeCount++;
}
}

private sealed class ScopeOwnedDisposable : IDisposable
{
internal int DisposeCount
{
get;
private set;
}

public void Dispose()
{
DisposeCount++;
}
}
}
Loading