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