From 3b1248419177683e8220790fb89f0d9cfbe73981 Mon Sep 17 00:00:00 2001 From: AriusII <35581726+AriusII@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:28:42 +0200 Subject: [PATCH] Unify stale client and local process semantics --- README.md | 10 +- docs/engineering/work-items/CLI-021.md | 22 ++- .../Processes/ILocalProcessDiagnostics.cs | 22 +++ .../Processes/IProcessClient.cs | 30 ++- .../Processes/LocalProcessId.cs | 16 ++ .../Processes/ProcessInfoSnapshot.cs | 9 +- .../Processes/ProcessSnapshot.cs | 1 + .../PublicAPI.Unshipped.txt | 19 +- .../UnavailableAllocationClient.cs | 5 +- .../Assembly/UnavailableAssemblyClient.cs | 7 +- .../Domains/Dbvm/UnavailableDbvmClient.cs | 11 +- .../Debugger/UnavailableDebuggerClient.cs | 9 +- .../Events/UnavailableCapabilityFailure.cs | 6 + .../Hashing/UnavailableHashingClient.cs | 11 +- .../Hotkeys/UnavailableHotkeyClient.cs | 9 +- .../Domains/InspectionClient.cs | 1 + .../Domains/LocalProcessDiagnostics.cs | 110 +++++++++++ .../Domains/LuaClient.cs | 19 +- .../Domains/ProcessClient.cs | 155 +++++---------- .../UnavailableRemoteExecutionClient.cs | 11 +- .../Domains/Speed/UnavailableSpeedClient.cs | 11 +- .../Domains/TableClient.cs | 6 +- .../Domains/Timers/UnavailableTimerClient.cs | 9 +- .../Domains/UnavailableValueScanner.cs | 4 + .../Domains/UnsafeLuaClient.cs | 10 +- .../CheatEngineClientBuilder.cs | 3 +- ...EngineClientServiceCollectionExtensions.cs | 37 ++-- .../Domains/LocalProcessDiagnosticsTests.cs | 132 +++++++++++++ .../Domains/ProcessClientTests.cs | 187 +++++++++--------- .../Domains/UnavailableValueScannerTests.cs | 17 ++ ...eClientServiceCollectionExtensionsTests.cs | 1 + 31 files changed, 625 insertions(+), 275 deletions(-) create mode 100644 libs/CheatEngine.Client.Abstractions/Processes/ILocalProcessDiagnostics.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Processes/LocalProcessId.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/LocalProcessDiagnostics.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessDiagnosticsTests.cs diff --git a/README.md b/README.md index b338b1f..a18be43 100644 --- a/README.md +++ b/README.md @@ -170,9 +170,13 @@ OnDisable resource lease, a Lua reference, a cancellation token, or a target-bound value across disable/re-enable. Constructors, field initializers, and static initialization must not call Cheat Engine; the SDK binding is valid only after enable. -All Client operations are synchronous. A cancellation token can prevent dispatch or stop Client-managed work between -steps, but it does not claim to interrupt a Lua primitive that has already started. Read -[ADR 0002](docs/adr/0002-plugin-activation-lifecycle.md) before adding a service that touches Cheat Engine. +All Client operations are synchronous. For stateful Client operations, request validation is followed by activation +admission, then caller-cancellation observation, and only then policy checks or Cheat Engine work. A stale activation +therefore throws `CheatEngineActivationExpiredException` even when the requested capability is currently gated. A +cancellation token can prevent dispatch or stop Client-managed work between steps, but it does not claim to interrupt +a Lua primitive that has already started. `ILocalProcessDiagnostics` is the explicit exception: it is an offline BCL +catalog service, never a proof of Cheat Engine target identity, and its copied snapshots remain usable after disable. +Read [ADR 0002](docs/adr/0002-plugin-activation-lifecycle.md) before adding a service that touches Cheat Engine. ## Packages and direct SDK reference diff --git a/docs/engineering/work-items/CLI-021.md b/docs/engineering/work-items/CLI-021.md index e8edd68..44847fb 100644 --- a/docs/engineering/work-items/CLI-021.md +++ b/docs/engineering/work-items/CLI-021.md @@ -24,20 +24,20 @@ SDK owns CE mappings, native safety, factual outcomes and low-level owners. Clie ### Technical requirements and task checklist -- [ ] Apply stale-client policy consistently before operational work, even for unavailable implementations. -- [ ] Label local process discovery/enrichment as local; no speculative remote backend. -- [ ] Document cancellation ordering and keep pure immutable snapshots usable where intended. +- [x] Apply stale-client policy consistently before operational work, even for unavailable implementations. +- [x] Label local process discovery/enrichment as local; no speculative remote backend. +- [x] Document cancellation ordering and keep pure immutable snapshots usable where intended. ### Acceptance criteria -- [ ] Disable/re-enable does not produce different lifetime behavior by domain accident. -- [ ] Offline diagnostics have a separate explicit contract. -- [ ] Local process metadata is never mistaken for authoritative SDK target identity. +- [x] Disable/re-enable does not produce different lifetime behavior by domain accident. +- [x] Offline diagnostics have a separate explicit contract. +- [x] Local process metadata is never mistaken for authoritative SDK target identity. ### Required validation -- [ ] Stale facade across local, implemented and unavailable domains. -- [ ] Canceled enumeration and disappearing local process fixtures. +- [x] Stale facade across local, implemented and unavailable domains. +- [x] Canceled enumeration and disappearing local process fixtures. ### Scope exclusions @@ -93,3 +93,9 @@ Required for the affected capability. Optional profiles may be deferred through ### Maintainer notes Keep execution updates, actual commands/results, decisions, and refinements here. The bootstrap does not overwrite an existing issue body on rerun. + +Implementation: local BCL enumeration moved to `ILocalProcessDiagnostics` with `LocalProcessId`; `IProcessClient` +continues to describe only Cheat Engine selection. Missing BCL metadata now yields optional enrichment, not a target +detach. Stateful and capability-gated services admit the activation before policy or cancellation; offline snapshots +remain pure managed values. Release build passed with zero warnings. Core tests passed (238); the full solution run had +one pre-existing source-generator snapshot newline mismatch, while its other 522 tests passed. diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ILocalProcessDiagnostics.cs b/libs/CheatEngine.Client.Abstractions/Processes/ILocalProcessDiagnostics.cs new file mode 100644 index 0000000..d7dacdc --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/ILocalProcessDiagnostics.cs @@ -0,0 +1,22 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Processes; + +/// Reads bounded, copied metadata from the local operating-system process catalog. +/// +/// This is an offline diagnostic contract. It neither dispatches to Cheat Engine nor observes, selects, or proves +/// a Cheat Engine target. Its values remain ordinary managed snapshots after a plugin activation ends. For each +/// operation, request validation occurs first, then cancellation is observed before catalog access and between +/// Client-managed materialization steps. +/// +public interface ILocalProcessDiagnostics +{ + /// Tries to enumerate copied local-process metadata within an explicit materialization bound. + /// Cancellation is observed before catalog access and between Client-managed materialization steps. + public bool TryGetProcesses(ProcessEnumerationRequest request, out ProcessEnumerationResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Enumerates copied local-process metadata within an explicit materialization bound. + public ProcessEnumerationResult GetProcesses(ProcessEnumerationRequest request, + CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs b/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs index ee46fbd..9994e7e 100644 --- a/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs @@ -3,23 +3,15 @@ namespace CheatEngine.Client.Processes; -/// Reads the process selected by the active Cheat Engine session. +/// Reads and changes the process selected by the active Cheat Engine session. public interface IProcessClient { - /// Tries to enumerate copied local-process metadata within an explicit materialization bound. - public bool TryGetProcesses(ProcessEnumerationRequest request, out ProcessEnumerationResult result, - out CheatEngineFailure failure, CancellationToken cancellationToken = default); - - /// Enumerates copied local-process metadata within an explicit materialization bound. - public ProcessEnumerationResult GetProcesses(ProcessEnumerationRequest request, - CancellationToken cancellationToken = default); - /// Tries to get a copied snapshot of the currently selected target process. /// - /// Returns when Cheat Engine has no selected target or - /// its selected target is no longer available in local process metadata. An inconsistent local metadata result - /// returns . Invalid arguments, lifecycle failures, and - /// unexpected implementation exceptions are not converted into a Try result. + /// Returns only when Cheat Engine has no selected target. + /// Local operating-system metadata is optional enrichment; its absence leaves the Cheat Engine target snapshot + /// valid with null name and executable path. Invalid arguments, lifecycle failures, and unexpected implementation + /// exceptions are not converted into a Try result. /// public bool TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure, CancellationToken cancellationToken = default); @@ -32,9 +24,9 @@ public bool TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure f /// architecture changed. /// /// - /// Returns and invalidates an observed selection when - /// the selected target is absent or no longer has local process metadata. This is an observation, not an - /// atomic process-lifetime guarantee. + /// Returns and invalidates an observed selection only + /// when Cheat Engine reports no selected target. Local metadata is optional enrichment and does not establish + /// liveness or target identity. This is an observation, not an atomic process-lifetime guarantee. /// public bool TryRefresh(out ProcessSnapshot snapshot, out CheatEngineFailure failure, CancellationToken cancellationToken = default); @@ -50,7 +42,11 @@ public bool TryAttach(TargetProcessId processId, out ProcessSnapshot snapshot, public ProcessSnapshot Attach(TargetProcessId processId, CancellationToken cancellationToken = default); - /// Tries to attach to the single local process whose executable name matches exactly. + /// Tries to attach to the single locally discovered process whose executable name matches exactly. + /// + /// Activation admission occurs before local discovery; caller cancellation is then observed before catalog access. + /// A local match is only an attach candidate. Cheat Engine's selected target is verified before returning. + /// public bool TryAttachExactName(string processName, out ProcessSnapshot snapshot, out CheatEngineFailure failure, CancellationToken cancellationToken = default); diff --git a/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessId.cs b/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessId.cs new file mode 100644 index 0000000..49d895c --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessId.cs @@ -0,0 +1,16 @@ +namespace CheatEngine.Client.Processes; + +/// Identifies a process observed by the local operating-system catalog. +/// This is deliberately not a Cheat Engine target identity. A numeric process identifier can be reused. +public readonly record struct LocalProcessId +{ + /// Creates a positive local operating-system process identifier. + public LocalProcessId(int value) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + Value = value; + } + + /// Gets the locally observed numeric process identifier. + public int Value { get; } +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs index b8cb42c..a56591b 100644 --- a/libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs @@ -1,12 +1,11 @@ -using CheatEngine.SDK.Engine.Inspection; - namespace CheatEngine.Client.Processes; /// Copied local-process metadata that is independent of Cheat Engine's selected target. +/// This data is local operating-system enrichment only; it neither selects nor identifies a Cheat Engine target. public readonly record struct ProcessInfoSnapshot { /// Creates copied local-process metadata. - public ProcessInfoSnapshot(TargetProcessId id, string? name, string? executablePath) + public ProcessInfoSnapshot(LocalProcessId id, string? name, string? executablePath) { if (name is { Length: 0 }) { @@ -23,8 +22,8 @@ public ProcessInfoSnapshot(TargetProcessId id, string? name, string? executableP ExecutablePath = executablePath; } - /// Gets the local process identifier. - public TargetProcessId Id + /// Gets the local operating-system process identifier, not a Cheat Engine target identity. + public LocalProcessId Id { get; } diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs index c5bab29..b3be05b 100644 --- a/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs @@ -4,6 +4,7 @@ namespace CheatEngine.Client.Processes; /// An immutable snapshot of the process currently selected in Cheat Engine. +/// The identifier and architecture are Cheat Engine observations. Name and executable path are optional local BCL enrichment and do not establish liveness or authoritative target provenance. public readonly record struct ProcessSnapshot { /// Creates a selected-process snapshot without a target-architecture observation. diff --git a/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt index 5fc66a3..e683629 100644 --- a/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt +++ b/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt @@ -1,4 +1,17 @@ #nullable enable +CheatEngine.Client.Processes.ILocalProcessDiagnostics +CheatEngine.Client.Processes.ILocalProcessDiagnostics.GetProcesses(CheatEngine.Client.Processes.ProcessEnumerationRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessEnumerationResult +CheatEngine.Client.Processes.ILocalProcessDiagnostics.TryGetProcesses(CheatEngine.Client.Processes.ProcessEnumerationRequest request, out CheatEngine.Client.Processes.ProcessEnumerationResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.LocalProcessId +CheatEngine.Client.Processes.LocalProcessId.Equals(CheatEngine.Client.Processes.LocalProcessId other) -> bool +CheatEngine.Client.Processes.LocalProcessId.LocalProcessId() -> void +CheatEngine.Client.Processes.LocalProcessId.LocalProcessId(int value) -> void +CheatEngine.Client.Processes.LocalProcessId.Value.get -> int +~override CheatEngine.Client.Processes.LocalProcessId.Equals(object obj) -> bool +override CheatEngine.Client.Processes.LocalProcessId.GetHashCode() -> int +~override CheatEngine.Client.Processes.LocalProcessId.ToString() -> string +static CheatEngine.Client.Processes.LocalProcessId.operator !=(CheatEngine.Client.Processes.LocalProcessId left, CheatEngine.Client.Processes.LocalProcessId right) -> bool +static CheatEngine.Client.Processes.LocalProcessId.operator ==(CheatEngine.Client.Processes.LocalProcessId left, CheatEngine.Client.Processes.LocalProcessId right) -> bool CheatEngine.Client.ICheatEngineClient.Allocations.get -> CheatEngine.Client.Allocations.IAllocationClient! CheatEngine.Client.ICheatEngineClient.Assembly.get -> CheatEngine.Client.Assembly.IAssemblyClient! CheatEngine.Client.ICheatEngineClient.Dbvm.get -> CheatEngine.Client.Dbvm.IDbvmClient! @@ -20,13 +33,11 @@ static CheatEngine.Client.Runtime.ClientCapabilityId.Timers.get -> CheatEngine.C CheatEngine.Client.Processes.IProcessClient.AttachForeground(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot CheatEngine.Client.Processes.IProcessClient.Create(CheatEngine.Client.Processes.ProcessStartRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot CheatEngine.Client.Processes.IProcessClient.GetPauseState(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessPauseSnapshot -CheatEngine.Client.Processes.IProcessClient.GetProcesses(CheatEngine.Client.Processes.ProcessEnumerationRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessEnumerationResult CheatEngine.Client.Processes.IProcessClient.Pause(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot CheatEngine.Client.Processes.IProcessClient.ResumeExecution(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot CheatEngine.Client.Processes.IProcessClient.TryAttachForeground(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Processes.IProcessClient.TryCreate(CheatEngine.Client.Processes.ProcessStartRequest request, out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Processes.IProcessClient.TryGetPauseState(out CheatEngine.Client.Processes.ProcessPauseSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Processes.IProcessClient.TryGetProcesses(CheatEngine.Client.Processes.ProcessEnumerationRequest request, out CheatEngine.Client.Processes.ProcessEnumerationResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Processes.IProcessClient.TryPause(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Processes.IProcessClient.TryResumeExecution(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Processes.ProcessEnumerationRequest @@ -54,10 +65,10 @@ static CheatEngine.Client.Processes.ProcessEnumerationResult.operator ==(CheatEn CheatEngine.Client.Processes.ProcessInfoSnapshot CheatEngine.Client.Processes.ProcessInfoSnapshot.Equals(CheatEngine.Client.Processes.ProcessInfoSnapshot other) -> bool CheatEngine.Client.Processes.ProcessInfoSnapshot.ExecutablePath.get -> string? -CheatEngine.Client.Processes.ProcessInfoSnapshot.Id.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.Client.Processes.ProcessInfoSnapshot.Id.get -> CheatEngine.Client.Processes.LocalProcessId CheatEngine.Client.Processes.ProcessInfoSnapshot.Name.get -> string? CheatEngine.Client.Processes.ProcessInfoSnapshot.ProcessInfoSnapshot() -> void -CheatEngine.Client.Processes.ProcessInfoSnapshot.ProcessInfoSnapshot(CheatEngine.SDK.Engine.Inspection.TargetProcessId id, string? name, string? executablePath) -> void +CheatEngine.Client.Processes.ProcessInfoSnapshot.ProcessInfoSnapshot(CheatEngine.Client.Processes.LocalProcessId id, string? name, string? executablePath) -> void ~override CheatEngine.Client.Processes.ProcessInfoSnapshot.Equals(object obj) -> bool override CheatEngine.Client.Processes.ProcessInfoSnapshot.GetHashCode() -> int ~override CheatEngine.Client.Processes.ProcessInfoSnapshot.ToString() -> string diff --git a/libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs b/libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs index 5626467..550d277 100644 --- a/libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs @@ -3,18 +3,21 @@ using CheatEngine.Client.Allocations; using CheatEngine.Client.Core.Domains.Events; using CheatEngine.Client.Results; +using CheatEngine.Client.Core.Infrastructure; namespace CheatEngine.Client.Core.Domains.Allocations; /// Preserves the allocation contract while its SDK ownership factory awaits live-host validation. internal sealed class UnavailableAllocationClient : IAllocationClient { + private readonly CoreLifetime? _lifetime; + internal UnavailableAllocationClient(CoreLifetime? lifetime = null) => _lifetime = lifetime; public bool TryAllocate(TargetAllocationRequest request, [NotNullWhen(true)] out ITargetMemoryLease? lease, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { lease = null; - failure = UnavailableCapabilityFailure.Create("Target allocations", "Allocations.Allocate", cancellationToken); + failure = UnavailableCapabilityFailure.Create(_lifetime, "Target allocations", "Allocations.Allocate", cancellationToken); return false; } diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs index 94daf0a..3fdcbef 100644 --- a/libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs @@ -4,6 +4,7 @@ using CheatEngine.Client.Assembly; using CheatEngine.Client.Core.Domains.Events; using CheatEngine.Client.Results; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Domains.Assembly; @@ -11,6 +12,8 @@ namespace CheatEngine.Client.Core.Domains.Assembly; /// Preserves the assembly and patch surface until Auto Assembler ownership passes its live-host gate. internal sealed class UnavailableAssemblyClient : IAssemblyClient { + private readonly CoreLifetime? _lifetime; + internal UnavailableAssemblyClient(CoreLifetime? lifetime = null) => _lifetime = lifetime; public bool TryDisassemble(Address address, out AssemblyInstructionSnapshot instruction, out CheatEngineFailure failure, CancellationToken cancellationToken = default) @@ -99,9 +102,9 @@ public IAutoAssemblerPatchLease ApplyPatch(AutoAssemblerScript script, return UnavailableCapabilityFailure.Throw(failure); } - private static CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) + private CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) { - return UnavailableCapabilityFailure.Create("Assembly, disassembly, and Auto Assembler patches", operation, + return UnavailableCapabilityFailure.Create(_lifetime, "Assembly, disassembly, and Auto Assembler patches", operation, cancellationToken); } } diff --git a/libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs b/libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs index 801de60..1937ac8 100644 --- a/libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Dbvm; using CheatEngine.Client.Events; using CheatEngine.Client.Results; @@ -10,6 +11,12 @@ namespace CheatEngine.Client.Core.Domains.Dbvm; /// Observes no inferred DBVM state and never initializes DBVM before its explicit live-host gate passes. internal sealed class UnavailableDbvmClient : IDbvmClient { + private readonly CoreLifetime? _lifetime; + + internal UnavailableDbvmClient(CoreLifetime? lifetime = null) + { + _lifetime = lifetime; + } public bool TryGetStatus(out DbvmStatusSnapshot status, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -57,9 +64,9 @@ public IDbvmWatchLease RegisterWatch(DbvmWatchRequest request, DbvmWatchHandler return UnavailableCapabilityFailure.Throw(failure); } - private static CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) + private CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) { - return UnavailableCapabilityFailure.Create("DBVM observation, explicit initialization, and watches", operation, + return UnavailableCapabilityFailure.Create(_lifetime, "DBVM observation, explicit initialization, and watches", operation, cancellationToken); } } diff --git a/libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs b/libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs index 9e00343..b19743c 100644 --- a/libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Debugger; using CheatEngine.Client.Events; using CheatEngine.Client.Results; @@ -10,6 +11,12 @@ namespace CheatEngine.Client.Core.Domains.Debugger; /// Preserves copied breakpoint semantics until debugger callback ownership and reactivation pass a live gate. internal sealed class UnavailableDebuggerClient : IDebuggerClient { + private readonly CoreLifetime? _lifetime; + + internal UnavailableDebuggerClient(CoreLifetime? lifetime = null) + { + _lifetime = lifetime; + } public bool TryRegisterBreakpoint(BreakpointRequest request, BreakpointHandler handler, EventStreamOptions streamOptions, [NotNullWhen(true)] out IBreakpointLease? lease, out CheatEngineFailure failure, @@ -18,7 +25,7 @@ public bool TryRegisterBreakpoint(BreakpointRequest request, BreakpointHandler h ArgumentNullException.ThrowIfNull(handler); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); lease = null; - failure = UnavailableCapabilityFailure.Create("Debugger breakpoints", "Debugger.RegisterBreakpoint", + failure = UnavailableCapabilityFailure.Create(_lifetime, "Debugger breakpoints", "Debugger.RegisterBreakpoint", cancellationToken); return false; } diff --git a/libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs b/libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs index 15e68e8..5019e7e 100644 --- a/libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs +++ b/libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using CheatEngine.Client.Results; +using CheatEngine.Client.Core.Infrastructure; namespace CheatEngine.Client.Core.Domains.Events; @@ -9,9 +10,14 @@ internal static class UnavailableCapabilityFailure { internal static CheatEngineFailure Create(string capabilityName, string operation, CancellationToken cancellationToken) + => Create(null, capabilityName, operation, cancellationToken); + + internal static CheatEngineFailure Create(CoreLifetime? lifetime, string capabilityName, string operation, + CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(capabilityName); ArgumentException.ThrowIfNullOrWhiteSpace(operation); + lifetime?.ThrowIfInactive(operation); return cancellationToken.IsCancellationRequested ? new CheatEngineFailure(CheatEngineFailureKind.Cancelled, operation, diff --git a/libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs b/libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs index 8804838..85e66f6 100644 --- a/libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs @@ -1,4 +1,5 @@ using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Hashing; using CheatEngine.Client.Results; @@ -7,6 +8,12 @@ namespace CheatEngine.Client.Core.Domains.Hashing; /// Preserves the separate file and target-memory hash contracts until their live-host gates are complete. internal sealed class UnavailableHashingClient : IHashingClient { + private readonly CoreLifetime? _lifetime; + + internal UnavailableHashingClient(CoreLifetime? lifetime = null) + { + _lifetime = lifetime; + } public bool TryHashMemory(MemoryHashRequest request, out HashDigest digest, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -35,8 +42,8 @@ public HashDigest HashFile(FileHashRequest request, CancellationToken cancellati return UnavailableCapabilityFailure.Throw(failure); } - private static CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) + private CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) { - return UnavailableCapabilityFailure.Create("Target-memory and file hashing", operation, cancellationToken); + return UnavailableCapabilityFailure.Create(_lifetime, "Target-memory and file hashing", operation, cancellationToken); } } diff --git a/libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs b/libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs index 459e033..a3c8589 100644 --- a/libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Events; using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Results; @@ -10,6 +11,12 @@ namespace CheatEngine.Client.Core.Domains.Hotkeys; /// Preserves copied hotkey semantics until host callback lifecycle behavior passes the live-host gate. internal sealed class UnavailableHotkeyClient : IHotkeyClient { + private readonly CoreLifetime? _lifetime; + + internal UnavailableHotkeyClient(CoreLifetime? lifetime = null) + { + _lifetime = lifetime; + } public bool TryRegister(HotkeyRegistration registration, HotkeyHandler handler, EventStreamOptions streamOptions, [NotNullWhen(true)] out IHotkeyLease? lease, out CheatEngineFailure failure, CancellationToken cancellationToken = default) @@ -17,7 +24,7 @@ public bool TryRegister(HotkeyRegistration registration, HotkeyHandler handler, ArgumentNullException.ThrowIfNull(handler); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); lease = null; - failure = UnavailableCapabilityFailure.Create("Hotkeys", "Hotkeys.Register", cancellationToken); + failure = UnavailableCapabilityFailure.Create(_lifetime, "Hotkeys", "Hotkeys.Register", cancellationToken); return false; } diff --git a/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs index d09e9a2..d0b24ec 100644 --- a/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs @@ -236,6 +236,7 @@ public bool TryRegisterSymbol(SymbolRegistration registration, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { lease = null; + _lifetime.ThrowIfInactive("Inspection.RegisterSymbol"); if (!TryReserveSymbolName(registration.Name, out failure)) { return false; diff --git a/libs/CheatEngine.Client.Core/Domains/LocalProcessDiagnostics.cs b/libs/CheatEngine.Client.Core/Domains/LocalProcessDiagnostics.cs new file mode 100644 index 0000000..72219fb --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LocalProcessDiagnostics.cs @@ -0,0 +1,110 @@ +using System.Collections.Immutable; +using System.ComponentModel; + +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains; + +/// Provides the explicitly local, Cheat-Engine-independent process diagnostic contract. +internal sealed class LocalProcessDiagnostics(IProcessHost host) : ILocalProcessDiagnostics +{ + private readonly IProcessHost _host = host ?? throw new ArgumentNullException(nameof(host)); + + public bool TryGetProcesses( + ProcessEnumerationRequest request, + out ProcessEnumerationResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.MaximumItems); + if (request.NameContains is { Length: 0 }) + { + throw new ArgumentException("A process-name filter must be null or non-empty.", nameof(request)); + } + + if (cancellationToken.IsCancellationRequested) + { + return Cancel(out result, out failure); + } + + try + { + IReadOnlyList processes = _host.GetLocalProcesses(); + List matches = new(processes.Count); + foreach (LocalProcessInfo process in processes) + { + if (cancellationToken.IsCancellationRequested) + { + return Cancel(out result, out failure); + } + + if (Matches(request, process)) + { + matches.Add(process); + } + } + + matches.Sort(static (left, right) => left.Id.CompareTo(right.Id)); + int count = Math.Min(matches.Count, request.MaximumItems); + ProcessInfoSnapshot[] snapshots = new ProcessInfoSnapshot[count]; + for (int index = 0; index < count; index++) + { + if (cancellationToken.IsCancellationRequested) + { + return Cancel(out result, out failure); + } + + LocalProcessInfo process = matches[index]; + snapshots[index] = new ProcessInfoSnapshot( + new LocalProcessId(process.Id), + process.Name, + process.ExecutablePath); + } + + result = new ProcessEnumerationResult(ImmutableArray.Create(snapshots), matches.Count > count); + failure = default; + return true; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or Win32Exception + or PlatformNotSupportedException) + { + result = default; + failure = new CheatEngineFailure( + CheatEngineFailureKind.OperationRejected, + "LocalProcesses.GetProcesses", + "The local process list could not be materialized.", + exception); + return false; + } + } + + public ProcessEnumerationResult GetProcesses( + ProcessEnumerationRequest request, + CancellationToken cancellationToken = default) + { + if (TryGetProcesses(request, out ProcessEnumerationResult result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + private static bool Matches(ProcessEnumerationRequest request, LocalProcessInfo process) + { + return request.NameContains is null || + process.Name?.Contains(request.NameContains, StringComparison.OrdinalIgnoreCase) == true; + } + + private static bool Cancel(out ProcessEnumerationResult result, out CheatEngineFailure failure) + { + result = default; + failure = new CheatEngineFailure( + CheatEngineFailureKind.Cancelled, + "LocalProcesses.GetProcesses", + "The operation was cancelled before local-process materialization completed."); + return false; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs index fd3d6a4..36c4428 100644 --- a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs @@ -12,6 +12,7 @@ namespace CheatEngine.Client.Core.Domains; internal sealed class LuaClient : ILuaClient { private readonly ICheatEngineDispatcher _dispatcher; + private readonly Action? _admitStatefulOperation; private readonly Func _epochProvider; private readonly Func _isContextCurrent; @@ -40,7 +41,8 @@ private LuaClient(ICheatEngineDispatcher dispatcher, LuaClientInitialization ini initialization.EpochProvider, initialization.IsContextCurrent, initialization.TrackLease, - initialization.UntrackLease) + initialization.UntrackLease, + initialization.AdmitStatefulOperation) { } @@ -50,7 +52,8 @@ internal LuaClient( Func epochProvider, Func isContextCurrent, Action? trackLease = null, - Action? untrackLease = null) + Action? untrackLease = null, + Action? admitStatefulOperation = null) { _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _epochProvider = epochProvider ?? throw new ArgumentNullException(nameof(epochProvider)); @@ -61,6 +64,7 @@ internal LuaClient( _untrackLease = untrackLease ?? (static _ => { }); + _admitStatefulOperation = admitStatefulOperation; } public bool TryRegisterModule(ILuaModule luaModule, [NotNullWhen(true)] out ILuaModuleLease? lease, @@ -68,6 +72,7 @@ public bool TryRegisterModule(ILuaModule luaModule, [NotNullWhen(true)] out ILua { ArgumentNullException.ThrowIfNull(luaModule); lease = null; + Admit("Lua.RegisterModule"); if (cancellationToken.IsCancellationRequested) { failure = CoreFailureFactory.Cancelled("Lua.RegisterModule"); @@ -156,6 +161,7 @@ public bool TryExecute(ILuaOperation operation, [MaybeNullWhen out CheatEngineFailure failure, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(operation); + Admit("Lua.Execute"); if (cancellationToken.IsCancellationRequested) { result = default; @@ -188,6 +194,7 @@ public bool TryExecute(TOperation operation, [MaybeNullWhen out CheatEngineFailure failure, CancellationToken cancellationToken) where TOperation : struct, ILuaOperation { + Admit("Lua.Execute"); if (cancellationToken.IsCancellationRequested) { result = default; @@ -411,7 +418,8 @@ private static LuaClientInitialization CreateProductionInitialization(CoreLifeti () => lifetime.Epoch, () => lifetime.IsActivationCurrent, lease => lifetime.Track(lease), - lease => lifetime.Untrack(lease)); + lease => lifetime.Untrack(lease), + lifetime.ThrowIfInactive); } private readonly record struct LuaOperationResult( @@ -423,7 +431,10 @@ private readonly record struct LuaClientInitialization( Func EpochProvider, Func IsContextCurrent, Action TrackLease, - Action UntrackLease); + Action UntrackLease, + Action AdmitStatefulOperation); + + private void Admit(string operation) => _admitStatefulOperation?.Invoke(operation); private readonly struct LuaOperationDispatchState { diff --git a/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs b/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs index 1056b48..bf2df22 100644 --- a/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.ComponentModel; using CheatEngine.Client.Core.Infrastructure; @@ -14,6 +13,7 @@ namespace CheatEngine.Client.Core.Domains; /// Owns deterministic target selection without taking ownership of Cheat Engine's global process state. internal sealed class ProcessClient : IProcessClient { + private readonly Action? _admitStatefulOperation; private readonly ICheatEngineDispatcher _dispatcher; private readonly IProcessHost _host; private readonly Lock _selectionGate = new(); @@ -21,8 +21,12 @@ internal sealed class ProcessClient : IProcessClient private ProcessSelection? _lastSelection; internal ProcessClient(ICheatEngineDispatcher dispatcher, CoreLifetime lifetime) - : this(dispatcher, new LocalProcessHost(), - lifetime?.TargetSelection ?? throw new ArgumentNullException(nameof(lifetime))) + : this(dispatcher, new LocalProcessHost(), lifetime?.TargetSelection ?? throw new ArgumentNullException(nameof(lifetime)), lifetime.ThrowIfInactive) + { + } + + internal ProcessClient(ICheatEngineDispatcher dispatcher, IProcessHost host, CoreLifetime lifetime) + : this(dispatcher, host, lifetime?.TargetSelection ?? throw new ArgumentNullException(nameof(lifetime)), lifetime.ThrowIfInactive) { } @@ -30,10 +34,16 @@ internal ProcessClient( ICheatEngineDispatcher dispatcher, IProcessHost host, TargetSelectionLifetime selectionLifetime) + : this(dispatcher, host, selectionLifetime, null) + { + } + + internal ProcessClient(ICheatEngineDispatcher dispatcher, IProcessHost host, TargetSelectionLifetime selectionLifetime, Action? admitStatefulOperation) { _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _host = host ?? throw new ArgumentNullException(nameof(host)); _selectionLifetime = selectionLifetime ?? throw new ArgumentNullException(nameof(selectionLifetime)); + _admitStatefulOperation = admitStatefulOperation; } public bool TryGetCurrent( @@ -44,78 +54,6 @@ public bool TryGetCurrent( return TryReadCurrent("Processes.GetCurrent", out snapshot, out failure, cancellationToken); } - public bool TryGetProcesses( - ProcessEnumerationRequest request, - out ProcessEnumerationResult result, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - ValidateEnumerationRequest(request); - if (cancellationToken.IsCancellationRequested) - { - result = default; - failure = Cancelled("Processes.GetProcesses"); - return false; - } - - try - { - IReadOnlyList localProcesses = _host.GetLocalProcesses(); - List matching = new(localProcesses.Count); - for (int index = 0; index < localProcesses.Count; index++) - { - LocalProcessInfo process = localProcesses[index]; - if (Matches(request, process)) - { - matching.Add(process); - } - } - - matching.Sort(static (left, right) => left.Id.CompareTo(right.Id)); - int materializedCount = Math.Min(matching.Count, request.MaximumItems); - ProcessInfoSnapshot[] snapshots = new ProcessInfoSnapshot[materializedCount]; - for (int index = 0; index < materializedCount; index++) - { - LocalProcessInfo process = matching[index]; - snapshots[index] = new ProcessInfoSnapshot( - new TargetProcessId(process.Id), - process.Name, - process.ExecutablePath); - } - - result = new ProcessEnumerationResult( - ImmutableArray.Create(snapshots), - matching.Count > materializedCount); - failure = default; - return true; - } - catch (Exception exception) when (exception is ArgumentException or InvalidOperationException - or Win32Exception or PlatformNotSupportedException) - { - result = default; - failure = new CheatEngineFailure( - CheatEngineFailureKind.OperationRejected, - "Processes.GetProcesses", - "The local process list could not be materialized.", - exception); - return false; - } - } - - public ProcessEnumerationResult GetProcesses( - ProcessEnumerationRequest request, - CancellationToken cancellationToken = default) - { - if (TryGetProcesses(request, out ProcessEnumerationResult result, out CheatEngineFailure failure, - cancellationToken)) - { - return result; - } - - failure.Throw(); - return default; - } - public ProcessSnapshot GetCurrent(CancellationToken cancellationToken = default) { if (TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) @@ -156,6 +94,7 @@ public bool TryAttach( { throw new ArgumentOutOfRangeException(nameof(processId)); } + Admit("Processes.Attach"); CurrentProcessCapture captured = default; if (!_dispatcher.TryInvoke( @@ -207,7 +146,30 @@ public bool TryAttachExactName( CancellationToken cancellationToken = default) { string expectedName = NormalizeExactProcessName(processName); - IReadOnlyList matches = _host.FindProcessesByExactName(expectedName); + Admit("Processes.AttachExactName"); + if (cancellationToken.IsCancellationRequested) + { + snapshot = default; + failure = Cancelled("Processes.AttachExactName"); + return false; + } + IReadOnlyList matches; + try + { + matches = _host.FindProcessesByExactName(expectedName); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or Win32Exception + or PlatformNotSupportedException) + { + snapshot = default; + failure = new CheatEngineFailure( + CheatEngineFailureKind.OperationRejected, + "Processes.AttachExactName", + "The local process catalog could not be searched for an attach candidate.", + exception); + return false; + } + if (matches.Count == 0) { snapshot = default; @@ -366,19 +328,14 @@ private CurrentProcessCapture CaptureCurrent(string operation) } TargetProcessId id = new(checked((int) processId)); - if (!_host.TryGetLocalProcess(id.Value, out LocalProcessInfo process)) - { - ClearObservedSelection(operation); - return new CurrentProcessCapture(CurrentProcessCaptureFailure.LocalProcessUnavailable); - } - - if (process.Id != id.Value) + bool hasLocalMetadata = _host.TryGetLocalProcess(id.Value, out LocalProcessInfo process); + if (hasLocalMetadata && process.Id != id.Value) { return new CurrentProcessCapture(CurrentProcessCaptureFailure.InvalidLocalMetadata, process.Id); } CheatEngineArchitecture architecture = TryGetTargetArchitecture(); - return new CurrentProcessCapture(ObserveSelection(id, process, architecture, operation)); + return new CurrentProcessCapture(ObserveSelection(id, hasLocalMetadata ? process : default, architecture, operation)); } private static bool TryGetCapturedSnapshot( @@ -401,10 +358,6 @@ private static bool TryGetCapturedSnapshot( CheatEngineFailureKind.TargetNotAttached, operation, "Cheat Engine has no selected local target process."), - CurrentProcessCaptureFailure.LocalProcessUnavailable => new CheatEngineFailure( - CheatEngineFailureKind.TargetNotAttached, - operation, - "The selected target process is no longer available in local process metadata."), CurrentProcessCaptureFailure.InvalidLocalMetadata => new CheatEngineFailure( CheatEngineFailureKind.InvalidHostResult, operation, @@ -450,10 +403,7 @@ private ProcessSnapshot ObserveSelection( } _lastSelection = selection; - return new ProcessSnapshot( - id, - process.Name, - process.ExecutablePath, + return new ProcessSnapshot(id, process.Name, process.ExecutablePath, architecture, _selectionLifetime.Epoch); } @@ -494,15 +444,6 @@ private static string NormalizeExactProcessName(string processName) return normalized; } - private static void ValidateEnumerationRequest(ProcessEnumerationRequest request) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.MaximumItems); - if (request.NameContains is { Length: 0 }) - { - throw new ArgumentException("A process-name filter must be null or non-empty.", nameof(request)); - } - } - private static void ValidateStartRequest(ProcessStartRequest request) { if (string.IsNullOrWhiteSpace(request.ExecutablePath)) @@ -521,18 +462,13 @@ private static void ValidateStartRequest(ProcessStartRequest request) } } - private static bool Matches(ProcessEnumerationRequest request, LocalProcessInfo process) - { - return request.NameContains is null || - process.Name?.IndexOf(request.NameContains, StringComparison.OrdinalIgnoreCase) >= 0; - } - - private static bool TryUnavailable( + private bool TryUnavailable( string operation, out T result, out CheatEngineFailure failure, CancellationToken cancellationToken) { + Admit(operation); result = default!; failure = cancellationToken.IsCancellationRequested ? Cancelled(operation) @@ -543,6 +479,8 @@ private static bool TryUnavailable( return false; } + private void Admit(string operation) => _admitStatefulOperation?.Invoke(operation); + private static CheatEngineFailure Cancelled(string operation) { return new CheatEngineFailure( @@ -573,7 +511,6 @@ private enum CurrentProcessCaptureFailure { None, NoTargetSelected, - LocalProcessUnavailable, InvalidLocalMetadata } } diff --git a/libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs b/libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs index 96893e1..de70b24 100644 --- a/libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs @@ -1,4 +1,5 @@ using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Results; @@ -7,6 +8,12 @@ namespace CheatEngine.Client.Core.Domains.RemoteExecution; /// Preserves remote-execution intent contracts until allocation and thread-affinity live gates are complete. internal sealed class UnavailableRemoteExecutionClient : IRemoteExecutionClient { + private readonly CoreLifetime? _lifetime; + + internal UnavailableRemoteExecutionClient(CoreLifetime? lifetime = null) + { + _lifetime = lifetime; + } public bool TryInjectLibrary(RemoteDllInjectionRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -34,8 +41,8 @@ public RemoteCallResult Invoke(RemoteCallRequest request, CancellationToken canc return UnavailableCapabilityFailure.Throw(failure); } - private static CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) + private CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) { - return UnavailableCapabilityFailure.Create("Remote execution and injection", operation, cancellationToken); + return UnavailableCapabilityFailure.Create(_lifetime, "Remote execution and injection", operation, cancellationToken); } } diff --git a/libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs b/libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs index a20440b..3cd906d 100644 --- a/libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs @@ -1,4 +1,5 @@ using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Results; using CheatEngine.Client.Speed; @@ -7,6 +8,12 @@ namespace CheatEngine.Client.Core.Domains.Speed; /// Preserves validated speed semantics until Cheat Engine speed control passes its live-host gate. internal sealed class UnavailableSpeedClient : ISpeedClient { + private readonly CoreLifetime? _lifetime; + + internal UnavailableSpeedClient(CoreLifetime? lifetime = null) + { + _lifetime = lifetime; + } public bool TryGetMultiplier(out SpeedMultiplier multiplier, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -34,8 +41,8 @@ public void SetMultiplier(SpeedMultiplier multiplier, CancellationToken cancella UnavailableCapabilityFailure.Throw(failure); } - private static CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) + private CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) { - return UnavailableCapabilityFailure.Create("Target speed control", operation, cancellationToken); + return UnavailableCapabilityFailure.Create(_lifetime, "Target speed control", operation, cancellationToken); } } diff --git a/libs/CheatEngine.Client.Core/Domains/TableClient.cs b/libs/CheatEngine.Client.Core/Domains/TableClient.cs index e167b6a..54fe7a9 100644 --- a/libs/CheatEngine.Client.Core/Domains/TableClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/TableClient.cs @@ -14,7 +14,8 @@ namespace CheatEngine.Client.Core.Domains; internal sealed class TableClient( ICheatEngineDispatcher dispatcher, CoreClientPolicy policy, - ITableRecordMutationPort? recordMutations = null) : ITableClient + ITableRecordMutationPort? recordMutations = null, + CoreLifetime? lifetime = null) : ITableClient { private const string _getHierarchyOperation = "Tables.GetHierarchy"; @@ -23,6 +24,7 @@ internal sealed class TableClient( private readonly CoreClientPolicy _policy = policy ?? throw new ArgumentNullException(nameof(policy)); private readonly ITableRecordMutationPort _recordMutations = recordMutations ?? new SdkTableRecordMutationPort(); + private readonly CoreLifetime? _lifetime = lifetime; public bool TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure failure, CancellationToken cancellationToken = default) @@ -428,6 +430,7 @@ public MemoryRecordHierarchySnapshot GetHierarchy(MemoryRecordId rootId, public bool TryLoadTrustedTable(TableLoadRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + _lifetime?.ThrowIfInactive("Tables.LoadTrustedTable"); if (!TryAuthorize(request.File.FullPath, "Tables.LoadTrustedTable", out failure)) { return false; @@ -448,6 +451,7 @@ public void LoadTrustedTable(TableLoadRequest request, CancellationToken cancell public bool TrySaveTable(TableSaveRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + _lifetime?.ThrowIfInactive("Tables.SaveTable"); if (!TryAuthorize(request.File.FullPath, "Tables.SaveTable", out failure)) { return false; diff --git a/libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs b/libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs index 81f36dd..8ff19e4 100644 --- a/libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Events; using CheatEngine.Client.Results; using CheatEngine.Client.Timers; @@ -10,6 +11,12 @@ namespace CheatEngine.Client.Core.Domains.Timers; /// Preserves recurring timer semantics until timer callback cleanup passes the live-host gate. internal sealed class UnavailableTimerClient : ITimerClient { + private readonly CoreLifetime? _lifetime; + + internal UnavailableTimerClient(CoreLifetime? lifetime = null) + { + _lifetime = lifetime; + } public bool TryRegister(TimerRequest request, TimerHandler handler, EventStreamOptions streamOptions, [NotNullWhen(true)] out ITimerLease? lease, out CheatEngineFailure failure, CancellationToken cancellationToken = default) @@ -17,7 +24,7 @@ public bool TryRegister(TimerRequest request, TimerHandler handler, EventStreamO ArgumentNullException.ThrowIfNull(handler); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); lease = null; - failure = UnavailableCapabilityFailure.Create("Timers", "Timers.Register", cancellationToken); + failure = UnavailableCapabilityFailure.Create(_lifetime, "Timers", "Timers.Register", cancellationToken); return false; } diff --git a/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs b/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs index d1da5ce..c27a94b 100644 --- a/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs +++ b/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs @@ -2,6 +2,7 @@ using CheatEngine.Client.Results; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Core.Infrastructure; namespace CheatEngine.Client.Core.Domains; @@ -11,6 +12,8 @@ namespace CheatEngine.Client.Core.Domains; /// internal sealed class UnavailableValueScanner : IValueScanner { + private readonly CoreLifetime? _lifetime; + internal UnavailableValueScanner(CoreLifetime? lifetime = null) => _lifetime = lifetime; // This is a deliberate product gate, not a transient host-capability probe. SDK 1.0.0 exposes the state machine // only through MemoryScanSession.Adopt(Owned, Owned), while Owned has an internal // constructor and the Lua-global generator cannot marshal CEObject results. Bypassing that with reflection or a @@ -24,6 +27,7 @@ public bool TryCreateSession(out IValueScanSession? session, out CheatEngineFail CancellationToken cancellationToken = default) { session = null; + _lifetime?.ThrowIfInactive("Scans.CreateSession"); failure = cancellationToken.IsCancellationRequested ? new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Scans.CreateSession", "The operation was cancelled before Cheat Engine work began.") diff --git a/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs b/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs index c75ecbe..f1a3821 100644 --- a/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs @@ -14,18 +14,20 @@ namespace CheatEngine.Client.Core.Domains; internal sealed class UnsafeLuaClient : IUnsafeLuaClient { private readonly ICheatEngineDispatcher _dispatcher; + private readonly CoreLifetime? _lifetime; private readonly CoreClientPolicy _policy; - internal UnsafeLuaClient(SdkMainThreadDispatcher dispatcher, CoreClientPolicy policy) - : this((ICheatEngineDispatcher) (dispatcher ?? throw new ArgumentNullException(nameof(dispatcher))), policy) + internal UnsafeLuaClient(SdkMainThreadDispatcher dispatcher, CoreClientPolicy policy, CoreLifetime lifetime) + : this((ICheatEngineDispatcher) (dispatcher ?? throw new ArgumentNullException(nameof(dispatcher))), policy, lifetime) { } /// Deterministic internal seam for policy tests; production construction uses the SDK dispatcher overload. - internal UnsafeLuaClient(ICheatEngineDispatcher dispatcher, CoreClientPolicy policy) + internal UnsafeLuaClient(ICheatEngineDispatcher dispatcher, CoreClientPolicy policy, CoreLifetime? lifetime = null) { _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + _lifetime = lifetime; } public bool TryExecute(LuaScript script, out CheatEngineFailure failure, @@ -37,6 +39,8 @@ public bool TryExecute(LuaScript script, out CheatEngineFailure failure, throw new ArgumentException("A Lua chunk name must be null or non-empty.", nameof(script)); } + _lifetime?.ThrowIfInactive("Lua.ExecuteUnsafe"); + if (!_policy.EnableUnsafeLuaExecution) { failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, "Lua.ExecuteUnsafe", diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs index c965825..d7d47e0 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs @@ -129,7 +129,8 @@ public CheatEngineClientBuilder EnableUnsafeLuaExecution() Services.AddSingleton(); Services.AddSingleton(static serviceProvider => new UnsafeLuaClient( serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService())); + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); return this; } } diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs index 4881b3b..334ae80 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs @@ -118,9 +118,13 @@ private static void AddCoreServices(IServiceCollection services) services.TryAddSingleton(static serviceProvider => serviceProvider.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(static serviceProvider => + new LocalProcessDiagnostics(serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => new ProcessClient( serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => serviceProvider.GetRequiredService()); @@ -135,16 +139,26 @@ private static void AddCoreServices(IServiceCollection services) services.TryAddSingleton(static serviceProvider => serviceProvider.GetRequiredService()); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(static serviceProvider => + new UnavailableValueScanner(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + new UnavailableAllocationClient(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + new UnavailableAssemblyClient(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + new UnavailableRemoteExecutionClient(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + new UnavailableDebuggerClient(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + new UnavailableHotkeyClient(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + new UnavailableTimerClient(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + new UnavailableSpeedClient(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + new UnavailableHashingClient(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + new UnavailableDbvmClient(serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => new InspectionClient( @@ -156,7 +170,8 @@ private static void AddCoreServices(IServiceCollection services) services.TryAddSingleton(static serviceProvider => new TableClient( serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService())); + serviceProvider.GetRequiredService(), + lifetime: serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => serviceProvider.GetRequiredService()); diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessDiagnosticsTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessDiagnosticsTests.cs new file mode 100644 index 0000000..4e2d20d --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessDiagnosticsTests.cs @@ -0,0 +1,132 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class LocalProcessDiagnosticsTests +{ + [Fact] + public void GetProcessesReturnsBoundedOrderedCopiedLocalMetadata() + { + FakeLocalProcessHost host = new( + [ + new LocalProcessInfo(52, "alpha-worker", "C:\\fixtures\\alpha-worker.exe"), + new LocalProcessInfo(43, "alpha-server", "C:\\fixtures\\alpha-server.exe"), + new LocalProcessInfo(44, "beta", "C:\\fixtures\\beta.exe") + ]); + LocalProcessDiagnostics diagnostics = new(host); + + ProcessEnumerationResult result = diagnostics.GetProcesses( + new ProcessEnumerationRequest(1, "ALPHA"), + TestContext.Current.CancellationToken); + + Assert.True(result.IsTruncated); + ProcessInfoSnapshot snapshot = Assert.Single(result.Processes); + Assert.Equal(new LocalProcessId(43), snapshot.Id); + Assert.Equal("alpha-server", snapshot.Name); + Assert.Equal("C:\\fixtures\\alpha-server.exe", snapshot.ExecutablePath); + } + + [Fact] + public void TryGetProcessesHonorsCancellationBeforeReadingTheLocalCatalog() + { + FakeLocalProcessHost host = new([]); + LocalProcessDiagnostics diagnostics = new(host); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = diagnostics.TryGetProcesses( + new ProcessEnumerationRequest(1), + out ProcessEnumerationResult result, + out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal("LocalProcesses.GetProcesses", failure.Operation); + Assert.Equal(0, host.GetLocalProcessesCalls); + } + + [Fact] + public void TryGetProcessesRejectsAnInvalidRequestBeforeReadingTheLocalCatalog() + { + FakeLocalProcessHost host = new([]); + LocalProcessDiagnostics diagnostics = new(host); + + Assert.Throws(() => + diagnostics.TryGetProcesses(default, out _, out _, TestContext.Current.CancellationToken)); + + Assert.Equal(0, host.GetLocalProcessesCalls); + } + + [Fact] + public void TryGetProcessesMapsLocalCatalogFailuresWithoutClaimingTargetState() + { + FakeLocalProcessHost host = new([]) { GetLocalProcessesException = new InvalidOperationException("fixture enumeration failed") }; + LocalProcessDiagnostics diagnostics = new(host); + + bool succeeded = diagnostics.TryGetProcesses( + new ProcessEnumerationRequest(1), + out ProcessEnumerationResult result, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("LocalProcesses.GetProcesses", failure.Operation); + Assert.Equal(1, host.GetLocalProcessesCalls); + } + + [Fact] + public void CopiedLocalDiagnosticsRemainUsableAfterAClientActivationExpires() + { + FakeLocalProcessHost host = new( + [ + new LocalProcessInfo(43, "fixture", "C:\\fixtures\\fixture.exe") + ]); + LocalProcessDiagnostics diagnostics = new(host); + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + ProcessInfoSnapshot snapshot = Assert.Single(diagnostics.GetProcesses( + new ProcessEnumerationRequest(1), TestContext.Current.CancellationToken).Processes); + context.IsCurrent = false; + + Assert.Throws(() => lifetime.ThrowIfInactive("Test.Stale")); + Assert.Equal(new LocalProcessId(43), snapshot.Id); + Assert.Equal("fixture", snapshot.Name); + } + + private sealed class FakeLocalProcessHost(IReadOnlyList processes) : IProcessHost + { + internal int GetLocalProcessesCalls { get; private set; } + internal Exception? GetLocalProcessesException { get; init; } + + public long GetOpenedProcessId() => 0; + public void OpenProcess(long processId) => throw new NotSupportedException(); + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) + { + process = default; + return false; + } + + public IReadOnlyList GetLocalProcesses() + { + GetLocalProcessesCalls++; + if (GetLocalProcessesException is { } exception) + { + throw exception; + } + + return processes; + } + + public IReadOnlyList FindProcessesByExactName(string processName) => []; + public CheatEngineArchitecture GetTargetArchitecture() => CheatEngineArchitecture.Unknown; + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs index b580f4b..f5cc24f 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs @@ -29,85 +29,6 @@ public void RefreshKeepsTheSelectionEpochForTheSamePidAndArchitecture() Assert.Equal(initial.SelectionEpoch, refreshed.SelectionEpoch); } - [Fact] - public void GetProcessesFiltersOrdersAndTruncatesCopiedLocalMetadataWithoutSelectingAnyProcess() - { - FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); - host.LocalProcesses[52] = new LocalProcessInfo(52, "alpha-worker", "C:\\fixtures\\alpha-worker.exe"); - host.LocalProcesses[43] = new LocalProcessInfo(43, "alpha-server", "C:\\fixtures\\alpha-server.exe"); - host.LocalProcesses[44] = new LocalProcessInfo(44, "beta", "C:\\fixtures\\beta.exe"); - using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - - ProcessEnumerationResult result = client.GetProcesses( - new ProcessEnumerationRequest(1, "ALPHA"), - TestContext.Current.CancellationToken); - - Assert.True(result.IsTruncated); - ProcessInfoSnapshot snapshot = Assert.Single(result.Processes); - Assert.Equal(new TargetProcessId(43), snapshot.Id); - Assert.Equal("alpha-server", snapshot.Name); - Assert.Equal("C:\\fixtures\\alpha-server.exe", snapshot.ExecutablePath); - Assert.Empty(host.OpenProcessCalls); - Assert.Equal(0, selectionLifetime.Epoch); - } - - [Fact] - public void TryGetProcessesHonorsCancellationBeforeEnumeratingTheHost() - { - FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); - using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - using CancellationTokenSource cancellation = new(); - cancellation.Cancel(); - - bool succeeded = client.TryGetProcesses( - new ProcessEnumerationRequest(1), - out ProcessEnumerationResult result, - out CheatEngineFailure failure, - cancellation.Token); - - Assert.False(succeeded); - Assert.Equal(default, result); - Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); - Assert.Equal(0, host.GetLocalProcessesCalls); - } - - [Fact] - public void TryGetProcessesRejectsTheDefaultRequestBeforeEnumeratingTheHost() - { - FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); - using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - - Assert.Throws(() => - client.TryGetProcesses(default, out _, out _, TestContext.Current.CancellationToken)); - - Assert.Equal(0, host.GetLocalProcessesCalls); - } - - [Fact] - public void TryGetProcessesMapsAHostMaterializationFailureWithoutSelectingAnyProcess() - { - FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); - host.GetLocalProcessesException = new InvalidOperationException("fixture enumeration failed"); - using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - - bool succeeded = client.TryGetProcesses( - new ProcessEnumerationRequest(1), - out ProcessEnumerationResult result, - out CheatEngineFailure failure, - TestContext.Current.CancellationToken); - - Assert.False(succeeded); - Assert.Equal(default, result); - Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); - Assert.Equal("Processes.GetProcesses", failure.Operation); - Assert.Equal(1, host.GetLocalProcessesCalls); - Assert.Empty(host.OpenProcessCalls); - } - [Fact] public void RefreshChangesPidAdvancesSelectionEpochAndDisposesTheOldTargetLease() { @@ -179,7 +100,7 @@ public void TryGetCurrentReportsTargetNotAttachedAndInvalidatesTheKnownSelection } [Fact] - public void TryRefreshReportsTargetNotAttachedOnceWhenTheSelectedProcessDisappears() + public void TryRefreshRetainsTheCheatEngineSelectionWhenLocalMetadataDisappears() { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); using ControlledCoreLifetimeContext activationContext = new(); @@ -203,16 +124,17 @@ public void TryRefreshReportsTargetNotAttachedOnceWhenTheSelectedProcessDisappea out CheatEngineFailure secondFailure, TestContext.Current.CancellationToken); - Assert.False(firstSucceeded); - Assert.Equal(default, firstSnapshot); - Assert.Equal(CheatEngineFailureKind.TargetNotAttached, firstFailure.Kind); - Assert.Equal("Processes.Refresh", firstFailure.Operation); - Assert.False(secondSucceeded); - Assert.Equal(default, secondSnapshot); - Assert.Equal(CheatEngineFailureKind.TargetNotAttached, secondFailure.Kind); - Assert.Equal("Processes.Refresh", secondFailure.Operation); - Assert.Equal(1, selectionLifetime.Epoch); - Assert.Equal(1, lease.DisposeCount); + Assert.True(firstSucceeded); + Assert.Equal(default, firstFailure); + Assert.Equal(initial.Id, firstSnapshot.Id); + Assert.Null(firstSnapshot.Name); + Assert.Null(firstSnapshot.ExecutablePath); + Assert.True(secondSucceeded); + Assert.Equal(default, secondFailure); + Assert.Equal(initial.Id, secondSnapshot.Id); + Assert.Equal(initial.SelectionEpoch, secondSnapshot.SelectionEpoch); + Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + Assert.Equal(0, lease.DisposeCount); } [Fact] @@ -324,7 +246,7 @@ public void AttachVerifiesThePidSelectedByCheatEngineBeforeReturningTheSnapshot( } [Fact] - public void TryAttachReportsTargetNotAttachedWhenTheSelectedProcessDisappearsAfterOpen() + public void TryAttachReturnsTheSelectedCheatEngineTargetWhenLocalMetadataDisappearsAfterOpen() { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture-b", "C:\\fixtures\\fixture-b.exe"); @@ -346,10 +268,11 @@ public void TryAttachReportsTargetNotAttachedWhenTheSelectedProcessDisappearsAft out CheatEngineFailure failure, TestContext.Current.CancellationToken); - Assert.False(succeeded); - Assert.Equal(default, snapshot); - Assert.Equal(CheatEngineFailureKind.TargetNotAttached, failure.Kind); - Assert.Equal("Processes.Attach", failure.Operation); + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(new TargetProcessId(43), snapshot.Id); + Assert.Null(snapshot.Name); + Assert.Null(snapshot.ExecutablePath); Assert.Equal([43L], host.OpenProcessCalls); Assert.Equal(1, selectionLifetime.Epoch); Assert.Equal(1, lease.DisposeCount); @@ -419,6 +342,62 @@ public void TryAttachExactNameRejectsZeroAndMultipleCandidatesWithoutOpeningAnyP Assert.Empty(ambiguousHost.OpenProcessCalls); } + [Fact] + public void TryAttachExactNameRejectsAStaleActivationBeforeLocalProcessDiscovery() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using ControlledCoreLifetimeContext context = new() { IsCurrent = false }; + using CoreLifetime lifetime = new(context); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime, lifetime.ThrowIfInactive); + + CheatEngineActivationExpiredException exception = Assert.Throws(() => + client.TryAttachExactName("fixture.exe", out _, out _, TestContext.Current.CancellationToken)); + + Assert.Equal("Processes.AttachExactName", exception.Failure.Operation); + Assert.Equal(0, host.FindProcessesByExactNameCalls); + Assert.Empty(host.OpenProcessCalls); + } + + [Fact] + public void TryAttachExactNameHonorsCancellationBeforeLocalProcessDiscovery() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime, lifetime.ThrowIfInactive); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = client.TryAttachExactName("fixture.exe", out ProcessSnapshot snapshot, + out CheatEngineFailure failure, cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(0, host.FindProcessesByExactNameCalls); + } + + [Fact] + public void TryAttachExactNameMapsLocalCatalogFailuresBeforeChangingTheCheatEngineSelection() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.FindProcessesByExactNameException = new InvalidOperationException("fixture discovery failed"); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + bool succeeded = client.TryAttachExactName("fixture.exe", out ProcessSnapshot snapshot, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Processes.AttachExactName", failure.Operation); + Assert.Equal(1, host.FindProcessesByExactNameCalls); + Assert.Empty(host.OpenProcessCalls); + } + [Fact] public void AttachExactNameUsesTheSingleExactCandidateAndReturnsCopiedMetadata() { @@ -639,6 +618,12 @@ internal int GetOpenedProcessIdCalls private set; } + internal int FindProcessesByExactNameCalls + { + get; + private set; + } + internal Exception? GetLocalProcessesException { get; @@ -651,6 +636,12 @@ internal Exception? GetOpenedProcessIdException set; } + internal Exception? FindProcessesByExactNameException + { + get; + set; + } + internal Action? AfterOpenProcess { get; @@ -711,6 +702,12 @@ public IReadOnlyList GetLocalProcesses() public IReadOnlyList FindProcessesByExactName(string processName) { + FindProcessesByExactNameCalls++; + if (FindProcessesByExactNameException is { } exception) + { + throw exception; + } + return NameMatches.TryGetValue(processName, out IReadOnlyList? matches) ? matches : []; } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs index 52a590a..731254b 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs @@ -1,4 +1,6 @@ using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; using CheatEngine.Client.Results; using CheatEngine.Client.Scanning; @@ -37,6 +39,21 @@ public void TryCreateSessionReportsCancellationWithoutAttemptingHostWork() Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); } + [Fact] + public void TryCreateSessionRejectsAStaleActivationBeforeCapabilityOrCancellation() + { + using ControlledCoreLifetimeContext context = new() { IsCurrent = false }; + using CoreLifetime lifetime = new(context); + UnavailableValueScanner scanner = new(lifetime); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + CheatEngineActivationExpiredException exception = Assert.Throws(() => + scanner.TryCreateSession(out _, out _, cancellation.Token)); + + Assert.Equal("Scans.CreateSession", exception.Failure.Operation); + } + [Fact] public void CreateSessionThrowsTheClassifiedUnavailableFailure() { diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs index f47d265..675b331 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs @@ -39,6 +39,7 @@ public void AddCheatEngineClientRegistersDescriptorsThatPassProviderValidationWi Assert.NotNull(builder); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(ICheatEngineClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(ILocalProcessDiagnostics)); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec)); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IAllocationClient)); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IAssemblyClient));