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
7 changes: 7 additions & 0 deletions docs/adr/0002-plugin-activation-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ On disable, the base clears the active Client reference first, invokes `OnClient
reverse order, drains Client-owned CE resources while the host is still attached, and finally disposes the scope,
provider, and configuration. Cleanup is best-effort and aggregates failures only after every step has been attempted.

Before an activation is published, construction has a separate rollback path. It releases only the stages that were
actually acquired—scope, provider, then the host-created configuration—in reverse construction order. Each stage gets
one independent cleanup attempt. The construction exception remains the primary failure; cleanup failures follow it as
diagnostics. Module callbacks and Client-owned CE resource draining are never run for an incomplete activation.

## Invariants

- SDK calls are forbidden from plugin constructors, field initializers, and static initialization. SDK-dependent
Expand All @@ -33,6 +38,8 @@ provider, and configuration. Cleanup is best-effort and aggregates failures only
epoch. `ICheatEngineClient.Epoch` and `Stopping` identify the active lifetime.
- 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.

## Consequences and project value

Expand Down
122 changes: 84 additions & 38 deletions libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

namespace CheatEngine.Client.Hosting;

/// <summary>Base class that activates a scoped <see cref="ICheatEngineClient" /> for each Cheat Engine enable epoch.</summary>
/// <summary>Base class that activates an activation-owned <see cref="ICheatEngineClient" /> for each Cheat Engine enable epoch.</summary>
/// <remarks>
/// The SDK constructs a plugin through a parameterless factory and reuses that instance across enable/disable cycles.
/// This base class therefore creates a fresh validated provider and scope only from <see cref="OnEnable" />, when the
Expand Down Expand Up @@ -73,12 +73,13 @@ protected sealed override void OnEnable()
}

CheatEnginePluginBuilder builder = new();
ActivationConstruction construction = new(builder);
Activation? activation = null;

try
{
Configure(builder);
activation = CreateActivation(builder);
activation = CreateActivation(construction);
Volatile.Write(ref _activation, activation);

activation.Lifecycle.Enable(OnClientEnabled);
Expand All @@ -88,14 +89,15 @@ protected sealed override void OnEnable()
{
if (activation is null)
{
builder.ReleaseConfiguration();
throw;
RethrowAfterCleanup(enableFailure, CleanupUnpublishedActivation(construction));
}
else
{
Interlocked.CompareExchange(ref _activation, null, activation);
ClientHostingLog.ActivationRollingBack(activation.Logger, activation.Client.Epoch);
RethrowAfterCleanup(enableFailure,
CleanupActivation(activation));
}

Interlocked.CompareExchange(ref _activation, null, activation);
ClientHostingLog.ActivationRollingBack(activation.Logger, activation.Client.Epoch);
RethrowAfterCleanup(enableFailure,
CleanupActivation(activation));
}
}

Expand Down Expand Up @@ -125,37 +127,26 @@ protected sealed override void OnDisable()
/// <summary>Builds and resolves the complete activation graph before publishing it to the plugin instance.</summary>
/// <remarks>
/// The activation is not returned until options validation, the Client facade, modules, logging, and cleanup support
/// have all resolved from one scope. If any step fails, this method releases the scope, provider, and configuration
/// before propagating the original failure so a partially built epoch can never become observable.
/// have all resolved from one scope. The outer activation transaction records each acquired component before the
/// next step, so its failure-independent rollback can release a partial graph without invoking lifecycle callbacks.
/// </remarks>
private static Activation CreateActivation(CheatEnginePluginBuilder builder)
private static Activation CreateActivation(ActivationConstruction construction)
{
ServiceProvider? provider = null;
IServiceScope? scope = null;

try
{
provider = builder.BuildServiceProvider();
scope = provider.CreateScope();

// IOptions<T>.Value invokes the generated validator in plugin hosts that do not run Generic Host startup.
_ = scope.ServiceProvider.GetRequiredService<IOptions<CheatEngineClientOptions>>().Value;
ICheatEngineClient client = scope.ServiceProvider.GetRequiredService<ICheatEngineClient>();
ICheatEngineClientModule[] modules = GetModules(scope.ServiceProvider);
ILogger<CheatEngineClientPlugin> logger =
scope.ServiceProvider.GetRequiredService<ILogger<CheatEngineClientPlugin>>();
ICheatEngineClientActivationCleanup cleanup =
scope.ServiceProvider.GetRequiredService<ICheatEngineClientActivationCleanup>();

return new Activation(builder, provider, scope, client, modules, logger, cleanup);
}
catch
{
scope?.Dispose();
provider?.Dispose();
builder.ReleaseConfiguration();
throw;
}
ServiceProvider provider = construction.Builder.BuildServiceProvider();
construction.Provider = provider;
IServiceScope scope = provider.CreateScope();
construction.Scope = scope;

// IOptions<T>.Value invokes the generated validator in plugin hosts that do not run Generic Host startup.
_ = scope.ServiceProvider.GetRequiredService<IOptions<CheatEngineClientOptions>>().Value;
ICheatEngineClient client = scope.ServiceProvider.GetRequiredService<ICheatEngineClient>();
ICheatEngineClientModule[] modules = GetModules(scope.ServiceProvider);
ILogger<CheatEngineClientPlugin> logger =
scope.ServiceProvider.GetRequiredService<ILogger<CheatEngineClientPlugin>>();
ICheatEngineClientActivationCleanup cleanup =
scope.ServiceProvider.GetRequiredService<ICheatEngineClientActivationCleanup>();

return new Activation(construction.Builder, provider, scope, client, modules, logger, cleanup);
}

private static ICheatEngineClientModule[] GetModules(IServiceProvider services)
Expand All @@ -181,6 +172,29 @@ private static void RethrowAfterCleanup(Exception enableFailure, List<Exception>
cleanupFailures);
}

/// <summary>Releases a partial activation without invoking callbacks that were never admitted.</summary>
/// <remarks>
/// Provider construction happens before scope construction, and configuration outlives both. Each completed
/// construction stage is therefore released once in reverse order. Cleanup failures are recorded instead of
/// interrupting the remaining stages, so the original construction failure remains the first reported failure.
/// </remarks>
private static List<Exception> CleanupUnpublishedActivation(ActivationConstruction construction)
{
List<Exception> failures = [];
if (construction.Scope is not null)
{
TryCleanup(failures, construction.Scope.Dispose);
}

if (construction.Provider is not null)
{
TryCleanup(failures, construction.Provider.Dispose);
}

TryCleanup(failures, construction.Builder.ReleaseConfiguration);
return failures;
}

/// <summary>Closes an activation in the only safe disposal order and collects every cleanup failure.</summary>
/// <remarks>
/// Module callbacks and Client-owned Cheat Engine resources run while the SDK context is valid. The activation scope,
Expand Down Expand Up @@ -256,6 +270,18 @@ private static void ThrowCleanupFailures(List<Exception> failures)
throw new AggregateException("Client deactivation encountered one or more cleanup failures.", failures);
}

private static void TryCleanup(List<Exception> failures, Action cleanup)
{
try
{
cleanup();
}
catch (Exception exception)
{
failures.Add(exception);
}
}

private ICheatEngineClient GetActiveClient()
{
Activation? activation = Volatile.Read(ref _activation);
Expand All @@ -269,6 +295,26 @@ private ICheatEngineClient GetActiveClient()
"The Cheat Engine client is available only while the plugin is enabled.");
}

private sealed class ActivationConstruction(CheatEnginePluginBuilder builder)
{
internal CheatEnginePluginBuilder Builder
{
get;
} = builder;

internal ServiceProvider? Provider
{
get;
set;
}

internal IServiceScope? Scope
{
get;
set;
}
}

private sealed class Activation
{
internal Activation(
Expand Down
7 changes: 7 additions & 0 deletions libs/CheatEngine.Client.Hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ 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.

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
attached as secondary diagnostics. No module callback or Client-owned resource drain runs until an activation has been
created and published. The DI container disposes services it creates; Hosting explicitly releases its host-created
`ConfigurationManager` only after the scope and provider have been released.

Add configuration sources explicitly and keep reload disabled. The following is the normal plugin shape:

```csharp
Expand Down
Loading