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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ under [1.0.0](#100---2026-09-20) before upgrading.

## [Unreleased]

### Added

- Add protected `CheatEnginePlugin.BeginFailedEnableRollback()` so an `OnEnable` failure handler can close
general dispatch admission and signal shutdown before releasing resources that need the attached runtime.

### Fixed

- Permit bounded inline main-thread cleanup during lifecycle callbacks while keeping worker admission closed.
- Refuse failed-enable rollback from nested `MainThread.Invoke` work before changing shutdown state, avoiding a
self-deadlock while draining the caller's admission lease. Both action and function overloads have fixture coverage;
this does not claim new exact-host qualification.

## [2.0.1] - 2026-10-16

Patch release for the 2.0 line. The public API and native ABI are unchanged from 2.0.0.
Expand Down
33 changes: 29 additions & 4 deletions libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
// The three stdcall callbacks whose addresses the bootstrap writes into the init record, and the enable/disable
// logic behind them. [UnmanagedCallersOnly] methods cannot be generic or live in a generic type: the factory they
// need is reached through the PluginDescriptor the bootstrap stored.
public static unsafe partial class PluginHost // NOSONAR: bootstrap callbacks must expose Cheat Engine's unmanaged ABI.

Check warning on line 24 in libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs

View workflow job for this annotation

GitHub Actions / CI / Sonar / Analyze

Avoid using this unsafe code block.

Check warning on line 24 in libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs

View workflow job for this annotation

GitHub Actions / CI / Sonar / Analyze

Avoid using this unsafe code block.
{
/// <summary>
/// Cheat Engine's version query. Writes <see cref="AbiConstants.SdkVersion" /> and the name pointer of the
Expand Down Expand Up @@ -238,6 +238,7 @@
{
Volatile.Write(ref s_incompleteEnableCleanup, 0);
Volatile.Write(ref s_incompleteEnableCleanupActive, 0);
Volatile.Write(ref s_failedEnableRollbackSignaled, 0);

// Opt-in identification (WI-5): at most one entry, before any copy, bind or construction is attempted, so
// it is still emitted when one of those steps fails. Never touches Lua or plugin code.
Expand All @@ -262,8 +263,9 @@
}

// Attaches the runtime binding (this thread is the main thread of this enable; the epoch advances), publishes
// the context so that OnEnable can use it, and runs OnEnable. It opens dispatch admission only after OnEnable
// succeeds. IsEnabled intentionally stays false until then: the published context is lifecycle-only in Enabling.
// the context so that OnEnable can use it, and runs OnEnable. Synchronous inline dispatch is allowed only on the
// lifecycle thread while its callback is running; worker-dispatch admission opens after OnEnable succeeds.
// IsEnabled intentionally stays false until then: the published context is lifecycle-only in Enabling.
private static bool AttachAndEnable(CheatEnginePlugin plugin, in ManagedExportedFunctions exports, uint pluginId)
{
bool runtimeAttached = false;
Expand All @@ -272,7 +274,17 @@
{
PluginContext context =
AttachAndPublishEnableContext(in exports, pluginId, out runtimeAttached, out shutdown);
plugin.OnEnable();
PluginContext? previousCallbackContext = t_lifecycleCallbackInlineContext;
t_lifecycleCallbackInlineContext = context;
try
{
plugin.OnEnable();
}
finally
{
t_lifecycleCallbackInlineContext = previousCallbackContext;
}

CompleteEnable(context, shutdown);
return true;
}
Expand Down Expand Up @@ -314,7 +326,14 @@

private static void CompleteEnable(PluginContext context, CancellationTokenSource shutdown)
{
// OnEnable observes Enabling and cannot admit worker dispatch. Only its successful completion opens the gate.
if (Volatile.Read(ref s_failedEnableRollbackSignaled) != 0)
{
throw new InvalidOperationException(
"OnEnable began failed-enable rollback and cannot complete the activation successfully.");
}

// OnEnable observes Enabling. Inline work in the callback has a scoped exception; only successful completion opens
// worker-dispatch admission.
OpenMainThreadWorkAdmission(shutdown);
SetPhase(PluginHostLifecyclePhase.Enabled);

Expand Down Expand Up @@ -625,6 +644,8 @@

if (plugin is not null)
{
PluginContext? previousCallbackContext = t_lifecycleCallbackInlineContext;
t_lifecycleCallbackInlineContext = context;
try
{
// The runtime is still attached, so plugin cleanup can release Lua resources before Detach.
Expand All @@ -636,6 +657,10 @@
"DisablePlugin: OnDisable threw; cleanup continues and detach determines the final disable result.",
exception);
}
finally
{
t_lifecycleCallbackInlineContext = previousCallbackContext;
}
}
}

Expand Down
48 changes: 45 additions & 3 deletions libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using CheatEngine.SDK.Hosting.Context;
using CheatEngine.SDK.Hosting.Diagnostics;
using CheatEngine.SDK.Hosting.Plugin;
using CheatEngine.SDK.Hosting.Threading;
using CheatEngine.SDK.Lua.Runtime;

namespace CheatEngine.SDK.Hosting.Bootstrap;
Expand Down Expand Up @@ -45,8 +46,12 @@
/// from any thread; the transitions are serialized by one lock.
/// </para>
/// </remarks>
public static unsafe partial class PluginHost

Check warning on line 49 in libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs

View workflow job for this annotation

GitHub Actions / CI / Sonar / Analyze

Avoid using this unsafe code block.

Check warning on line 49 in libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs

View workflow job for this annotation

GitHub Actions / CI / Sonar / Analyze

Avoid using this unsafe code block.
{
// Lifecycle callbacks may synchronously dispatch inline work while the runtime is attached. A ThreadStatic scope
// permits only that callback thread; worker threads created during a callback do not inherit the exception.
[ThreadStatic] private static PluginContext? t_lifecycleCallbackInlineContext;

Check warning on line 53 in libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Naming rule violation: Missing prefix: '_'

See more on https://sonarcloud.io/project/issues?id=CheatEngineNet_CheatEngine.SDK&issues=AaFzHvyfPtVSnfJOzP9B&open=AaFzHvyfPtVSnfJOzP9B&pullRequest=100

private static readonly Lock SGate = new();
private static readonly Lock SAdmissionGate = new();
private static readonly ManualResetEventSlim SNoAdmittedMainThreadWork = new(true);
Expand All @@ -63,6 +68,7 @@
private static int s_acceptingMainThreadWork;
private static int s_incompleteEnableCleanup;
private static int s_incompleteEnableCleanupActive;
private static int s_failedEnableRollbackSignaled;

Check warning on line 71 in libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Naming rule violation: Missing prefix: '_'

See more on https://sonarcloud.io/project/issues?id=CheatEngineNet_CheatEngine.SDK&issues=AaFzHvyfPtVSnfJOzP9C&open=AaFzHvyfPtVSnfJOzP9C&pullRequest=100
private static int s_phase;
private static int s_lastInitRecordArgument = -1;
private static int s_lastVersionRecordSize = -1;
Expand Down Expand Up @@ -123,9 +129,13 @@
{
lock (SAdmissionGate)
{
if (Phase is not PluginHostLifecyclePhase.Enabled ||
!ReferenceEquals(context, Volatile.Read(ref s_context)) ||
Volatile.Read(ref s_acceptingMainThreadWork) == 0)
bool isCurrentContext = ReferenceEquals(context, Volatile.Read(ref s_context));
bool isEnabledAdmission = Phase is PluginHostLifecyclePhase.Enabled &&
Volatile.Read(ref s_acceptingMainThreadWork) != 0;
bool isInlineLifecycleCallback =
(Phase is PluginHostLifecyclePhase.Enabling or PluginHostLifecyclePhase.Disabling) &&
ReferenceEquals(context, t_lifecycleCallbackInlineContext) && context.IsMainThread;
if (!isCurrentContext || (!isEnabledAdmission && !isInlineLifecycleCallback))
{
throw new InvalidOperationException(
"The plugin is stopping or disabled and no longer accepts new main-thread dispatch work.");
Expand All @@ -145,6 +155,37 @@
}
}

/// <summary>
/// Closes general admission and signals the current activation's shutdown token while its failed enable callback
/// still owns the attached runtime.
/// </summary>
/// <remarks>
/// This is intentionally available only to <see cref="CheatEnginePlugin" />. It accepts the exact context on the
/// captured lifecycle callback thread during <see cref="PluginHostLifecyclePhase.Enabling" />. The callback-local
/// inline dispatch exception remains available for bounded cleanup; it never opens worker admission.
/// Nested dispatch work cannot initiate rollback because draining admission would wait for its own lease.
/// </remarks>
internal static void BeginFailedEnableRollback(PluginContext context)
{
ArgumentNullException.ThrowIfNull(context);
lock (SAdmissionGate)
{
if (Phase is not PluginHostLifecyclePhase.Enabling ||
!ReferenceEquals(context, Volatile.Read(ref s_context)) ||
!ReferenceEquals(context, t_lifecycleCallbackInlineContext) || !context.IsMainThread ||
MainThreadDispatcher.IsExecutingInlineWorkOnCurrentThread ||
MainThreadDispatcher.IsExecutingWorkOnCurrentThread)
{
throw new InvalidOperationException(
"A failed-enable rollback can begin only from the current plugin's main-thread OnEnable callback, outside nested main-thread dispatch work.");
}

Volatile.Write(ref s_failedEnableRollbackSignaled, 1);
}

CloseMainThreadWorkAdmissionAndSignalShutdown(context);
}

/// <summary>
/// The managed bootstrap: fills the <see cref="PluginInitRecord" /> at <paramref name="initRecord" /> with the
/// plugin name and the three lifecycle callbacks. Called by the generated <c>CESDK.CESDK.CEPluginInitialize</c>;
Expand Down Expand Up @@ -294,6 +335,7 @@
Volatile.Write(ref s_descriptor, null);
Volatile.Write(ref s_incompleteEnableCleanup, 0);
Volatile.Write(ref s_incompleteEnableCleanupActive, 0);
Volatile.Write(ref s_failedEnableRollbackSignaled, 0);
SetPhase(PluginHostLifecyclePhase.Uninitialized);
Volatile.Write(ref s_lastInitRecordArgument, -1);
Volatile.Write(ref s_lastVersionRecordSize, -1);
Expand Down
22 changes: 22 additions & 0 deletions libs/CheatEngine.SDK.Hosting/Plugin/CheatEnginePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,28 @@ protected CheatEnginePlugin()
[RequiresPluginEnabled]
protected static PluginContext Context => PluginHost.RequireContext();

/// <summary>
/// Begins shutdown for a failed <see cref="OnEnable" /> activation before its owner releases resources that need
/// the attached runtime.
/// </summary>
/// <remarks>
/// Call this only from an <see cref="OnEnable" /> failure handler that will rethrow after its synchronous cleanup.
/// It closes general main-thread admission and cancels <see cref="PluginContext.ShutdownToken" />, while preserving
/// the lifecycle callback's inline main-thread cleanup path until <see cref="OnEnable" /> returns. A call that
/// returns normally from <see cref="OnEnable" /> after this method is rejected as a failed enable.
/// Call directly from the failure handler, outside nested <c>MainThread.Invoke</c> work: that work holds an
/// admission lease which rollback would otherwise wait for before the caller could return.
/// </remarks>
/// <exception cref="InvalidOperationException">
/// The caller is not the current plugin's main-thread <see cref="OnEnable" /> callback, or is inside nested
/// main-thread dispatch work.
/// </exception>
[RunsOnMainThread]
protected static void BeginFailedEnableRollback()
{
PluginHost.BeginFailedEnableRollback(Context);
}

/// <summary>
/// Called by the host each time Cheat Engine enables the plugin, on the main thread, with the Lua API bound and
/// the runtime binding attached. Register Lua functions, menus and callbacks here.
Expand Down
1 change: 1 addition & 0 deletions libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
const CheatEngine.SDK.Hosting.Diagnostics.HostLog.IdentifyOnEnableEnvironmentVariable = "CHEATENGINE_SDK_IDENTIFY_ON_ENABLE" -> string!
static CheatEngine.SDK.Hosting.Diagnostics.HostLog.IdentifyOnEnable.get -> bool
static CheatEngine.SDK.Hosting.Diagnostics.HostLog.IdentifyOnEnable.set -> void
static CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin.BeginFailedEnableRollback() -> void
15 changes: 11 additions & 4 deletions libs/CheatEngine.SDK.Hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,14 @@ The lifecycle state machine is `Uninitialized → Registered → Enabling → En
`LuaRuntime` attaches after construction.
4. Attach `LuaRuntime` with a new epoch and publish the `PluginContext`. The enabling thread becomes the main thread of
this enable. The phase is `Enabling`, so `IsEnabled` remains false and worker dispatch admission remains closed.
5. Run `OnEnable`. On success, open worker-dispatch admission and move to `Enabled`. On any failure, cancel the
context's shutdown token, detach in `finally`, withdraw the context, and return to `Registered`.
5. Run `OnEnable`. A synchronous inline `MainThread.Invoke` on the lifecycle thread is admitted while this callback
runs; worker dispatch stays closed. On success, open worker-dispatch admission and move to `Enabled`. On any
failure, cancel the context's shutdown token, detach in `finally`, withdraw the context, and return to `Registered`.

An `OnEnable` failure handler may call `BeginFailedEnableRollback()` before its synchronous resource cleanup,
then rethrow. This signals shutdown while retaining the callback's inline cleanup path. Calling rollback from
inside `MainThread.Invoke` is refused before changing state: draining admission there would wait for the caller's
own lease. Returning normally after signaling rollback still fails enable.

`DisablePlugin` moves to `Disabling`, closes worker-dispatch admission, and signals `PluginContext.ShutdownToken`
before it invokes plugin cleanup. Work admitted before that boundary is drained first. When disable runs on the
Expand All @@ -73,8 +79,9 @@ captured GUI thread, Hosting pumps the host's `CheckSynchronize(0)` slot so a wo
operation or already executing dispatched action is refused before it changes lifecycle state. `OnDisable` then runs
while `LuaRuntime` is still attached. Finally, Hosting closes and drains `LuaRuntime`'s independent operation
admission, then detaches (neutralizing every Lua callback the plugin forgot), withdraws the context, and returns to
`Registered`. If detach fails, the callback returns `FALSE` and the host remains visibly `Disabling` with its runtime
attached rather than reporting a completed shutdown. After a completed shutdown, the next enable reuses
`Registered`. A synchronous inline `MainThread.Invoke` on the lifecycle thread is admitted during `OnDisable` so cleanup
can release resources; new worker dispatch stays refused. If detach fails, the callback returns `FALSE` and the host
remains visibly `Disabling` with its runtime attached rather than reporting a completed shutdown. After a completed shutdown, the next enable reuses
the plugin instance with a new epoch, so a `PluginContext` or Lua reference from an earlier enable is stale.
`PluginContext.IsCurrent` tells a kept context from the live one. While the plugin is disabled, `PluginHost.Context`
returns `null` and `CheatEnginePlugin.Context` throws.
Expand Down
15 changes: 9 additions & 6 deletions libs/CheatEngine.SDK.Hosting/Threading/MainThread.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,21 @@
/// path is confirmed live.
/// </para>
/// <para>
/// <b>Preconditions.</b> Everything here needs the plugin to be enabled (<see cref="InvalidOperationException" />
/// otherwise). <see cref="ProcessMessages" /> and <see cref="CheckSynchronize" /> additionally require the main
/// thread and throw when called from another one rather than pumping a foreign thread's queue.
/// <b>Preconditions.</b> Calls need the current plugin context. Outside a synchronous lifecycle callback,
/// <see cref="Invoke{TState}" /> also requires the plugin to be enabled; during <c>OnEnable</c> or
/// <c>OnDisable</c>, only an inline invocation on the captured main thread is admitted. Worker dispatch remains
/// closed until enable completes and after disable begins. <see cref="ProcessMessages" /> and
/// <see cref="CheckSynchronize" /> require the plugin to be enabled and the main thread.
/// </para>
/// <para>
/// <b>Admission.</b> Every <c>Invoke</c> call, including an inline call
/// already on the main thread, acquires the lifecycle work admission before its action starts. A transition
/// therefore rejects a new invocation before user work runs; a nested disable requested by that work is refused
/// because it cannot drain its own admission.
/// already on the main thread, acquires the lifecycle work admission before its action starts. During a
/// lifecycle callback, that exception is limited to the callback's exact context and main thread, so cleanup
/// can run synchronously while worker dispatch remains refused. A nested disable requested by inline work is
/// refused because it cannot drain its own admission.
/// </para>
/// </remarks>
public static unsafe class MainThread

Check warning on line 56 in libs/CheatEngine.SDK.Hosting/Threading/MainThread.cs

View workflow job for this annotation

GitHub Actions / CI / Sonar / Analyze

Avoid using this unsafe code block.
{
/// <summary>
/// Gets a value indicating whether the calling thread is the main thread of the current enable.
Expand Down
Loading
Loading