diff --git a/CHANGELOG.md b/CHANGELOG.md
index 884be2df..ad4974d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs
index ec7dada4..9c33a6d1 100644
--- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs
+++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs
@@ -238,6 +238,7 @@ private static bool RunEnable(ManagedExportedFunctions* exports, uint pluginId,
{
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.
@@ -262,8 +263,9 @@ private static bool RunEnable(ManagedExportedFunctions* exports, uint pluginId,
}
// 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;
@@ -272,7 +274,17 @@ private static bool AttachAndEnable(CheatEnginePlugin plugin, in ManagedExported
{
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;
}
@@ -314,7 +326,14 @@ private static PluginContext AttachAndPublishEnableContext(
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);
@@ -625,6 +644,8 @@ private static void RunDisable(PluginContext context, CheatEnginePlugin? plugin)
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.
@@ -636,6 +657,10 @@ private static void RunDisable(PluginContext context, CheatEnginePlugin? plugin)
"DisablePlugin: OnDisable threw; cleanup continues and detach determines the final disable result.",
exception);
}
+ finally
+ {
+ t_lifecycleCallbackInlineContext = previousCallbackContext;
+ }
}
}
diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs
index 5721509f..c86904cc 100644
--- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs
+++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs
@@ -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;
@@ -47,6 +48,10 @@ namespace CheatEngine.SDK.Hosting.Bootstrap;
///
public static unsafe partial class PluginHost
{
+ // 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;
+
private static readonly Lock SGate = new();
private static readonly Lock SAdmissionGate = new();
private static readonly ManualResetEventSlim SNoAdmittedMainThreadWork = new(true);
@@ -63,6 +68,7 @@ public static unsafe partial class PluginHost
private static int s_acceptingMainThreadWork;
private static int s_incompleteEnableCleanup;
private static int s_incompleteEnableCleanupActive;
+ private static int s_failedEnableRollbackSignaled;
private static int s_phase;
private static int s_lastInitRecordArgument = -1;
private static int s_lastVersionRecordSize = -1;
@@ -123,9 +129,13 @@ internal static MainThreadWorkAdmission AdmitMainThreadWork(PluginContext contex
{
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.");
@@ -145,6 +155,37 @@ internal static MainThreadWorkAdmission AdmitMainThreadWork(PluginContext contex
}
}
+ ///
+ /// Closes general admission and signals the current activation's shutdown token while its failed enable callback
+ /// still owns the attached runtime.
+ ///
+ ///
+ /// This is intentionally available only to . It accepts the exact context on the
+ /// captured lifecycle callback thread during . 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.
+ ///
+ 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);
+ }
+
///
/// The managed bootstrap: fills the at with the
/// plugin name and the three lifecycle callbacks. Called by the generated CESDK.CESDK.CEPluginInitialize;
@@ -294,6 +335,7 @@ internal static void ResetForTests()
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);
diff --git a/libs/CheatEngine.SDK.Hosting/Plugin/CheatEnginePlugin.cs b/libs/CheatEngine.SDK.Hosting/Plugin/CheatEnginePlugin.cs
index 24e8a3ce..c1ae9581 100644
--- a/libs/CheatEngine.SDK.Hosting/Plugin/CheatEnginePlugin.cs
+++ b/libs/CheatEngine.SDK.Hosting/Plugin/CheatEnginePlugin.cs
@@ -58,6 +58,28 @@ protected CheatEnginePlugin()
[RequiresPluginEnabled]
protected static PluginContext Context => PluginHost.RequireContext();
+ ///
+ /// Begins shutdown for a failed activation before its owner releases resources that need
+ /// the attached runtime.
+ ///
+ ///
+ /// Call this only from an failure handler that will rethrow after its synchronous cleanup.
+ /// It closes general main-thread admission and cancels , while preserving
+ /// the lifecycle callback's inline main-thread cleanup path until returns. A call that
+ /// returns normally from after this method is rejected as a failed enable.
+ /// Call directly from the failure handler, outside nested MainThread.Invoke work: that work holds an
+ /// admission lease which rollback would otherwise wait for before the caller could return.
+ ///
+ ///
+ /// The caller is not the current plugin's main-thread callback, or is inside nested
+ /// main-thread dispatch work.
+ ///
+ [RunsOnMainThread]
+ protected static void BeginFailedEnableRollback()
+ {
+ PluginHost.BeginFailedEnableRollback(Context);
+ }
+
///
/// 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.
diff --git a/libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt
index fea204f1..3219d633 100644
--- a/libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt
+++ b/libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt
@@ -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
diff --git a/libs/CheatEngine.SDK.Hosting/README.md b/libs/CheatEngine.SDK.Hosting/README.md
index c738364f..af5aef5d 100644
--- a/libs/CheatEngine.SDK.Hosting/README.md
+++ b/libs/CheatEngine.SDK.Hosting/README.md
@@ -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
@@ -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.
diff --git a/libs/CheatEngine.SDK.Hosting/Threading/MainThread.cs b/libs/CheatEngine.SDK.Hosting/Threading/MainThread.cs
index dd2fb6b7..01701353 100644
--- a/libs/CheatEngine.SDK.Hosting/Threading/MainThread.cs
+++ b/libs/CheatEngine.SDK.Hosting/Threading/MainThread.cs
@@ -39,15 +39,18 @@ namespace CheatEngine.SDK.Hosting.Threading;
/// path is confirmed live.
///
///
-/// Preconditions. Everything here needs the plugin to be enabled (
-/// otherwise). and additionally require the main
-/// thread and throw when called from another one rather than pumping a foreign thread's queue.
+/// Preconditions. Calls need the current plugin context. Outside a synchronous lifecycle callback,
+/// also requires the plugin to be enabled; during OnEnable or
+/// OnDisable, only an inline invocation on the captured main thread is admitted. Worker dispatch remains
+/// closed until enable completes and after disable begins. and
+/// require the plugin to be enabled and the main thread.
///
///
/// Admission. Every Invoke 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.
///
///
public static unsafe class MainThread
diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/AdmissionLifecycleTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/AdmissionLifecycleTests.cs
index 1cec9838..c3225a35 100644
--- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/AdmissionLifecycleTests.cs
+++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/AdmissionLifecycleTests.cs
@@ -7,6 +7,7 @@
using CheatEngine.SDK.Hosting.Bootstrap;
using CheatEngine.SDK.Hosting.Context;
using CheatEngine.SDK.Hosting.Diagnostics;
+using CheatEngine.SDK.Hosting.Plugin;
using CheatEngine.SDK.Hosting.Tests.Support;
using CheatEngine.SDK.Hosting.Threading;
using CheatEngine.SDK.Lua.Callbacks;
@@ -107,6 +108,217 @@ public void A_worker_cannot_queue_work_after_disable_has_closed_admission()
}
}
+ [Fact]
+ [Trait("Category", "NativeLua")]
+ public void OnDisable_can_run_inline_main_thread_cleanup_after_general_admission_closes()
+ {
+ HostingTest.RequireNativeLua();
+ HostingTest.Reset();
+ using NativeLuaState state = new();
+ using HostSimulator host = new();
+ RecordingPlugin plugin = HostingTest.Enable(host, state);
+ InlineDisableObservation observation = new(host);
+ int dispatchAttempts = 0;
+ MainThreadDispatcher.DispatchOverrideForTests = _ => Interlocked.Increment(ref dispatchAttempts);
+ RecordingPlugin.NestedCallInOnDisable = () =>
+ {
+ MainThread.Invoke(static current =>
+ {
+ current.ExecutionCount++;
+ current.IsExecutingInline = MainThreadDispatcher.IsExecutingInlineWorkOnCurrentThread;
+ current.IsExecutingWork = MainThreadDispatcher.IsExecutingWorkOnCurrentThread;
+ current.RuntimeAttached = LuaRuntime.IsAttached;
+ current.NestedDisableResult = current.Host.CallDisable();
+ }, observation);
+ return Bool32.True;
+ };
+
+ try
+ {
+ Assert.True(host.CallDisable().IsTrue);
+
+ Assert.Equal(1, observation.ExecutionCount);
+ Assert.True(observation.IsExecutingInline);
+ Assert.True(observation.IsExecutingWork);
+ Assert.True(observation.RuntimeAttached);
+ Assert.False(observation.NestedDisableResult.IsTrue);
+ Assert.Equal(0, dispatchAttempts);
+ Assert.Equal(1, plugin.DisableCalls);
+ Assert.False(LuaRuntime.IsAttached);
+ Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase);
+ }
+ finally
+ {
+ MainThreadDispatcher.DispatchOverrideForTests = null;
+ }
+ }
+
+ [Fact]
+ [Trait("Category", "NativeLua")]
+ [SuppressMessage("xUnit.Analyzers", "xUnit1051",
+ Justification = "The worker is joined against the deterministic OnEnable callback boundary.")]
+ public void OnEnable_allows_inline_dispatch_but_keeps_worker_dispatch_closed()
+ {
+ HostingTest.RequireNativeLua();
+ HostingTest.Reset();
+ using NativeLuaState state = new();
+ using HostSimulator host = new();
+ HostingTest.UseFixture(state);
+ HostingTest.Bootstrap(host);
+ InlineEnableObservation observation = new();
+ int dispatchAttempts = 0;
+ MainThreadDispatcher.DispatchOverrideForTests = _ => Interlocked.Increment(ref dispatchAttempts);
+ RecordingPlugin.NestedCallInOnEnable = () =>
+ {
+ MainThread.Invoke(static current =>
+ {
+ current.InlineExecuted = true;
+ current.RuntimeAttached = LuaRuntime.IsAttached;
+ current.HostEnabledDuringCallback = PluginHost.IsEnabled;
+ }, observation);
+
+ Thread worker = new(() => observation.WorkerFailure = Record.Exception(() => MainThread.Invoke(
+ static _ =>
+ {
+ }, 0)));
+ worker.Start();
+ if (!worker.Join(TimeSpan.FromSeconds(5)))
+ {
+ throw new TimeoutException("The OnEnable admission-closed worker did not return.");
+ }
+
+ return Bool32.True;
+ };
+
+ try
+ {
+ ManagedExportedFunctions exports = FakeExports.Create();
+ Assert.True(host.CallEnable(&exports, 1).IsTrue);
+
+ Assert.True(observation.InlineExecuted);
+ Assert.True(observation.RuntimeAttached);
+ Assert.False(observation.HostEnabledDuringCallback);
+ InvalidOperationException failure = Assert.IsType(observation.WorkerFailure);
+ Assert.Contains("no longer accepts new main-thread dispatch", failure.Message, StringComparison.Ordinal);
+ Assert.Equal(0, dispatchAttempts);
+ Assert.True(PluginHost.IsEnabled);
+ }
+ finally
+ {
+ MainThreadDispatcher.DispatchOverrideForTests = null;
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ [Trait("Category", "NativeLua")]
+ [Trait("Qualification", "Q07")]
+ public void Failed_enable_rollback_nested_in_inline_work_is_refused_without_changing_activation(bool returnsValue)
+ {
+ HostingTest.RequireNativeLua();
+ HostingTest.Reset();
+ using NativeLuaState state = new();
+ using HostSimulator host = new();
+ HostingTest.UseFixture(state);
+ HostingTest.Bootstrap(host);
+ Exception? nestedFailure = null;
+ bool shutdownObserved = true;
+ RecordingPlugin.NestedCallInOnEnable = () =>
+ {
+ nestedFailure = Record.Exception(() =>
+ {
+ if (returnsValue)
+ {
+ MainThread.Invoke(static _ =>
+ {
+ PluginHost.BeginFailedEnableRollback(PluginHost.Context!);
+ return 1;
+ }, 0);
+ }
+ else
+ {
+ MainThread.Invoke(static _ => PluginHost.BeginFailedEnableRollback(PluginHost.Context!), 0);
+ }
+ });
+ shutdownObserved = PluginHost.Context!.ShutdownToken.IsCancellationRequested;
+ return Bool32.True;
+ };
+
+ ManagedExportedFunctions exports = FakeExports.Create();
+ Assert.True(host.CallEnable(&exports, 1).IsTrue);
+
+ InvalidOperationException failure = Assert.IsType(nestedFailure);
+ Assert.Contains("outside nested main-thread dispatch work", failure.Message, StringComparison.Ordinal);
+ Assert.False(shutdownObserved);
+ Assert.False(PluginHost.Context!.ShutdownToken.IsCancellationRequested);
+ Assert.Equal(PluginHostLifecyclePhase.Enabled, PluginHost.Phase);
+ Assert.True(LuaRuntime.IsAttached);
+ Assert.Equal(17, MainThread.Invoke(static value => value, 17));
+ Assert.True(host.CallDisable().IsTrue);
+ Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase);
+ }
+
+ [Fact]
+ [Trait("Category", "NativeLua")]
+ [SuppressMessage("xUnit.Analyzers", "xUnit1051",
+ Justification = "The worker is joined from the failed-enable lifecycle callback after admission closes.")]
+ public void FailedOnEnableRollback_signals_shutdown_before_cleanup_and_preserves_only_inline_dispatch()
+ {
+ HostingTest.RequireNativeLua();
+ HostingTest.Reset();
+ using NativeLuaState state = new();
+ using HostSimulator host = new();
+ HostingTest.UseFixture(state);
+ Assert.Equal(1, host.Initialize());
+ int dispatchAttempts = 0;
+ MainThreadDispatcher.DispatchOverrideForTests = _ => Interlocked.Increment(ref dispatchAttempts);
+
+ try
+ {
+ ManagedExportedFunctions exports = FakeExports.Create();
+ Assert.False(host.CallEnable(&exports, 1).IsTrue);
+
+ RollbackSignalPlugin plugin = Assert.IsType(PluginHost.PluginForTests);
+ Assert.True(plugin.ShutdownObserved);
+ Assert.True(plugin.InlineCleanupExecuted);
+ Assert.True(plugin.InlineCleanupWasMarked);
+ Assert.True(plugin.InlineCleanupSawAttachedRuntime);
+ InvalidOperationException workerFailure = Assert.IsType(plugin.WorkerFailure);
+ Assert.Contains("no longer accepts new main-thread dispatch", workerFailure.Message, StringComparison.Ordinal);
+ Assert.Equal(0, dispatchAttempts);
+ Assert.True(plugin.ContextAtRollback.ShutdownToken.IsCancellationRequested);
+ Assert.False(LuaRuntime.IsAttached);
+ Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase);
+ }
+ finally
+ {
+ MainThreadDispatcher.DispatchOverrideForTests = null;
+ }
+ }
+
+ [Fact]
+ [Trait("Category", "NativeLua")]
+ public void A_failed_enable_rollback_that_returns_cannot_open_admission()
+ {
+ HostingTest.RequireNativeLua();
+ CapturingLogSink sink = HostingTest.Reset();
+ using NativeLuaState state = new();
+ using HostSimulator host = new();
+ HostingTest.UseFixture(state);
+ Assert.Equal(1, host.Initialize());
+ ManagedExportedFunctions exports = FakeExports.Create();
+
+ Assert.False(host.CallEnable(&exports, 1).IsTrue);
+
+ ReturningRollbackPlugin plugin = Assert.IsType(PluginHost.PluginForTests);
+ Assert.True(plugin.ContextAtRollback.ShutdownToken.IsCancellationRequested);
+ Assert.False(PluginHost.IsEnabled);
+ Assert.False(LuaRuntime.IsAttached);
+ Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase);
+ Assert.NotEmpty(sink.Errors("OnEnable threw"));
+ }
+
[Fact]
[Trait("Category", "NativeLua")]
public void A_throwing_shutdown_registration_is_neutralized_and_cleanup_completes()
@@ -182,6 +394,68 @@ private static Bool32 StartAdmissionClosedWorker(
return Bool32.True;
}
+ private sealed class InlineDisableObservation(HostSimulator host)
+ {
+ public HostSimulator Host { get; } = host;
+
+ public int ExecutionCount
+ {
+ get;
+ set;
+ }
+
+ public bool IsExecutingInline
+ {
+ get;
+ set;
+ }
+
+ public bool IsExecutingWork
+ {
+ get;
+ set;
+ }
+
+ public bool RuntimeAttached
+ {
+ get;
+ set;
+ }
+
+ public Bool32 NestedDisableResult
+ {
+ get;
+ set;
+ }
+ }
+
+ private sealed class InlineEnableObservation
+ {
+ public bool InlineExecuted
+ {
+ get;
+ set;
+ }
+
+ public bool RuntimeAttached
+ {
+ get;
+ set;
+ }
+
+ public bool HostEnabledDuringCallback
+ {
+ get;
+ set;
+ }
+
+ public Exception? WorkerFailure
+ {
+ get;
+ set;
+ }
+ }
+
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static int CountInvocation(nint pointer)
{
@@ -210,4 +484,108 @@ public int InvocationCount
set;
}
}
+
+ private sealed class RollbackSignalPlugin : CheatEnginePlugin
+ {
+ internal PluginContext ContextAtRollback
+ {
+ get;
+ private set;
+ } = null!;
+
+ internal bool ShutdownObserved
+ {
+ get;
+ private set;
+ }
+
+ internal bool InlineCleanupExecuted
+ {
+ get;
+ private set;
+ }
+
+ internal bool InlineCleanupWasMarked
+ {
+ get;
+ private set;
+ }
+
+ internal bool InlineCleanupSawAttachedRuntime
+ {
+ get;
+ private set;
+ }
+
+ internal Exception? WorkerFailure
+ {
+ get;
+ private set;
+ }
+
+ protected internal override void OnEnable()
+ {
+ ContextAtRollback = Context;
+ BeginFailedEnableRollback();
+ ShutdownObserved = Context.ShutdownToken.IsCancellationRequested;
+ MainThread.Invoke(_ =>
+ {
+ InlineCleanupExecuted = true;
+ InlineCleanupWasMarked = MainThreadDispatcher.IsExecutingInlineWorkOnCurrentThread;
+ InlineCleanupSawAttachedRuntime = LuaRuntime.IsAttached;
+ }, 0);
+
+ Thread worker = new(() => WorkerFailure = Record.Exception(() => MainThread.Invoke(static _ => { }, 0)));
+ worker.Start();
+ if (!worker.Join(TimeSpan.FromSeconds(5)))
+ {
+ throw new TimeoutException("The failed-enable admission-closed worker did not return.");
+ }
+
+ throw new InvalidOperationException("Rollback requested by the test.");
+ }
+
+ protected internal override void OnDisable()
+ {
+ }
+ }
+
+ private sealed class RollbackSignalPluginFactory : IPluginFactory
+ {
+ public static ReadOnlySpan Utf8Name => "Rollback Signal Test Plugin"u8;
+
+ public static CheatEnginePlugin Create()
+ {
+ return new RollbackSignalPlugin();
+ }
+ }
+
+ private sealed class ReturningRollbackPlugin : CheatEnginePlugin
+ {
+ internal PluginContext ContextAtRollback
+ {
+ get;
+ private set;
+ } = null!;
+
+ protected internal override void OnEnable()
+ {
+ ContextAtRollback = Context;
+ BeginFailedEnableRollback();
+ }
+
+ protected internal override void OnDisable()
+ {
+ }
+ }
+
+ private sealed class ReturningRollbackPluginFactory : IPluginFactory
+ {
+ public static ReadOnlySpan Utf8Name => "Returning Rollback Test Plugin"u8;
+
+ public static CheatEnginePlugin Create()
+ {
+ return new ReturningRollbackPlugin();
+ }
+ }
}
diff --git a/tests/CheatEngine.SDK.Hosting.Tests/README.md b/tests/CheatEngine.SDK.Hosting.Tests/README.md
index bba9b643..3ca0a341 100644
--- a/tests/CheatEngine.SDK.Hosting.Tests/README.md
+++ b/tests/CheatEngine.SDK.Hosting.Tests/README.md
@@ -48,6 +48,10 @@ wrong-thread behavior is rejected; they do not claim a thread hop. The end-to-en
- `DisablePlugin` closes worker-dispatch admission, signals `PluginContext.ShutdownToken`, pumps
`CheckSynchronize` when needed to drain admitted work, closes and drains ordinary Lua operations, then detaches
`LuaRuntime`, withdraws `PluginContext` and releases Lua callbacks the plugin forgot.
+- `MainThread.Invoke` can run inline on the captured lifecycle thread during `OnEnable` and `OnDisable`; worker
+ dispatch remains closed during both callbacks (`AdmissionLifecycleTests`).
+- Failed-enable rollback nested in either `MainThread.Invoke` overload is refused before shutdown is signaled;
+ the outer enable and subsequent normal dispatch/disable still succeed (`AdmissionLifecycleTests`, Q07, C2).
- `MainThread.ProcessMessages`, `CheckSynchronize` and `Invoke` throw `InvalidOperationException` while no plugin is
enabled. A worker `Invoke` rejects a `synchronize` callback that runs it on the wrong managed thread.
- Without a Lua module, or with a module that lacks one Lua export, the enable fails before any plugin code runs and