From 2775afed23e30d551d32b6cf70d10469c971b17b Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 17:40:24 +0800 Subject: [PATCH 01/22] feat: add generation-owned settings migration and application lifecycle --- .../Data/DataGenerationManager.Services.cs | 31 ++ .../Data/DataGenerationManager.cs | 5 +- .../Data/DataGenerationScope.cs | 13 +- .../Settings/ILegacySettingsSource.cs | 9 + .../ISettingsApplicationParticipant.cs | 25 + .../Settings/LegacySettingsSnapshot.cs | 78 +++ .../Settings/SettingsApplicationRequest.cs | 76 +++ .../Settings/SettingsAuthorityBootstrapper.cs | 73 +++ .../Settings/SettingsAuthorityResult.cs | 49 ++ .../SettingsAuthoritySession.Application.cs | 139 +++++ .../SettingsAuthoritySession.Lifetime.cs | 36 ++ .../Settings/SettingsAuthoritySession.cs | 192 +++++++ .../Settings/SettingsMigrationPlanner.cs | 144 +++++ ...gsApplicationBatchEditor.Reconciliation.cs | 56 ++ .../SettingsApplicationBatchEditor.cs | 156 ++++++ .../Settings/JsonSettingsRepository.Read.cs | 28 + .../Settings/WindowsLegacySettingsSource.cs | 34 ++ .../JsonSettingsRepositoryTests.cs | 25 + .../SettingsAuthorityBootstrapperTests.cs | 175 ++++++ .../SettingsAuthoritySessionTests.cs | 518 ++++++++++++++++++ .../SettingsGenerationLifetimeTests.cs | 168 ++++++ .../SettingsApplicationBatchEditorTests.cs | 144 +++++ .../Settings/SettingsMigrationPlannerTests.cs | 188 +++++++ .../SettingsStartupReconciliationTests.cs | 73 +++ .../2026-09-08-settings-generation-cutover.md | 46 ++ docs/reviews/1.0.0-execution-ledger.md | 7 + 26 files changed, 2486 insertions(+), 2 deletions(-) create mode 100644 ClashSharp/ClashSharp.Application/Data/DataGenerationManager.Services.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/ILegacySettingsSource.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/ISettingsApplicationParticipant.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/LegacySettingsSnapshot.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/SettingsApplicationRequest.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityBootstrapper.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityResult.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Lifetime.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/SettingsMigrationPlanner.cs create mode 100644 ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.Reconciliation.cs create mode 100644 ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.cs create mode 100644 ClashSharp/ClashSharp.Infrastructure/Settings/WindowsLegacySettingsSource.cs create mode 100644 ClashSharp/ClashSharp.Tests/Integration/SettingsAuthorityBootstrapperTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Integration/SettingsAuthoritySessionTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Integration/SettingsGenerationLifetimeTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsApplicationBatchEditorTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsMigrationPlannerTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsStartupReconciliationTests.cs create mode 100644 docs/design/2026-09-08-settings-generation-cutover.md diff --git a/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.Services.cs b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.Services.cs new file mode 100644 index 0000000..b670d1f --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.Services.cs @@ -0,0 +1,31 @@ +namespace ClashSharp.ApplicationModel.Data; + +public sealed partial class DataGenerationManager +{ + /// Resolves a generation-owned service and pins its complete asynchronous operation before transition can retire it. + /// Service provided by the scope's owned lifetime through . + /// Immutable operation result that can outlive the scope. + /// Owned operation; it must await all work and must not return a service or live repository handle. + /// Cancels acquisition and is passed to the owned operation without abandoning its task. + public async Task ExecuteAsync( + Func> operation, + CancellationToken cancellationToken) where TService : class + { + ArgumentNullException.ThrowIfNull(operation); + await using DataGenerationLease lease = await AcquireAsync(cancellationToken).ConfigureAwait(false); + TService service = lease.Scope.GetOwnedService(); + return await operation(service, lease.Descriptor, cancellationToken).ConfigureAwait(false); + } + + /// Captures an immutable in-memory projection under a short synchronous generation pin without blocking on asynchronous work. + /// Service provided by this generation's owned lifetime. + /// Immutable projection that can outlive the scope. + /// Pure synchronous reader; it must not perform I/O, start tasks, or return a live service or repository. + public TResult ReadSnapshot(Func capture) + where TService : class + { + ArgumentNullException.ThrowIfNull(capture); + using DataGenerationLease lease = AcquireCore(CancellationToken.None); + return capture(lease.Scope.GetOwnedService(), lease.Descriptor); + } +} diff --git a/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs index 35117e9..3377ec1 100644 --- a/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs +++ b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs @@ -72,6 +72,9 @@ public void Initialize( /// Cancels acquisition before a lease is granted. /// A lease that must cover the complete repository operation. public ValueTask AcquireAsync(CancellationToken cancellationToken) + => ValueTask.FromResult(AcquireCore(cancellationToken)); + + private DataGenerationLease AcquireCore(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); lock (_syncLock) @@ -93,7 +96,7 @@ public ValueTask AcquireAsync(CancellationToken cancellatio _leaseCount++; } - return ValueTask.FromResult(new DataGenerationLease(this, _currentScope)); + return new DataGenerationLease(this, _currentScope); } } diff --git a/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs b/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs index bf6165b..e468f68 100644 --- a/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs +++ b/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs @@ -12,7 +12,7 @@ public sealed class DataGenerationScope : IAsyncDisposable /// Initializes a paused scope without starting work or touching the filesystem. /// Immutable generation descriptor. - /// Optional composite repository lifetime transferred to this scope. + /// Optional composite repository lifetime transferred to this scope; scoped service access also requires it to implement . public DataGenerationScope( DataGenerationDescriptor descriptor, IAsyncDisposable? ownedLifetime = null) @@ -37,6 +37,17 @@ public DataGenerationScopeState State } } + internal TService GetOwnedService() where TService : class + { + if (_ownedLifetime is not IServiceProvider provider + || provider.GetService(typeof(TService)) is not TService service) + { + throw new InvalidOperationException("The requested service is not registered in this generation's owned lifetime."); + } + + return service; + } + /// Disposes an unclaimed staged scope; claimed scopes remain owner-controlled. public ValueTask DisposeAsync() { diff --git a/ClashSharp/ClashSharp.Application/Settings/ILegacySettingsSource.cs b/ClashSharp/ClashSharp.Application/Settings/ILegacySettingsSource.cs new file mode 100644 index 0000000..da5b3a7 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/ILegacySettingsSource.cs @@ -0,0 +1,9 @@ +namespace ClashSharp.ApplicationModel.Settings; + +/// Reads legacy preferences once during migration without exposing any writer. +public interface ILegacySettingsSource +{ + /// Reads one atomic allowlisted legacy preference snapshot. + /// Cancels observation before a migration write starts. + Task ReadSnapshotAsync(CancellationToken cancellationToken); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/ISettingsApplicationParticipant.cs b/ClashSharp/ClashSharp.Application/Settings/ISettingsApplicationParticipant.cs new file mode 100644 index 0000000..d1b32b0 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/ISettingsApplicationParticipant.cs @@ -0,0 +1,25 @@ +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Applies one settings batch through an admitted runtime boundary and independently probes its effect. +/// Implementations use explicit request values, never write settings authority, and own all tasks until completion. +public interface ISettingsApplicationParticipant +{ + /// Gets the application kind this participant can observe and apply. + SettingApplicationKind ApplicationKind { get; } + + /// Observes actual effective values without changing runtime or preference state. + /// Immutable generation and attempt to observe. + /// Caller-owned lease retained for the complete command. + /// Observation cancellation; failures must not fabricate desired values. + Task ProbeAsync( + SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken); + + /// Converges idempotently toward explicit desired values; successful return still requires an independent probe. + /// Immutable target and companion settings. + /// Existing authority; implementations must not reacquire ordinary admission. + /// Owner-controlled token; a page cancellation cannot abandon a started effect. + Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/LegacySettingsSnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/LegacySettingsSnapshot.cs new file mode 100644 index 0000000..f71d75b --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/LegacySettingsSnapshot.cs @@ -0,0 +1,78 @@ +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Text.Json; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Owns one immutable, allowlisted legacy preference snapshot without internal credentials. +public sealed class LegacySettingsSnapshot +{ + private const int MaximumStringLength = 1024 * 1024; + private readonly IReadOnlyDictionary _values; + + /// Copies only registered preference keys and read-only aliases before computing their identity. + /// Canonical preference schema; unregistered keys are never copied or hashed. + /// Atomic legacy read whose primitive values are snapshotted by this constructor. + public LegacySettingsSnapshot(SettingsRegistry registry, IReadOnlyDictionary values) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(values); + Dictionary snapshot = new(StringComparer.Ordinal); + foreach (string key in registry.Definitions.SelectMany( + definition => new[] { definition.Key.Value }.Concat(definition.Aliases.Select(alias => alias.Value)))) + { + if (!values.Keys.Contains(key, StringComparer.Ordinal) || !values.TryGetValue(key, out object? value)) + { + continue; + } + + if (value is string text && text.Length > MaximumStringLength) + { + throw new ArgumentException("A legacy preference exceeds the snapshot size limit.", nameof(values)); + } + + // LocalSettings preferences use only these immutable primitives. Unknown + // types become an invalid-value marker without invoking arbitrary ToString. + snapshot.Add(key, value is bool or int or string ? value : null); + } + + _values = new ReadOnlyDictionary(snapshot); + SourceHash = ComputeHash(snapshot); + } + + /// Gets the SHA-256 identity of the allowlisted primitive snapshot. + public string SourceHash { get; } + + /// Gets a copied raw preference; null denotes a present but unsupported legacy value. + /// Canonical or registered legacy key. + /// Copied immutable primitive or the invalid-value marker. + /// Whether the allowlisted source contained this exact key. + public bool TryGetValue(string key, out object? value) => _values.TryGetValue(key, out value); + + private static string ComputeHash(IReadOnlyDictionary values) + { + using MemoryStream bytes = new(); + using (Utf8JsonWriter writer = new(bytes)) + { + writer.WriteStartObject(); + writer.WriteString("schema", "clashsharp-legacy-preferences-v1"); + writer.WriteStartObject("values"); + foreach ((string key, object? value) in values.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + switch (value) + { + case bool boolean: writer.WriteBoolean(key, boolean); break; + case int integer: writer.WriteNumber(key, integer); break; + case string text: writer.WriteString(key, text); break; + default: writer.WriteNull(key); break; + } + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + return Convert.ToHexStringLower(SHA256.HashData(bytes.GetBuffer().AsSpan(0, checked((int)bytes.Length)))); + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsApplicationRequest.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsApplicationRequest.cs new file mode 100644 index 0000000..f6e66ae --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsApplicationRequest.cs @@ -0,0 +1,76 @@ +using System.Collections.ObjectModel; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Specifies whether application occurs during an exclusively owned startup or a live command. +public enum SettingsApplicationPhase +{ + /// Live application cannot consume restart-bound batches. + Live, + /// Startup application requires an active exclusive mutation lease. + Startup, +} + +/// Captures the exact generation, attempt, and immutable desired values assigned to a participant. +public sealed class SettingsApplicationRequest +{ + internal SettingsApplicationRequest( + DataGenerationDescriptor generation, SettingsEnvelope envelope, SettingsApplicationBatch batch, + SettingsApplicationPhase phase) + { + Generation = generation; + Envelope = envelope; + Batch = batch; + Phase = phase; + Values = new ReadOnlyDictionary( + batch.Entries.ToDictionary(entry => entry.Key, entry => envelope.Desired[entry.Key].Value)); + } + + /// Gets the pinned storage generation. + public DataGenerationDescriptor Generation { get; } + + /// Gets the durable running envelope, including companion desired settings. + public SettingsEnvelope Envelope { get; } + + /// Gets the exact current attempt. + public SettingsApplicationBatch Batch { get; } + + /// Gets the owned application phase. + public SettingsApplicationPhase Phase { get; } + + /// Gets only this batch's canonical desired values. + public IReadOnlyDictionary Values { get; } +} + +/// Contains independently probed values bound to one exact generation and application attempt. +public sealed class SettingsApplicationObservation +{ + /// Copies a completed participant probe without deriving observed values from the request. + /// Generation under which the participant was observed. + /// Observed batch identity. + /// Observed attempt identity. + /// Independent effective values; the authority validates complete canonical coverage. + public SettingsApplicationObservation( + DataGenerationDescriptor generation, Guid batchId, Guid attemptId, IEnumerable values) + { + Generation = generation ?? throw new ArgumentNullException(nameof(generation)); + ArgumentNullException.ThrowIfNull(values); + BatchId = batchId; + AttemptId = attemptId; + Values = Array.AsReadOnly(values.ToArray()); + } + + /// Gets the generation actually observed. + public DataGenerationDescriptor Generation { get; } + + /// Gets the batch identity of the observation. + public Guid BatchId { get; } + + /// Gets the current attempt identity of the observation. + public Guid AttemptId { get; } + + /// Gets the defensively copied effective values. + public IReadOnlyList Values { get; } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityBootstrapper.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityBootstrapper.cs new file mode 100644 index 0000000..3e95281 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityBootstrapper.cs @@ -0,0 +1,73 @@ +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Opens a pinned settings authority or initializes a demonstrably unused repository from legacy preferences. +/// The caller retains exclusive startup or generation-transition ownership for the complete operation. +public sealed class SettingsAuthorityBootstrapper +{ + private readonly ISettingsRepository _repository; + private readonly ILegacySettingsSource _legacy; + private readonly SettingsMigrationPlanner _migration; + private readonly SemaphoreSlim _initializationGate = new(1, 1); + + /// Creates the initializer without reading either storage system. + /// Repository pinned to the generation being opened. + /// Read-only source consulted only for an unused repository. + /// Canonical legacy normalization and initial pending-state planner. + public SettingsAuthorityBootstrapper( + ISettingsRepository repository, + ILegacySettingsSource legacy, + SettingsMigrationPlanner migration) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _legacy = legacy ?? throw new ArgumentNullException(nameof(legacy)); + _migration = migration ?? throw new ArgumentNullException(nameof(migration)); + } + + /// Returns a verified nonempty authority or a persistence failure without silently replacing existing data. + /// Stable identity for a possible first migration. + /// Cancels observation and repository work before its durable commit point. + public async Task OpenAsync(Guid migrationId, CancellationToken cancellationToken) + { + if (migrationId == Guid.Empty) + { + throw new ArgumentException("Migration identity cannot be empty.", nameof(migrationId)); + } + + await _initializationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + SettingsPersistenceResult existing = await _repository.OpenAsync(cancellationToken).ConfigureAwait(false); + if (!existing.IsSucceeded || existing.Envelope is not null) + { + return existing; + } + + cancellationToken.ThrowIfCancellationRequested(); + LegacySettingsSnapshot snapshot = await _legacy.ReadSnapshotAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + SettingsMigrationPlan plan = _migration.CreatePlan(snapshot, migrationId); + SettingsPersistenceResult saved = await _repository.SaveAsync( + plan.Envelope, expectedRevision: 0, cancellationToken).ConfigureAwait(false); + if (saved.Status == SettingsPersistenceStatus.Conflict && saved.Envelope is not null) + { + // Another initializer won the atomic first publication. Its verified + // authority wins; never repeat migration over the observed revision. + return SettingsPersistenceResult.Succeeded(saved.Envelope); + } + + if (saved.IsSucceeded && saved.Envelope is null) + { + return SettingsPersistenceResult.Invalid(new SettingsPersistenceDiagnostic( + "settings.bootstrap.empty_commit", "envelope")); + } + + return saved; + } + finally + { + _initializationGate.Release(); + } + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityResult.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityResult.cs new file mode 100644 index 0000000..fcc0aef --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityResult.cs @@ -0,0 +1,49 @@ +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Classifies a settings command independently of whether desired values are durable. +public enum SettingsAuthorityStatus +{ + /// The requested command completed and its resulting envelope was verified. + Succeeded, + /// The requested edit already matched the verified envelope. + NoChange, + /// The command was stale, invalid, or incompatible with the current application state. + Rejected, + /// Storage did not verify the requested commit. + PersistenceFailed, + /// The participant did not verify the target; failed work remains durable. + ApplicationFailed, + /// The batch requires startup ownership and was left unchanged. + DeferredToRestart, +} + +/// Contains the verified outcome of a command without equating persistence with runtime application. +public sealed class SettingsAuthorityResult +{ + internal SettingsAuthorityResult( + SettingsAuthorityStatus status, SettingsEnvelope? envelope, string? code = null, + SettingsPersistenceStatus? persistenceStatus = null) + { + Status = status; + Envelope = envelope; + Code = code; + PersistenceStatus = persistenceStatus; + } + + /// Gets the command outcome. + public SettingsAuthorityStatus Status { get; } + + /// Gets the last verified envelope, when one was observed by this command. + public SettingsEnvelope? Envelope { get; } + + /// Gets a stable value-free diagnostic. + public string? Code { get; } + + /// Gets the storage classification when persistence failed. + public SettingsPersistenceStatus? PersistenceStatus { get; } + + /// Gets whether the requested command completed successfully. + public bool IsSucceeded => Status is SettingsAuthorityStatus.Succeeded or SettingsAuthorityStatus.NoChange; +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs new file mode 100644 index 0000000..39ef0c7 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs @@ -0,0 +1,139 @@ +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +public sealed partial class SettingsAuthoritySession +{ + /// Persists running intent, probes before applying, and clears work only after complete independent verification. + /// Exact batch selected from the verified envelope. + /// Expected current attempt identity. + /// Admitted runtime implementation for this batch's application kind. + /// Live application or startup under exclusive ownership. + /// Active lease retained through observation, effects, and final durable classification. + /// Cancels waiting and work before running intent is durably acknowledged. + public Task ApplyBatchAdmittedAsync( + Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(participant); + if (!Enum.IsDefined(phase)) { throw new ArgumentOutOfRangeException(nameof(phase)); } + if (phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(admissionLease); } + return ExecuteAdmittedAsync(admissionLease, + waiting => ApplyCoreAsync(batchId, attemptId, participant, phase, admissionLease, waiting), cancellationToken); + } + + private async Task ApplyCoreAsync( + Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase, + MutationAdmissionLease lease, CancellationToken waiting) + { + SettingsAuthorityResult read = await ReadCoreAsync(waiting).ConfigureAwait(false); + if (!read.IsSucceeded) { return read; } + SettingsEnvelope envelope = read.Envelope!; + SettingsApplicationBatch? batch = envelope.PendingApplications.SingleOrDefault(item => item.BatchId == batchId); + if (batch is null || batch.AttemptId != attemptId) + { + return new(SettingsAuthorityStatus.Rejected, envelope, "settings.application.stale_attempt"); + } + + if (batch.ApplicationKind != participant.ApplicationKind) + { + return new(SettingsAuthorityStatus.Rejected, envelope, "settings.application.participant_mismatch"); + } + + if (batch.Kind == SettingsApplicationBatchKind.Restart && phase != SettingsApplicationPhase.Startup) + { + return new(SettingsAuthorityStatus.DeferredToRestart, envelope, "settings.application.restart_required"); + } + + waiting.ThrowIfCancellationRequested(); + SettingsAuthorityResult started = await PersistEditAsync(envelope, + _batches.BeginAttempt(envelope, batchId, attemptId), waiting).ConfigureAwait(false); + if (!started.IsSucceeded) { return started; } + envelope = started.Envelope!; + batch = envelope.PendingApplications.Single(item => item.BatchId == batchId); + SettingsApplicationRequest request = new(Generation, envelope, batch, phase); + + // Running intent is durable. Ignore page cancellation until the participant and + // the final save finish; exclusive generation changes still wait for this lease. + SettingsApplicationObservation? observation = await TryProbeAsync(participant, request, lease).ConfigureAwait(false); + if (!ValidateObservation(request, observation, out bool matches)) + { + return await FailCoreAsync(envelope, batch, "settings.application.probe_failed").ConfigureAwait(false); + } + + bool applied = false; + bool replyFailed = false; + if (!matches) + { + try + { + await participant.ApplyAsync(request, lease, CancellationToken.None).ConfigureAwait(false); + applied = true; + } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) + { + replyFailed = true; + // A participant may have completed its effect before losing the reply. + // Resolve that ambiguity by probing; never infer rollback from an exception. + } + + observation = await TryProbeAsync(participant, request, lease).ConfigureAwait(false); + if (!ValidateObservation(request, observation, out matches) || !matches) + { + return await FailCoreAsync(envelope, batch, replyFailed + ? "settings.application.participant_failed" : "settings.application.verification_failed").ConfigureAwait(false); + } + } + + SettingAppliedValueSource source = phase == SettingsApplicationPhase.Startup + ? SettingAppliedValueSource.StartupReconciliation + : applied ? SettingAppliedValueSource.MutationVerification : SettingAppliedValueSource.RuntimeProbe; + SettingsEnvelopeEditResult complete = _batches.CompleteAttempt(envelope, batchId, attemptId, + observation!.Values, source, _time.GetUtcNow()); + SettingsAuthorityResult completed = await PersistEditAsync(envelope, complete, CancellationToken.None).ConfigureAwait(false); + return completed.IsSucceeded && replyFailed + ? new(completed.Status, completed.Envelope, "settings.application.reply_lost_resolved") : completed; + } + + private async Task FailCoreAsync(SettingsEnvelope source, SettingsApplicationBatch batch, string code) + { + SettingsAuthorityResult failed = await PersistEditAsync(source, + _batches.FailAttempt(source, batch.BatchId, batch.AttemptId, new(code)), CancellationToken.None).ConfigureAwait(false); + return failed.IsSucceeded ? new(SettingsAuthorityStatus.ApplicationFailed, failed.Envelope, code) : failed; + } + + private static async Task TryProbeAsync( + ISettingsApplicationParticipant participant, SettingsApplicationRequest request, MutationAdmissionLease lease) + { + try + { + return await participant.ProbeAsync(request, lease, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) + { + return null; + } + } + + private bool ValidateObservation(SettingsApplicationRequest request, SettingsApplicationObservation? observation, out bool matches) + { + matches = false; + if (observation is null || !Generation.IsSameGeneration(observation.Generation) + || observation.BatchId != request.Batch.BatchId || observation.AttemptId != request.Batch.AttemptId + || observation.Values.Count != request.Values.Count) { return false; } + HashSet seen = []; + bool allMatch = true; + foreach (SettingValueChange value in observation.Values) + { + if (value is null || !seen.Add(value.Key) || !request.Values.TryGetValue(value.Key, out SettingValue? desired)) { return false; } + SettingNormalizationResult normalized = _registry.Get(value.Key.Value).Normalize(value.Value.CanonicalText); + if (!normalized.IsSuccess || !value.Value.Equals(normalized.Value)) { return false; } + allMatch &= value.Value.Equals(desired); + } + + matches = allMatch; + return true; + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Lifetime.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Lifetime.cs new file mode 100644 index 0000000..607aea5 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Lifetime.cs @@ -0,0 +1,36 @@ +namespace ClashSharp.ApplicationModel.Settings; + +public sealed partial class SettingsAuthoritySession +{ + private readonly object _lifetimeLock = new(); + private Task? _disposal; + private bool _closing; + + /// Rejects later commands and drains any started effect and final save before releasing this generation's projection. + public ValueTask DisposeAsync() + { + lock (_lifetimeLock) + { + Volatile.Write(ref _closing, true); + _disposal ??= DrainAsync(); + return new(_disposal); + } + } + + private async Task DrainAsync() + { + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + Volatile.Write(ref _snapshot, null); + } + finally + { + // Queued callers still need to acquire and reject themselves. The managed + // semaphore stays valid until their continuations have observed closure. + _operationGate.Release(); + } + } + + private void ThrowIfClosing() => ObjectDisposedException.ThrowIf(Volatile.Read(ref _closing), this); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs new file mode 100644 index 0000000..cb314e7 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs @@ -0,0 +1,192 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Serializes asynchronous edits and verified application within one pinned repository lifetime. +/// The generation owner keeps this session and its repository pinned for each complete call, including participant recovery. +public sealed partial class SettingsAuthoritySession : IAsyncDisposable +{ + private readonly ISettingsRepository _repository; + private readonly SettingsRegistry _registry; + private readonly MutationAdmissionBarrier _admission; + private readonly SettingsEnvelopeEditor _editor; + private readonly SettingsApplicationBatchEditor _batches; + private readonly SettingsEnvelopeValidator _validator; + private readonly TimeProvider _time; + private readonly SemaphoreSlim _operationGate = new(1, 1); + private SettingsEnvelope? _snapshot; + + /// Creates one session without opening storage, starting tasks, or publishing fallback preferences. + /// Repository owned by the immutable generation lifetime. + /// Canonical settings definitions. + /// Process-wide mutation admission shared with import and shutdown. + /// Optional clock for completed observation timestamps. + public SettingsAuthoritySession( + ISettingsRepository repository, SettingsRegistry registry, MutationAdmissionBarrier admission, + TimeProvider? timeProvider = null) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + _editor = new(registry); + _batches = new(registry); + _validator = new(registry); + _time = timeProvider ?? TimeProvider.System; + } + + /// Gets the immutable storage owner of this session. + public DataGenerationDescriptor Generation => _repository.Generation; + + /// Gets the last verified immutable envelope; uncertain commits invalidate this projection until storage is reread. + public SettingsEnvelope Snapshot + { + get + { + ThrowIfClosing(); + return Volatile.Read(ref _snapshot) + ?? throw new InvalidOperationException("Settings authority has not been verified for this generation."); + } + } + + /// Opens an already initialized authority; migration remains owned by the exclusive startup bootstrapper. + /// Active caller-owned lease. + /// Cancels queued or read-only work. + public Task OpenAdmittedAsync(MutationAdmissionLease admissionLease, CancellationToken cancellationToken) => + ExecuteAdmittedAsync(admissionLease, ReadCoreAsync, cancellationToken); + + /// Replaces previous-process evidence with pending observation while preserving blocked probes and existing attempt identities. + /// Fresh identity of the exclusively owned startup. + /// Exclusive startup lease held until reconciliation finishes. + /// Cancels before the repository's atomic publication point. + public Task PrepareStartupAdmittedAsync( + Guid startupId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + _admission.EnsureActiveExclusiveLease(admissionLease); + return EditAdmittedAsync(envelope => _batches.ScheduleStartupReconciliation(envelope, startupId), admissionLease, cancellationToken); + } + + /// Commits a complete canonical change set using the current durable revision. + /// Desired changes, copied before asynchronous admission waiting. + /// Stable identity for newly planned application batches. + /// Active caller-owned lease. + /// Cancels only before the repository's atomic publication point. + public Task ChangeAdmittedAsync( + IEnumerable changes, Guid transactionId, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(changes); + SettingValueChange[] snapshot = changes.ToArray(); + return EditAdmittedAsync(envelope => _editor.ApplyChanges(envelope, snapshot, transactionId), admissionLease, cancellationToken); + } + + /// Reverts selected desired preferences to verified evidence or the registry's explicit fallback while retaining necessary application work. + /// Canonical keys copied before asynchronous waiting. + /// Stable edit identity. + /// Active caller-owned lease. + /// Cancels before atomic publication. + public Task RevertAdmittedAsync( + IEnumerable keys, Guid transactionId, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(keys); + SettingKey[] snapshot = keys.ToArray(); + return EditAdmittedAsync(envelope => _editor.Revert(envelope, snapshot, transactionId), admissionLease, cancellationToken); + } + + /// Explicitly retries failed work under a new attempt identity. + /// Exact batch. + /// Failed attempt being replaced. + /// New nonempty attempt identity. + /// Active caller-owned lease. + /// Cancels before atomic publication. + public Task RetryAdmittedAsync( + Guid batchId, Guid expectedAttemptId, Guid newAttemptId, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) => + EditAdmittedAsync(envelope => _batches.RetryFailed(envelope, batchId, expectedAttemptId, newAttemptId), admissionLease, cancellationToken); + + private Task EditAdmittedAsync( + Func edit, MutationAdmissionLease lease, CancellationToken token) => + ExecuteAdmittedAsync(lease, async waiting => + { + SettingsAuthorityResult read = await ReadCoreAsync(waiting).ConfigureAwait(false); + return !read.IsSucceeded ? read + : await PersistEditAsync(read.Envelope!, edit(read.Envelope!), waiting).ConfigureAwait(false); + }, token); + + private async Task ExecuteAdmittedAsync( + MutationAdmissionLease lease, Func> operation, + CancellationToken cancellationToken) + { + ThrowIfClosing(); + _admission.EnsureActiveLease(lease); + using CancellationTokenSource waiting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lease.RevocationToken); + await _operationGate.WaitAsync(waiting.Token).ConfigureAwait(false); + try + { + ThrowIfClosing(); + _admission.EnsureActiveLease(lease); + waiting.Token.ThrowIfCancellationRequested(); + return await operation(waiting.Token).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + private async Task ReadCoreAsync(CancellationToken cancellationToken) + { + try + { + SettingsPersistenceResult result = await _repository.OpenAsync(cancellationToken).ConfigureAwait(false); + return ObservePersistence(result); + } + catch + { + Volatile.Write(ref _snapshot, null); + throw; + } + } + + private async Task PersistEditAsync( + SettingsEnvelope source, SettingsEnvelopeEditResult edit, CancellationToken cancellationToken) + { + if (!edit.IsSuccess) { return new(SettingsAuthorityStatus.Rejected, source, edit.ErrorCode); } + if (edit.Outcome == SettingsEnvelopeEditOutcome.NoChange) { return new(SettingsAuthorityStatus.NoChange, source); } + try + { + SettingsPersistenceResult persisted = await _repository.SaveAsync(edit.Envelope, source.EnvelopeRevision, cancellationToken) + .ConfigureAwait(false); + return ObservePersistence(persisted); + } + catch + { + Volatile.Write(ref _snapshot, null); + throw; + } + } + + private SettingsAuthorityResult ObservePersistence(SettingsPersistenceResult result) + { + if (result.IsSucceeded && result.Envelope is null) + { + result = SettingsPersistenceResult.Invalid(new("settings.authority.uninitialized", "envelope")); + } + + SettingsEnvelope? observed = result.Status is SettingsPersistenceStatus.Succeeded or SettingsPersistenceStatus.Conflict + ? result.Envelope : null; + if (observed is not null && !_validator.Validate(observed).IsValid) + { + observed = null; + result = SettingsPersistenceResult.Invalid(new("settings.authority.invalid_observation", "envelope")); + } + + Volatile.Write(ref _snapshot, observed); + if (result.IsSucceeded && observed is not null) { return new(SettingsAuthorityStatus.Succeeded, observed); } + return new(SettingsAuthorityStatus.PersistenceFailed, observed, + result.Diagnostic?.Code ?? (result.Status == SettingsPersistenceStatus.Conflict + ? "settings.authority.conflict" : "settings.authority.uninitialized"), result.Status); + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsMigrationPlanner.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsMigrationPlanner.cs new file mode 100644 index 0000000..ed1cded --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsMigrationPlanner.cs @@ -0,0 +1,144 @@ +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Text; +using ClashSharp.Model; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Explains a migration decision using only an allowlisted key and stable code. +/// Canonical preference key. +/// Stable decision code; source values are never included. +public sealed record SettingsMigrationDiagnostic(SettingKey Key, string Code); + +/// Contains the complete initial envelope and value-free migration decisions. +/// Validated canonical envelope with pending application coverage. +/// Immutable key-addressed migration decisions. +public sealed record SettingsMigrationPlan(SettingsEnvelope Envelope, IReadOnlyList Diagnostics); + +/// Converts legacy preferences into a complete canonical authority without asserting runtime effects. +public sealed class SettingsMigrationPlanner +{ + private readonly SettingsRegistry _registry; + + /// Creates a pure planner for the canonical schema. + /// Immutable preference definitions and validation rules. + public SettingsMigrationPlanner(SettingsRegistry registry) => + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + + /// Creates one revision-one migration with deterministic application identities. + /// Immutable allowlisted source. + /// Stable caller-owned identity for this migration attempt. + public SettingsMigrationPlan CreatePlan(LegacySettingsSnapshot snapshot, Guid migrationId) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (migrationId == Guid.Empty) + { + throw new ArgumentException("Migration identity cannot be empty.", nameof(migrationId)); + } + + Dictionary desired = []; + Dictionary applied = []; + List diagnostics = []; + foreach (SettingDefinition definition in _registry.Definitions) + { + (SettingValue value, string code) = ReadValue(snapshot, definition); + desired.Add(definition.Key, new SettingDesiredEntry(value, keyDesiredRevision: 1)); + applied.Add(definition.Key, SettingAppliedState.Unknown( + SettingAppliedUnknownReason.NotObserved, SettingAppliedUnknownHandling.QueueApplication)); + diagnostics.Add(new(definition.Key, code)); + } + + List pending = []; + foreach (var group in _registry.Definitions + .GroupBy(definition => (definition.ApplicationKind, definition.ApplicationTiming)) + .OrderBy(group => group.Key.ApplicationTiming) + .ThenBy(group => group.Key.ApplicationKind)) + { + long sequence = pending.Count + 1; + pending.Add(new SettingsApplicationBatch( + CreateIdentity(migrationId, sequence, "batch"), + group.Key.ApplicationTiming == SettingApplicationTiming.Restart + ? SettingsApplicationBatchKind.Restart + : SettingsApplicationBatchKind.LiveReconcile, + sequence, + CreateIdentity(migrationId, sequence, "attempt"), + SettingsApplicationBatchState.Pending, + group.Key.ApplicationKind, + group.Select(definition => SettingsApplicationBatchEntry.Create(definition.Key, desired[definition.Key])))); + } + + SettingsEnvelope envelope = new(SettingsEnvelope.CurrentSchemaVersion, 1, desired, applied, pending, + [new SettingsMigrationRecord(migrationId, 0, SettingsEnvelope.CurrentSchemaVersion, snapshot.SourceHash)]); + SettingsEnvelopeValidationResult validation = new SettingsEnvelopeValidator(_registry).Validate(envelope); + if (!validation.IsValid) + { + throw new InvalidOperationException("The generated migration envelope violates the canonical schema."); + } + + return new(envelope, new ReadOnlyCollection(diagnostics)); + } + + private static (SettingValue Value, string Code) ReadValue(LegacySettingsSnapshot source, SettingDefinition definition) + { + string key = definition.Key.Value; + bool present = source.TryGetValue(key, out object? raw); + string code = "settings.migration.canonical"; + if (!present) + { + foreach (SettingKey aliasKey in definition.Aliases) + { + if (source.TryGetValue(aliasKey.Value, out raw)) + { + present = true; + code = "settings.migration.alias"; + break; + } + } + } + + if (key == SettingsRegistry.Keys.MainlandChinaFeatureMode.Value) + { + if (raw is int legacyMode && legacyMode == (int)MainlandChinaFeatureMode.AllIncludingUrlBlacklist) + { + raw = (int)MainlandChinaFeatureMode.FlagTextCompletionAndKeywordFilter; + code = "settings.migration.legacy_mode_split"; + } + else if ((!present || raw is not int || !Enum.IsDefined(typeof(MainlandChinaFeatureMode), raw)) + && source.TryGetValue(SettingsRegistry.Keys.MainlandChinaDisplayEnabled.Value, out object? alias) + && alias is bool display) + { + raw = (int)(display ? MainlandChinaFeatureMode.FlagReplacementAndTextCompletion : MainlandChinaFeatureMode.Disabled); + present = true; + code = "settings.migration.alias"; + } + } + else if (key == SettingsRegistry.Keys.MainlandChinaUrlBlockingEnabled.Value + && source.TryGetValue(SettingsRegistry.Keys.MainlandChinaFeatureMode.Value, out object? mode) + && mode is int oldMode && oldMode == (int)MainlandChinaFeatureMode.AllIncludingUrlBlacklist) + { + raw = true; + present = true; + code = "settings.migration.legacy_mode_split"; + } + + if (!present) + { + return (definition.DefaultValue, "settings.migration.default"); + } + + SettingNormalizationResult normalized = definition.ValueType.IsEnum && raw is int enumValue + ? definition.NormalizeValue(Enum.ToObject(definition.ValueType, enumValue)) + : definition.NormalizeValue(raw); + return normalized.IsSuccess + ? (normalized.Value!, code) + : (definition.SafeFallback, "settings.migration.invalid_fallback"); + } + + private static Guid CreateIdentity(Guid migrationId, long sequence, string purpose) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes( + FormattableString.Invariant($"clashsharp-settings-migration-v1/{migrationId:N}/{sequence}/{purpose}"))); + return new Guid(hash.AsSpan(0, 16)); + } +} diff --git a/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.Reconciliation.cs b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.Reconciliation.cs new file mode 100644 index 0000000..d39cda6 --- /dev/null +++ b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.Reconciliation.cs @@ -0,0 +1,56 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ClashSharp.Settings; + +public sealed partial class SettingsApplicationBatchEditor +{ + /// Invalidates evidence from a previous process and queues observation without removing blocked probes or changing desired values. + /// Verified persisted envelope being opened by a new process. + /// Fresh nonempty startup identity used for new application batches. + /// Existing running and failed attempts retain their identities and state; failed work still requires explicit retry. + public SettingsEnvelopeEditResult ScheduleStartupReconciliation(SettingsEnvelope envelope, Guid startupId) + { + ArgumentNullException.ThrowIfNull(envelope); + if (!_validator.Validate(envelope).IsValid) { return Invalid(envelope, "source_invalid"); } + if (startupId == Guid.Empty) { return Invalid(envelope, "startup_identity"); } + SettingKey[] staleEvidence = envelope.Applied.Where(pair => pair.Value.Kind == SettingAppliedStateKind.Verified) + .Select(pair => pair.Key).ToArray(); + if (staleEvidence.Length == 0) { return SettingsEnvelopeEditResult.NoChange(envelope); } + if (envelope.EnvelopeRevision == long.MaxValue) { return Invalid(envelope, "revision_exhausted"); } + + Dictionary applied = new(envelope.Applied); + HashSet alreadyCovered = [.. envelope.PendingApplications.SelectMany(batch => batch.Entries).Select(entry => entry.Key)]; + foreach (SettingKey key in staleEvidence) + { + applied[key] = SettingAppliedState.Unknown(SettingAppliedUnknownReason.NotObserved, SettingAppliedUnknownHandling.QueueApplication); + } + + List batches = [.. envelope.PendingApplications]; + long sequence = batches.Count == 0 ? 0 : batches.Max(batch => batch.CreationSequence); + foreach (var group in staleEvidence.Where(key => !alreadyCovered.Contains(key)) + .Select(key => _registry.Get(key.Value)) + .GroupBy(definition => (definition.ApplicationTiming, definition.ApplicationKind)) + .OrderBy(group => group.Key.ApplicationTiming).ThenBy(group => group.Key.ApplicationKind)) + { + if (sequence == long.MaxValue) { return Invalid(envelope, "sequence_exhausted"); } + ++sequence; + batches.Add(new( + CreateStartupIdentity(startupId, sequence, "batch"), + group.Key.ApplicationTiming == SettingApplicationTiming.Restart ? SettingsApplicationBatchKind.Restart : SettingsApplicationBatchKind.LiveReconcile, + sequence, CreateStartupIdentity(startupId, sequence, "attempt"), SettingsApplicationBatchState.Pending, + group.Key.ApplicationKind, group.Select(definition => SettingsApplicationBatchEntry.Create(definition.Key, envelope.Desired[definition.Key])))); + } + + SettingsEnvelope next = new(envelope.SchemaVersion, envelope.EnvelopeRevision + 1, envelope.Desired, applied, + batches.OrderBy(batch => batch, SettingsApplicationBatchComparer.Instance), envelope.MigrationHistory); + return _validator.Validate(next).IsValid ? SettingsEnvelopeEditResult.Updated(next) : Invalid(envelope, "result_invalid"); + } + + private static Guid CreateStartupIdentity(Guid startupId, long sequence, string purpose) + { + byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes( + FormattableString.Invariant($"clashsharp-settings-startup-v1/{startupId:N}/{sequence}/{purpose}"))); + return new(digest.AsSpan(0, 16)); + } +} diff --git a/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.cs b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.cs new file mode 100644 index 0000000..bf027cc --- /dev/null +++ b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.cs @@ -0,0 +1,156 @@ +namespace ClashSharp.Settings; + +/// Advances exact application attempts without changing desired values or unrelated batch identities. +public sealed partial class SettingsApplicationBatchEditor +{ + private readonly SettingsRegistry _registry; + private readonly SettingsEnvelopeValidator _validator; + + /// Creates a pure lifecycle editor using the canonical schema. + /// Immutable setting definitions. + public SettingsApplicationBatchEditor(SettingsRegistry registry) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _validator = new(registry); + } + + /// Marks a pending attempt as possibly side-effecting before the participant is invoked. + /// Validated source envelope. + /// Exact batch to begin. + /// Expected current attempt; stale callbacks cannot begin a replacement attempt. + public SettingsEnvelopeEditResult BeginAttempt(SettingsEnvelope envelope, Guid batchId, Guid attemptId) + { + SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, attemptId, out SettingsApplicationBatch? batch); + if (invalid is not null) { return invalid; } + if (batch!.State == SettingsApplicationBatchState.Running) { return SettingsEnvelopeEditResult.NoChange(envelope); } + if (batch.State != SettingsApplicationBatchState.Pending) { return Invalid(envelope, "state"); } + + return Rewrite(envelope, ReplaceState(batch, SettingsApplicationBatchState.Running), + InvalidateEvidence(envelope, batch, SettingAppliedUnknownReason.NotObserved)); + } + + /// Retains failed work with unknown effective state; an attempted effect is never reported as rolled back without evidence. + /// Validated source envelope. + /// Exact running batch. + /// Expected current attempt. + /// Stable diagnostic chosen by the participant boundary. + public SettingsEnvelopeEditResult FailAttempt(SettingsEnvelope envelope, Guid batchId, Guid attemptId, SettingsApplicationError error) + { + ArgumentNullException.ThrowIfNull(error); + SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, attemptId, out SettingsApplicationBatch? batch); + if (invalid is not null) { return invalid; } + if (batch!.State != SettingsApplicationBatchState.Running) { return Invalid(envelope, "state"); } + + return Rewrite(envelope, ReplaceState(batch, SettingsApplicationBatchState.Failed, error), + InvalidateEvidence(envelope, batch, SettingAppliedUnknownReason.ProbeFailed)); + } + + /// Removes one running batch only when every independently observed value matches its exact desired revision and hash. + /// Validated source envelope. + /// Exact running batch. + /// Expected current attempt. + /// Complete independently obtained canonical values; duplicate or unrelated keys are rejected. + /// Runtime probe, mutation verification, or startup reconciliation evidence. + /// Nondefault UTC time of observation. + public SettingsEnvelopeEditResult CompleteAttempt( + SettingsEnvelope envelope, + Guid batchId, + Guid attemptId, + IEnumerable observedValues, + SettingAppliedValueSource source, + DateTimeOffset observedAt) + { + ArgumentNullException.ThrowIfNull(observedValues); + SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, attemptId, out SettingsApplicationBatch? batch); + if (invalid is not null) { return invalid; } + if (batch!.State != SettingsApplicationBatchState.Running) { return Invalid(envelope, "state"); } + if (source is not (SettingAppliedValueSource.RuntimeProbe or SettingAppliedValueSource.MutationVerification + or SettingAppliedValueSource.StartupReconciliation)) { return Invalid(envelope, "observation_source"); } + if (observedAt == default || observedAt.Offset != TimeSpan.Zero) { return Invalid(envelope, "observation_time"); } + + Dictionary observed = []; + foreach (SettingValueChange change in observedValues) + { + if (change is null || !observed.TryAdd(change.Key, change.Value)) { return Invalid(envelope, "observation_keys"); } + } + + if (observed.Count != batch.Entries.Count) { return Invalid(envelope, "observation_keys"); } + Dictionary applied = new(envelope.Applied); + foreach (SettingsApplicationBatchEntry entry in batch.Entries) + { + if (!observed.TryGetValue(entry.Key, out SettingValue? value)) { return Invalid(envelope, "observation_keys"); } + SettingDefinition definition = _registry.Get(entry.Key.Value); + SettingNormalizationResult normalized = definition.Normalize(value.CanonicalText); + if (!normalized.IsSuccess || !value.Equals(normalized.Value) + || !value.Equals(envelope.Desired[entry.Key].Value) + || SettingsApplicationBatchEntry.ComputeValueHash(value) != entry.ValueHash) + { + return Invalid(envelope, "observation_mismatch"); + } + + applied[entry.Key] = SettingAppliedState.Verified(value, source, entry.ValueHash, observedAt); + } + + return Rewrite(envelope, replacement: null, applied, batchId); + } + + /// Authorizes a failed batch to retry under a new attempt identity while retaining key revisions. + /// Validated source envelope. + /// Failed batch to retry. + /// Identity of the failed attempt being retried. + /// Fresh nonempty identity that invalidates previous attempt callbacks. + public SettingsEnvelopeEditResult RetryFailed(SettingsEnvelope envelope, Guid batchId, Guid expectedAttemptId, Guid newAttemptId) + { + SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, expectedAttemptId, out SettingsApplicationBatch? batch); + if (invalid is not null) { return invalid; } + if (batch!.State != SettingsApplicationBatchState.Failed) { return Invalid(envelope, "state"); } + if (newAttemptId == Guid.Empty || envelope.PendingApplications.Any(item => item.AttemptId == newAttemptId)) + { + return Invalid(envelope, "retry_identity"); + } + + SettingsApplicationBatch next = new(batch.BatchId, batch.Kind, batch.CreationSequence, newAttemptId, + SettingsApplicationBatchState.Pending, batch.ApplicationKind, batch.Entries); + return Rewrite(envelope, next, envelope.Applied); + } + + private SettingsEnvelopeEditResult? FindAttempt( + SettingsEnvelope envelope, Guid batchId, Guid attemptId, out SettingsApplicationBatch? batch) + { + ArgumentNullException.ThrowIfNull(envelope); + batch = null; + if (!_validator.Validate(envelope).IsValid) { return Invalid(envelope, "source_invalid"); } + batch = envelope.PendingApplications.SingleOrDefault(item => item.BatchId == batchId); + return batch is null || batch.AttemptId != attemptId ? Invalid(envelope, "stale_attempt") : null; + } + + private static SettingsApplicationBatch ReplaceState( + SettingsApplicationBatch batch, SettingsApplicationBatchState state, SettingsApplicationError? error = null) => + new(batch.BatchId, batch.Kind, batch.CreationSequence, batch.AttemptId, state, batch.ApplicationKind, batch.Entries, error); + + private static Dictionary InvalidateEvidence( + SettingsEnvelope envelope, SettingsApplicationBatch batch, SettingAppliedUnknownReason reason) + { + Dictionary result = new(envelope.Applied); + foreach (SettingsApplicationBatchEntry entry in batch.Entries) + { + result[entry.Key] = SettingAppliedState.Unknown(reason, SettingAppliedUnknownHandling.QueueApplication); + } + + return result; + } + + private SettingsEnvelopeEditResult Rewrite( + SettingsEnvelope source, SettingsApplicationBatch? replacement, + IEnumerable> applied, Guid? removedBatchId = null) + { + if (source.EnvelopeRevision == long.MaxValue) { return Invalid(source, "revision_exhausted"); } + SettingsEnvelope next = new(source.SchemaVersion, source.EnvelopeRevision + 1, source.Desired, applied, + source.PendingApplications.Where(batch => batch.BatchId != removedBatchId) + .Select(batch => batch.BatchId == replacement?.BatchId ? replacement! : batch), source.MigrationHistory); + return _validator.Validate(next).IsValid ? SettingsEnvelopeEditResult.Updated(next) : Invalid(source, "result_invalid"); + } + + private static SettingsEnvelopeEditResult Invalid(SettingsEnvelope source, string code) => + SettingsEnvelopeEditResult.Invalid(source, "settings.application." + code); +} diff --git a/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs b/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs index 5a97d9f..e31c09c 100644 --- a/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs +++ b/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs @@ -43,6 +43,14 @@ await RestorePrimaryAsync(backup.Bytes!, cancellationToken) if (primary.Kind == SettingsFileReadKind.Missing && backup.Kind == SettingsFileReadKind.Missing) { + if (HasQuarantinedEnvelope()) + { + return SettingsPersistenceResult.Corrupt( + new SettingsPersistenceDiagnostic( + "settings.persistence.quarantined_without_valid_envelope", + SettingsDirectoryPath)); + } + return SettingsPersistenceResult.Succeeded(); } @@ -173,6 +181,26 @@ private void Quarantine(string path) } } + private bool HasQuarantinedEnvelope() + { + // Quarantine survives process restart. Its absence from the primary names + // must not authorize a new migration over a previously corrupt authority. + foreach (string path in Directory.EnumerateFileSystemEntries(SettingsDirectoryPath)) + { + string name = Path.GetFileName(path); + foreach (string prefix in new[] { PrimaryFileName + ".corrupt.", BackupFileName + ".corrupt." }) + { + if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && Guid.TryParseExact(name.AsSpan(prefix.Length), "N", out _)) + { + return true; + } + } + } + + return false; + } + private enum SettingsFileReadKind { Missing, diff --git a/ClashSharp/ClashSharp.Infrastructure/Settings/WindowsLegacySettingsSource.cs b/ClashSharp/ClashSharp.Infrastructure/Settings/WindowsLegacySettingsSource.cs new file mode 100644 index 0000000..61cfc00 --- /dev/null +++ b/ClashSharp/ClashSharp.Infrastructure/Settings/WindowsLegacySettingsSource.cs @@ -0,0 +1,34 @@ +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Settings; +using Windows.Storage; + +namespace ClashSharp.Infrastructure.Settings; + +/// Reads only registered legacy preferences during exclusively owned packaged-app startup. +/// This adapter never writes LocalSettings and never requests the internal controller credential. +public sealed class WindowsLegacySettingsSource : ILegacySettingsSource +{ + private readonly SettingsRegistry _registry; + + /// Creates a read-only adapter without accessing Windows storage. + /// Canonical allowlist and legacy aliases. + public WindowsLegacySettingsSource(SettingsRegistry registry) => + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + + /// + public Task ReadSnapshotAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var values = ApplicationData.Current.LocalSettings.Values; + Dictionary snapshot = new(StringComparer.Ordinal); + foreach (string key in _registry.Definitions.SelectMany( + definition => new[] { definition.Key.Value }.Concat(definition.Aliases.Select(alias => alias.Value)))) + { + cancellationToken.ThrowIfCancellationRequested(); + if (values.TryGetValue(key, out object? value)) { snapshot.Add(key, value); } + } + + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new LegacySettingsSnapshot(_registry, snapshot)); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Integration/JsonSettingsRepositoryTests.cs b/ClashSharp/ClashSharp.Tests/Integration/JsonSettingsRepositoryTests.cs index 1ba5270..c378e24 100644 --- a/ClashSharp/ClashSharp.Tests/Integration/JsonSettingsRepositoryTests.cs +++ b/ClashSharp/ClashSharp.Tests/Integration/JsonSettingsRepositoryTests.cs @@ -312,6 +312,31 @@ public async Task OpenAsync_CorruptPrimaryAndBackup_ReturnsCorrupt() Assert.False(File.Exists(repository.BackupPath)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task QuarantinedRepository_ReopeningCannotMasqueradeAsFirstInitialization(bool save) + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationDescriptor generation = directory.CreateGeneration(1); + JsonSettingsRepository setup = CreateRepository(generation); + setup.EnsureLayout(); + await File.WriteAllTextAsync(setup.PrimaryPath, "{broken-primary"); + await File.WriteAllTextAsync(setup.BackupPath, "{broken-backup"); + Assert.Equal(SettingsPersistenceStatus.Corrupt, (await setup.OpenAsync(CancellationToken.None)).Status); + JsonSettingsRepository reopened = CreateRepository(generation); + + SettingsPersistenceResult result = save + ? await reopened.SaveAsync(SettingsEnvelopeTestData.CreateMatchingEnvelope(), 0, CancellationToken.None) + : await reopened.OpenAsync(CancellationToken.None); + + Assert.Equal(SettingsPersistenceStatus.Corrupt, result.Status); + Assert.Null(result.Envelope); + Assert.False(File.Exists(reopened.PrimaryPath)); + Assert.False(File.Exists(reopened.BackupPath)); + Assert.Equal(2, Directory.GetFiles(reopened.SettingsDirectoryPath, "*.corrupt.*").Length); + } + /// Verifies open removes abandoned same-directory candidates without touching other files. [Fact] public async Task OpenAsync_OrphanCandidates_CleansOnlyKnownCandidatePrefix() diff --git a/ClashSharp/ClashSharp.Tests/Integration/SettingsAuthorityBootstrapperTests.cs b/ClashSharp/ClashSharp.Tests/Integration/SettingsAuthorityBootstrapperTests.cs new file mode 100644 index 0000000..dc1b9de --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/SettingsAuthorityBootstrapperTests.cs @@ -0,0 +1,175 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Settings; + +namespace ClashSharp.Tests.Integration; + +/// Exercises initial migration and restart decisions against the actual generation-pinned JSON repository. +public sealed class SettingsAuthorityBootstrapperTests +{ + [Fact] + public async Task ExistingAuthority_IsReusedWithoutReadingChangedLegacySettings() + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationDescriptor generation = directory.CreateGeneration(1); + Source source = new() { Port = 7890 }; + SettingsAuthorityBootstrapper bootstrap = Create(generation, source); + Assert.Equal(0, source.Reads); + SettingsPersistenceResult first = await bootstrap.OpenAsync(Guid.NewGuid(), CancellationToken.None); + Source unavailableLegacy = new() { Failure = new IOException("legacy unavailable") }; + + SettingsPersistenceResult reopened = await Create(generation, unavailableLegacy).OpenAsync(Guid.NewGuid(), CancellationToken.None); + + Assert.True(first.IsSucceeded, first.Diagnostic?.Code); + Assert.True(reopened.IsSucceeded, reopened.Diagnostic?.Code); + Assert.Equal(1, source.Reads); + Assert.Equal(0, unavailableLegacy.Reads); + Assert.Equal(Hash(first), Hash(reopened)); + Assert.Equal(7890, reopened.Envelope!.Desired[SettingsRegistry.Keys.MixedPort].Value.Get()); + } + + [Fact] + public async Task CorruptionAcrossRepeatedStartup_NeverAuthorizesLegacyReinitialization() + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationDescriptor generation = directory.CreateGeneration(1); + JsonSettingsRepository repository = new(generation, SettingsRegistry.Default); + repository.EnsureLayout(); + await File.WriteAllTextAsync(repository.PrimaryPath, "{corrupt"); + Source source = new(); + for (int attempt = 0; attempt < 3; ++attempt) + { + SettingsPersistenceResult result = await Create(generation, source).OpenAsync(Guid.NewGuid(), CancellationToken.None); + Assert.Equal(SettingsPersistenceStatus.Corrupt, result.Status); + Assert.Null(result.Envelope); + } + + Assert.Equal(0, source.Reads); + Assert.False(File.Exists(repository.PrimaryPath)); + } + + [Theory] + [InlineData(SettingsPersistenceFaultPoint.BeforeEnvelopePromotion)] + [InlineData(SettingsPersistenceFaultPoint.AfterEnvelopePromotion)] + public async Task InterruptedInitialPublication_ReopeningResolvesTheActualDurableDecision(SettingsPersistenceFaultPoint cut) + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationDescriptor generation = directory.CreateGeneration(1); + Source source = new() { Port = 7890 }; + Guid firstId = Guid.NewGuid(); + SettingsPersistenceResult interrupted = await Create(generation, source, new Fault(cut)) + .OpenAsync(firstId, CancellationToken.None); + Assert.Equal(SettingsPersistenceStatus.Unavailable, interrupted.Status); + Source nextSource = new() { Port = 10001 }; + Guid nextId = Guid.NewGuid(); + + SettingsPersistenceResult restarted = await Create(generation, nextSource).OpenAsync(nextId, CancellationToken.None); + + Assert.True(restarted.IsSucceeded, restarted.Diagnostic?.Code); + bool published = cut == SettingsPersistenceFaultPoint.AfterEnvelopePromotion; + Assert.Equal(published ? 0 : 1, nextSource.Reads); + Assert.Equal(published ? 7890 : 10001, restarted.Envelope!.Desired[SettingsRegistry.Keys.MixedPort].Value.Get()); + Assert.Equal(published ? firstId : nextId, Assert.Single(restarted.Envelope.MigrationHistory).MigrationId); + } + + [Theory] + [InlineData(SettingsPersistenceFaultPoint.BeforeEnvelopePromotion)] + [InlineData(SettingsPersistenceFaultPoint.AfterEnvelopePromotion)] + public async Task CallerCancellation_RespectsTheRepositoryPublicationBoundary(SettingsPersistenceFaultPoint cut) + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationDescriptor generation = directory.CreateGeneration(1); + using CancellationTokenSource cancellation = new(); + Source source = new(); + SettingsAuthorityBootstrapper bootstrap = Create(generation, source, new Fault(cut, cancellation)); + if (cut == SettingsPersistenceFaultPoint.BeforeEnvelopePromotion) + { + await Assert.ThrowsAnyAsync(() => bootstrap.OpenAsync(Guid.NewGuid(), cancellation.Token)); + } + else + { + Assert.True((await bootstrap.OpenAsync(Guid.NewGuid(), cancellation.Token)).IsSucceeded); + } + + Source next = new(); + Assert.True((await Create(generation, next).OpenAsync(Guid.NewGuid(), CancellationToken.None)).IsSucceeded); + Assert.Equal(cut == SettingsPersistenceFaultPoint.BeforeEnvelopePromotion ? 1 : 0, next.Reads); + } + + [Fact] + public async Task TwoInitializers_ReturnTheSameWinningAuthorityWithoutOverwritingIt() + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationDescriptor generation = directory.CreateGeneration(1); + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + TaskCompletionSource bothRead = new(TaskCreationOptions.RunContinuationsAsynchronously); + int arrivals = 0; + async Task WaitForBoth(CancellationToken token) + { + if (Interlocked.Increment(ref arrivals) == 2) { bothRead.TrySetResult(); } + await bothRead.Task.WaitAsync(token); + } + + Source first = new() { Port = 7890, BeforeRead = WaitForBoth }; + Source second = new() { Port = 10001, BeforeRead = WaitForBoth }; + SettingsPersistenceResult[] results = await Task.WhenAll( + Create(generation, first).OpenAsync(Guid.NewGuid(), deadline.Token), + Create(generation, second).OpenAsync(Guid.NewGuid(), deadline.Token)); + + Assert.All(results, value => Assert.True(value.IsSucceeded, value.Diagnostic?.Code)); + Assert.Equal(Hash(results[0]), Hash(results[1])); + Assert.Equal(1, results[0].Envelope!.EnvelopeRevision); + Assert.Equal(1, first.Reads); + Assert.Equal(1, second.Reads); + } + + [Fact] + public async Task FailedLegacyObservation_DoesNotCreateAnAuthority() + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationDescriptor generation = directory.CreateGeneration(1); + IOException failure = new("read failed"); + Source source = new() { Failure = failure }; + + Assert.Same(failure, await Record.ExceptionAsync(() => Create(generation, source).OpenAsync(Guid.NewGuid(), CancellationToken.None))); + SettingsPersistenceResult result = await new JsonSettingsRepository(generation, SettingsRegistry.Default).OpenAsync(CancellationToken.None); + Assert.True(result.IsSucceeded); + Assert.Null(result.Envelope); + } + + private static SettingsAuthorityBootstrapper Create(DataGenerationDescriptor generation, Source source, ISettingsPersistenceFaultInjector? fault = null) => + new(new JsonSettingsRepository(generation, SettingsRegistry.Default, fault), source, new SettingsMigrationPlanner(SettingsRegistry.Default)); + + private static string Hash(SettingsPersistenceResult result) => SettingsEnvelopeCodec.Encode(result.Envelope!, SettingsRegistry.Default).ContentHash; + + private sealed class Source : ILegacySettingsSource + { + public int Port { get; init; } = 7890; + public int Reads { get; private set; } + public Exception? Failure { get; init; } + public Func? BeforeRead { get; init; } + public async Task ReadSnapshotAsync(CancellationToken cancellationToken) + { + ++Reads; + if (Failure is not null) { throw Failure; } + if (BeforeRead is not null) { await BeforeRead(cancellationToken); } + cancellationToken.ThrowIfCancellationRequested(); + return new(SettingsRegistry.Default, new Dictionary { ["MixedPort"] = Port }); + } + } + + private sealed class Fault(SettingsPersistenceFaultPoint selected, CancellationTokenSource? cancellation = null) : ISettingsPersistenceFaultInjector + { + public Task InjectAsync(SettingsPersistenceFaultPoint point, CancellationToken cancellationToken) + { + if (point == selected) + { + if (cancellation is null) { throw new IOException("publication interrupted"); } + cancellation.Cancel(); + } + + return Task.CompletedTask; + } + } +} diff --git a/ClashSharp/ClashSharp.Tests/Integration/SettingsAuthoritySessionTests.cs b/ClashSharp/ClashSharp.Tests/Integration/SettingsAuthoritySessionTests.cs new file mode 100644 index 0000000..aab746f --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/SettingsAuthoritySessionTests.cs @@ -0,0 +1,518 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Settings; +using ClashSharp.Tests.Unit.Settings; + +namespace ClashSharp.Tests.Integration; + +/// Verifies durable application decisions against the actual JSON repository with controlled runtime participants. +public sealed class SettingsAuthoritySessionTests +{ + [Fact] + public async Task DesiredBatch_IsDurableWithoutClaimingRuntimeApplication() + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsAuthorityResult result = await fixture.ChangeAsync( + [Change("MixedPort", "7890"), Change("TransparentProxyEnabled", "false")]); + Assert.True(result.IsSucceeded, result.Code); + SettingsEnvelope reopened = await fixture.ReadAsync(); + Assert.Equal("7890", reopened.Desired[SettingsRegistry.Keys.MixedPort].Value.CanonicalText); + Assert.Equal("10000", reopened.Applied[SettingsRegistry.Keys.MixedPort].Value!.CanonicalText); + Assert.Equal(2, Assert.Single(reopened.PendingApplications).Entries.Count); + Assert.Equal(0, fixture.Runtime.Applies); + Assert.Equal(0, fixture.Runtime.Probes); + Assert.Equal(2, fixture.Session.Snapshot.EnvelopeRevision); + } + + [Fact] + public async Task Application_PersistsRunningBeforeEffectsAndCompletesOnlyAfterIndependentProbe() + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + fixture.Runtime.BeforeApply = async (_, _) => + { + SettingsEnvelope during = await fixture.ReadAsync(); + Assert.Equal(SettingsApplicationBatchState.Running, Assert.Single(during.PendingApplications).State); + Assert.Equal(SettingAppliedStateKind.Unknown, during.Applied[SettingsRegistry.Keys.MixedPort].Kind); + Assert.Equal("10000", fixture.Runtime.Values[SettingsRegistry.Keys.MixedPort].CanonicalText); + }; + + SettingsAuthorityResult result = await fixture.ApplyAsync(batch); + + Assert.True(result.IsSucceeded, result.Code); + Assert.Empty(result.Envelope!.PendingApplications); + Assert.Equal("7890", result.Envelope.Applied[SettingsRegistry.Keys.MixedPort].Value!.CanonicalText); + Assert.Equal(SettingAppliedValueSource.MutationVerification, result.Envelope.Applied[SettingsRegistry.Keys.MixedPort].Source); + Assert.Equal(1, fixture.Runtime.Applies); + Assert.Equal(2, fixture.Runtime.Probes); + Assert.Equal(Hash(result.Envelope), Hash(await fixture.ReadAsync())); + } + + [Theory] + [InlineData(SettingsPersistenceFaultPoint.BeforeEnvelopePromotion)] + [InlineData(SettingsPersistenceFaultPoint.AfterEnvelopePromotion)] + public async Task InterruptedRunningPublication_DoesNotCallParticipantAndFreshSessionResolvesDurableIntent(SettingsPersistenceFaultPoint cut) + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + fixture.ResetSession(new Fault(cut, occurrence: 1)); + + SettingsAuthorityResult interrupted = await fixture.ApplyAsync(batch); + + Assert.Equal(SettingsAuthorityStatus.PersistenceFailed, interrupted.Status); + Assert.Equal(0, fixture.Runtime.Probes); + Assert.Equal(0, fixture.Runtime.Applies); + Assert.Throws(() => fixture.Session.Snapshot); + SettingsEnvelope reopened = await fixture.ReadAsync(); + Assert.Equal(cut == SettingsPersistenceFaultPoint.BeforeEnvelopePromotion + ? SettingsApplicationBatchState.Pending : SettingsApplicationBatchState.Running, Assert.Single(reopened.PendingApplications).State); + fixture.ResetSession(); + Assert.True((await fixture.ApplyAsync(batch)).IsSucceeded); + Assert.Equal(1, fixture.Runtime.Applies); + Assert.Empty((await fixture.ReadAsync()).PendingApplications); + } + + [Theory] + [InlineData(SettingsPersistenceFaultPoint.BeforeEnvelopePromotion)] + [InlineData(SettingsPersistenceFaultPoint.AfterEnvelopePromotion)] + public async Task InterruptedVerifiedPublication_FreshSessionDoesNotRepeatAnAlreadyObservedEffect(SettingsPersistenceFaultPoint cut) + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + fixture.ResetSession(new Fault(cut, occurrence: 2)); + + SettingsAuthorityResult interrupted = await fixture.ApplyAsync(batch); + + Assert.Equal(SettingsAuthorityStatus.PersistenceFailed, interrupted.Status); + Assert.Equal(1, fixture.Runtime.Applies); + Assert.Throws(() => fixture.Session.Snapshot); + fixture.ResetSession(); + SettingsEnvelope reopened = await fixture.ReadAsync(); + if (cut == SettingsPersistenceFaultPoint.BeforeEnvelopePromotion) + { + Assert.Equal(SettingsApplicationBatchState.Running, Assert.Single(reopened.PendingApplications).State); + Assert.True((await fixture.ApplyAsync(batch)).IsSucceeded); + Assert.Equal(3, fixture.Runtime.Probes); + } + else + { + Assert.Empty(reopened.PendingApplications); + Assert.Equal(SettingsAuthorityStatus.Rejected, (await fixture.ApplyAsync(batch)).Status); + Assert.Equal(2, fixture.Runtime.Probes); + } + + Assert.Equal(1, fixture.Runtime.Applies); + Assert.Empty((await fixture.ReadAsync()).PendingApplications); + } + + [Theory] + [InlineData(SettingsPersistenceFaultPoint.BeforeEnvelopePromotion)] + [InlineData(SettingsPersistenceFaultPoint.AfterEnvelopePromotion)] + public async Task Cancellation_ChangesMeaningAtTheDurableRunningBoundary(SettingsPersistenceFaultPoint cut) + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + using CancellationTokenSource caller = new(); + fixture.ResetSession(new Fault(cut, occurrence: 1, caller)); + if (cut == SettingsPersistenceFaultPoint.BeforeEnvelopePromotion) + { + await Assert.ThrowsAnyAsync(() => fixture.ApplyAsync(batch, caller.Token)); + Assert.Equal(0, fixture.Runtime.Probes); + Assert.Equal(0, fixture.Runtime.Applies); + Assert.Equal(SettingsApplicationBatchState.Pending, Assert.Single((await fixture.ReadAsync()).PendingApplications).State); + } + else + { + Assert.True((await fixture.ApplyAsync(batch, caller.Token)).IsSucceeded); + Assert.True(caller.IsCancellationRequested); + Assert.Equal(1, fixture.Runtime.Applies); + Assert.Empty((await fixture.ReadAsync()).PendingApplications); + Assert.All(fixture.Runtime.Tokens, token => Assert.False(token.CanBeCanceled)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LostParticipantReply_IsResolvedFromActualEffect(bool effectOccurred) + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + fixture.Runtime.IgnoreEffect = !effectOccurred; + fixture.Runtime.ApplyFailure = new IOException("synthetic private reply text"); + + SettingsAuthorityResult result = await fixture.ApplyAsync(batch); + + Assert.Equal(effectOccurred ? SettingsAuthorityStatus.Succeeded : SettingsAuthorityStatus.ApplicationFailed, result.Status); + Assert.Equal(2, fixture.Runtime.Probes); + if (effectOccurred) + { + Assert.Empty(result.Envelope!.PendingApplications); + Assert.Equal(SettingAppliedValueSource.RuntimeProbe, result.Envelope.Applied[SettingsRegistry.Keys.MixedPort].Source); + Assert.Equal("settings.application.reply_lost_resolved", result.Code); + } + else + { + Assert.Equal(SettingsApplicationBatchState.Failed, Assert.Single(result.Envelope!.PendingApplications).State); + Assert.Equal(SettingAppliedStateKind.Unknown, result.Envelope.Applied[SettingsRegistry.Keys.MixedPort].Kind); + Assert.DoesNotContain("private", result.Code!, StringComparison.Ordinal); + } + } + + [Fact] + public async Task FailedVerification_RequiresExplicitRetryAndRejectsThePreviousAttempt() + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + fixture.Runtime.IgnoreEffect = true; + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, (await fixture.ApplyAsync(batch)).Status); + Assert.Equal(SettingsAuthorityStatus.Rejected, (await fixture.ApplyAsync(batch)).Status); + Assert.Equal(1, fixture.Runtime.Applies); + await using (MutationAdmissionLease lease = await fixture.Admission.AcquireOrdinaryAsync(CancellationToken.None)) + { + Assert.True((await fixture.Session.RetryAdmittedAsync(batch.BatchId, batch.AttemptId, Guid.NewGuid(), lease, CancellationToken.None)).IsSucceeded); + } + + fixture.Runtime.IgnoreEffect = false; + Assert.Equal(SettingsAuthorityStatus.Rejected, (await fixture.ApplyAsync(batch)).Status); + SettingsApplicationBatch retry = Assert.Single((await fixture.ReadAsync()).PendingApplications); + Assert.True((await fixture.ApplyAsync(retry)).IsSucceeded); + Assert.Equal(2, fixture.Runtime.Applies); + } + + [Theory] + [InlineData("generation")] + [InlineData("generation_path")] + [InlineData("batch")] + [InlineData("attempt")] + [InlineData("missing")] + [InlineData("extra")] + [InlineData("wrong_type")] + public async Task UnboundOrIncompleteInitialProbe_CannotAuthorizeEffects(string fault) + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + fixture.Runtime.TransformProbe = observation => + { + DataGenerationDescriptor generation = observation.Generation; + Guid batchId = observation.BatchId; + Guid attemptId = observation.AttemptId; + List values = [.. observation.Values]; + switch (fault) + { + case "generation": generation = new(Guid.NewGuid(), generation.GenerationNumber, generation.RootPath); break; + case "generation_path": generation = new(generation.GenerationId, generation.GenerationNumber, Path.Combine(generation.RootPath, "foreign")); break; + case "batch": batchId = Guid.NewGuid(); break; + case "attempt": attemptId = Guid.NewGuid(); break; + case "missing": values.Clear(); break; + case "extra": values.Add(Change("AppThemeMode", "Dark")); break; + case "wrong_type": values[0] = new(SettingsRegistry.Keys.MixedPort, SettingsRegistry.Default.Get("AppThemeMode").DefaultValue); break; + } + + return new(generation, batchId, attemptId, values); + }; + + SettingsAuthorityResult result = await fixture.ApplyAsync(batch); + + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, result.Status); + Assert.Equal(0, fixture.Runtime.Applies); + Assert.Equal(SettingAppliedUnknownReason.ProbeFailed, result.Envelope!.Applied[SettingsRegistry.Keys.MixedPort].UnknownReason); + } + + [Fact] + public async Task ProbeFailure_PersistsFailureWithoutBlindlyApplyingDesiredValues() + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + fixture.Runtime.ProbeFailure = new IOException("not observed"); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, (await fixture.ApplyAsync(batch)).Status); + Assert.Equal(0, fixture.Runtime.Applies); + Assert.Equal(SettingsApplicationBatchState.Failed, Assert.Single((await fixture.ReadAsync()).PendingApplications).State); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FatalParticipantGraph_EscapesAndLeavesDurableRunningWork(bool duringApply) + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + AggregateException fatal = new(new IOException("wrapper", Activator.CreateInstance())); + if (duringApply) { fixture.Runtime.ApplyFailure = fatal; } + else { fixture.Runtime.ProbeFailure = fatal; } + + Assert.Same(fatal, await Record.ExceptionAsync(() => fixture.ApplyAsync(batch))); + Assert.Equal(SettingsApplicationBatchState.Running, Assert.Single((await fixture.ReadAsync()).PendingApplications).State); + } + + [Fact] + public async Task AdmissionDrain_RevokesQueuedEditsButWaitsForTheStartedParticipantAndItsFinalSave() + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(15)); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Runtime.BeforeApply = async (_, _) => + { + entered.TrySetResult(); + await release.Task.WaitAsync(deadline.Token); + }; + Task applying = fixture.ApplyAsync(batch, deadline.Token); + Task? queued = null; + Task? draining = null; + try + { + await entered.Task.WaitAsync(deadline.Token); + queued = fixture.ChangeAsync([Change("MixedPort", "10001")], deadline.Token); + Assert.False(queued.IsCompleted); + draining = fixture.Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, deadline.Token).AsTask(); + await Assert.ThrowsAnyAsync(() => queued); + Assert.False(draining.IsCompleted); + } + finally + { + release.TrySetResult(); + await applying; + } + + Assert.True((await applying).IsSucceeded); + await using MutationAdmissionLease exclusive = await draining!; + SettingsEnvelope committed = await fixture.ReadAsync(); + Assert.Empty(committed.PendingApplications); + Assert.Equal("7890", committed.Desired[SettingsRegistry.Keys.MixedPort].Value.CanonicalText); + } + + [Fact] + public async Task EmptyAuthority_IsNotInitializedByOrdinaryCommands() + { + await using DataGenerationTestDirectory directory = new(); + MutationAdmissionBarrier admission = new(); + await using SettingsAuthoritySession session = new(new JsonSettingsRepository(directory.CreateGeneration(1), SettingsRegistry.Default), SettingsRegistry.Default, admission); + Assert.Throws(() => session.Snapshot); + await using MutationAdmissionLease lease = await admission.AcquireOrdinaryAsync(CancellationToken.None); + SettingsAuthorityResult result = await session.ChangeAdmittedAsync([Change("MixedPort", "7890")], Guid.NewGuid(), lease, CancellationToken.None); + Assert.Equal(SettingsAuthorityStatus.PersistenceFailed, result.Status); + Assert.Equal(SettingsPersistenceStatus.Invalid, result.PersistenceStatus); + Assert.Equal("settings.authority.uninitialized", result.Code); + Assert.Throws(() => session.Snapshot); + } + + [Fact] + public async Task ConcurrentRepositoryWriter_IsPreservedAndRunningAttemptIsReprobedAfterConflict() + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + fixture.Runtime.BeforeApply = async (_, _) => + { + JsonSettingsRepository other = new(fixture.Generation, SettingsRegistry.Default); + SettingsEnvelope current = (await other.OpenAsync(CancellationToken.None)).Envelope!; + SettingsEnvelope next = new SettingsEnvelopeEditor(SettingsRegistry.Default).ApplyChanges(current, + [Change("AppThemeMode", "Dark")], Guid.NewGuid()).Envelope; + Assert.True((await other.SaveAsync(next, current.EnvelopeRevision, CancellationToken.None)).IsSucceeded); + }; + + SettingsAuthorityResult conflicted = await fixture.ApplyAsync(batch); + + Assert.Equal(SettingsAuthorityStatus.PersistenceFailed, conflicted.Status); + Assert.Equal(SettingsPersistenceStatus.Conflict, conflicted.PersistenceStatus); + Assert.Equal("Dark", fixture.Session.Snapshot.Desired[SettingsRegistry.Keys.AppThemeMode].Value.CanonicalText); + Assert.Equal(2, fixture.Session.Snapshot.PendingApplications.Count); + fixture.Runtime.BeforeApply = null; + Assert.True((await fixture.ApplyAsync(batch)).IsSucceeded); + Assert.Equal(1, fixture.Runtime.Applies); + SettingsApplicationBatch retained = Assert.Single((await fixture.ReadAsync()).PendingApplications); + Assert.Equal(SettingApplicationKind.Appearance, retained.ApplicationKind); + } + + [Fact] + public async Task RestartBatch_IsDeferredLiveAndRequiresExclusiveStartupOwnership() + { + await using DataGenerationTestDirectory directory = new(); + SettingsRegistry registry = SettingsEnvelopeTestData.CreateLiveAndRestartRegistry(); + DataGenerationDescriptor generation = directory.CreateGeneration(1); + JsonSettingsRepository repository = new(generation, registry); + Assert.True((await repository.SaveAsync(SettingsEnvelopeTestData.CreateMatchingEnvelope(registry), 0, CancellationToken.None)).IsSucceeded); + MutationAdmissionBarrier admission = new(); + await using SettingsAuthoritySession session = new(repository, registry, admission); + SettingKey key = new("RestartInternal"); + Participant runtime = new() { ApplicationKind = SettingApplicationKind.Internal }; + runtime.Values[key] = registry.Get(key.Value).DefaultValue; + SettingsApplicationBatch batch; + await using (MutationAdmissionLease ordinary = await admission.AcquireOrdinaryAsync(CancellationToken.None)) + { + SettingsAuthorityResult changed = await session.ChangeAdmittedAsync( + [new(key, registry.Get(key.Value).Normalize("true").Value!)], Guid.NewGuid(), ordinary, CancellationToken.None); + Assert.True(changed.IsSucceeded, changed.Code); + batch = Assert.Single(changed.Envelope!.PendingApplications); + SettingsAuthorityResult deferred = await session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, + runtime, SettingsApplicationPhase.Live, ordinary, CancellationToken.None); + Assert.Equal(SettingsAuthorityStatus.DeferredToRestart, deferred.Status); + Assert.Equal(0, runtime.Probes); + await Assert.ThrowsAsync(() => session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, + runtime, SettingsApplicationPhase.Startup, ordinary, CancellationToken.None)); + } + + await using MutationAdmissionLease startup = await admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, CancellationToken.None); + SettingsAuthorityResult applied = await session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, + runtime, SettingsApplicationPhase.Startup, startup, CancellationToken.None); + Assert.True(applied.IsSucceeded, applied.Code); + Assert.Equal(SettingAppliedValueSource.StartupReconciliation, applied.Envelope!.Applied[key].Source); + Assert.Empty(applied.Envelope.PendingApplications); + } + + [Fact] + public async Task SessionRetirement_WaitsForApplicationAndRejectsStaleWriters() + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsApplicationBatch batch = await fixture.QueueAsync(); + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(15)); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Runtime.BeforeApply = async (_, _) => + { + entered.TrySetResult(); + await release.Task.WaitAsync(deadline.Token); + }; + Task applying = fixture.ApplyAsync(batch, deadline.Token); + Task? retirement = null; + try + { + await entered.Task.WaitAsync(deadline.Token); + retirement = fixture.Session.DisposeAsync().AsTask(); + Assert.False(retirement.IsCompleted); + await Assert.ThrowsAsync(() => fixture.ChangeAsync([Change("MixedPort", "10001")], deadline.Token)); + } + finally + { + release.TrySetResult(); + await applying; + if (retirement is not null) { await retirement; } + } + + Assert.True((await applying).IsSucceeded); + Assert.Throws(() => fixture.Session.Snapshot); + Assert.Empty((await fixture.ReadAsync()).PendingApplications); + await fixture.Session.DisposeAsync(); + } + + private static SettingValueChange Change(string key, string value) => new(new(key), SettingsEnvelopeTestData.Value(key, value)); + private static string Hash(SettingsEnvelope envelope) => SettingsEnvelopeCodec.Encode(envelope, SettingsRegistry.Default).ContentHash; + + private sealed class Fixture : IAsyncDisposable + { + private readonly DataGenerationTestDirectory _directory = new(); + private readonly List _previousSessions = []; + private Fixture() + { + Generation = _directory.CreateGeneration(1); + Session = new(new JsonSettingsRepository(Generation, SettingsRegistry.Default), SettingsRegistry.Default, Admission); + } + + public DataGenerationDescriptor Generation { get; } + public MutationAdmissionBarrier Admission { get; } = new(); + public SettingsAuthoritySession Session { get; private set; } + public Participant Runtime { get; } = new(); + + public static async Task CreateAsync() + { + Fixture fixture = new(); + try + { + Assert.True((await new JsonSettingsRepository(fixture.Generation, SettingsRegistry.Default).SaveAsync( + SettingsEnvelopeTestData.CreateMatchingEnvelope(), 0, CancellationToken.None)).IsSucceeded); + return fixture; + } + catch + { + await fixture.DisposeAsync(); + throw; + } + } + + public void ResetSession(ISettingsPersistenceFaultInjector? fault = null) + { + _previousSessions.Add(Session); + Session = new(new JsonSettingsRepository(Generation, SettingsRegistry.Default, fault), SettingsRegistry.Default, Admission); + } + + public async Task ReadAsync() => + (await new JsonSettingsRepository(Generation, SettingsRegistry.Default).OpenAsync(CancellationToken.None)).Envelope!; + + public async Task ChangeAsync(IEnumerable changes, CancellationToken token = default) + { + await using MutationAdmissionLease lease = await Admission.AcquireOrdinaryAsync(token); + return await Session.ChangeAdmittedAsync(changes, Guid.NewGuid(), lease, token); + } + + public async Task QueueAsync() => + Assert.Single((await ChangeAsync([Change("MixedPort", "7890")])).Envelope!.PendingApplications); + + public async Task ApplyAsync(SettingsApplicationBatch batch, CancellationToken token = default) + { + await using MutationAdmissionLease lease = await Admission.AcquireOrdinaryAsync(token); + return await Session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, Runtime, SettingsApplicationPhase.Live, lease, token); + } + + public async ValueTask DisposeAsync() + { + await Session.DisposeAsync(); + foreach (SettingsAuthoritySession previous in _previousSessions) { await previous.DisposeAsync(); } + await _directory.DisposeAsync(); + } + } + + private sealed class Participant : ISettingsApplicationParticipant + { + public SettingApplicationKind ApplicationKind { get; init; } = SettingApplicationKind.Network; + public Dictionary Values { get; } = SettingsRegistry.Default.Definitions.ToDictionary(item => item.Key, item => item.DefaultValue); + public int Probes { get; private set; } + public int Applies { get; private set; } + public bool IgnoreEffect { get; set; } + public Exception? ApplyFailure { get; set; } + public Exception? ProbeFailure { get; set; } + public Func? BeforeApply { get; set; } + public Func? TransformProbe { get; set; } + public List Tokens { get; } = []; + + public Task ProbeAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ++Probes; + Tokens.Add(cancellationToken); + if (ProbeFailure is not null) { throw ProbeFailure; } + SettingsApplicationObservation observation = new(request.Generation, request.Batch.BatchId, request.Batch.AttemptId, + request.Values.Keys.Select(key => new SettingValueChange(key, Values[key]))); + return Task.FromResult(TransformProbe?.Invoke(observation) ?? observation); + } + + public async Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ++Applies; + Tokens.Add(cancellationToken); + if (BeforeApply is not null) { await BeforeApply(request, cancellationToken); } + if (!IgnoreEffect) + { + foreach ((SettingKey key, SettingValue value) in request.Values) { Values[key] = value; } + } + + if (ApplyFailure is not null) { throw ApplyFailure; } + } + } + + private sealed class Fault(SettingsPersistenceFaultPoint selected, int occurrence, CancellationTokenSource? cancellation = null) : ISettingsPersistenceFaultInjector + { + private int _arrivals; + public Task InjectAsync(SettingsPersistenceFaultPoint point, CancellationToken cancellationToken) + { + if (point == selected && ++_arrivals == occurrence) + { + if (cancellation is null) { throw new IOException("controlled durable cut"); } + cancellation.Cancel(); + } + + return Task.CompletedTask; + } + } +} diff --git a/ClashSharp/ClashSharp.Tests/Integration/SettingsGenerationLifetimeTests.cs b/ClashSharp/ClashSharp.Tests/Integration/SettingsGenerationLifetimeTests.cs new file mode 100644 index 0000000..9db6ba6 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/SettingsGenerationLifetimeTests.cs @@ -0,0 +1,168 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Settings; +using ClashSharp.Tests.Unit.Settings; + +namespace ClashSharp.Tests.Integration; + +/// Checks that actual settings repositories, actor lifetime, and durable generation manifests move together. +public sealed class SettingsGenerationLifetimeTests +{ + [Fact] + public async Task Transition_WaitsForOwnedSettingsWorkAndSubsequentCommandsResolveOnlyTheNewSession() + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationManifestSnapshot baseline = await directory.PromoteFirstAsync(); + MutationAdmissionBarrier admission = new(); + Lifetime oldLifetime = await Lifetime.CreateAsync(baseline.Descriptor, admission); + await using DataGenerationManager manager = new(); + manager.Initialize(baseline, new(baseline.Descriptor, oldLifetime)); + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(15)); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + async Task ChangeOldAsync() + { + await using MutationAdmissionLease lease = await admission.AcquireOrdinaryAsync(deadline.Token); + return await manager.ExecuteAsync(async (session, generation, token) => + { + Assert.True(session.Generation.IsSameGeneration(generation)); + entered.TrySetResult(); + await release.Task.WaitAsync(deadline.Token); + return await session.ChangeAdmittedAsync([Change("7890")], Guid.NewGuid(), lease, token); + }, deadline.Token); + } + + Task changing = ChangeOldAsync(); + Task? draining = null; + try + { + await entered.Task.WaitAsync(deadline.Token); + draining = manager.BeginDrainAsync(baseline.ContentHash, deadline.Token).AsTask(); + Assert.False(draining.IsCompleted); + Assert.Equal(0, oldLifetime.Disposals); + Assert.Throws(() => manager.ReadSnapshot( + (session, _) => session.Snapshot)); + } + finally + { + release.TrySetResult(); + await changing; + } + + Assert.True((await changing).IsSucceeded); + DataGenerationTransition transition = await draining!; + Assert.Equal("7890", oldLifetime.Session.Snapshot.Desired[SettingsRegistry.Keys.MixedPort].Value.CanonicalText); + DataGenerationDescriptor candidate = directory.CreateGeneration(2); + Lifetime nextLifetime = await Lifetime.CreateAsync(candidate, admission); + transition.Stage(new(candidate, nextLifetime)); + await transition.PromoteManifestAsync(directory.Store, deadline.Token); + transition.SwapToPromoted(); + await transition.CommitAsync(); + Assert.Equal(1, oldLifetime.Disposals); + Assert.Throws(() => oldLifetime.Session.Snapshot); + + await using MutationAdmissionLease currentLease = await admission.AcquireOrdinaryAsync(deadline.Token); + SettingsAuthorityResult current = await manager.ExecuteAsync( + (session, generation, token) => + { + Assert.True(candidate.IsSameGeneration(generation)); + Assert.Same(nextLifetime.Session, session); + return session.ChangeAdmittedAsync([Change("10001")], Guid.NewGuid(), currentLease, token); + }, deadline.Token); + Assert.True(current.IsSucceeded); + Assert.Equal("10001", manager.ReadSnapshot( + (session, _) => session.Snapshot.Desired[SettingsRegistry.Keys.MixedPort].Value.CanonicalText)); + SettingsEnvelope oldBytes = (await new JsonSettingsRepository(baseline.Descriptor, SettingsRegistry.Default) + .OpenAsync(deadline.Token)).Envelope!; + Assert.Equal("7890", oldBytes.Desired[SettingsRegistry.Keys.MixedPort].Value.CanonicalText); + Assert.Equal(candidate.GenerationId, (await directory.Store.LoadCurrentAsync(deadline.Token))!.Descriptor.GenerationId); + } + + [Fact] + public async Task Rollback_RestoresTheOriginalSessionAndDisposesOnlyTheCandidate() + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationManifestSnapshot baseline = await directory.PromoteFirstAsync(); + MutationAdmissionBarrier admission = new(); + Lifetime original = await Lifetime.CreateAsync(baseline.Descriptor, admission); + await using DataGenerationManager manager = new(); + manager.Initialize(baseline, new(baseline.Descriptor, original)); + DataGenerationTransition transition = await manager.BeginDrainAsync(baseline.ContentHash, CancellationToken.None); + DataGenerationDescriptor candidate = directory.CreateGeneration(2); + Lifetime rejected = await Lifetime.CreateAsync(candidate, admission); + transition.Stage(new(candidate, rejected)); + await transition.PromoteManifestAsync(directory.Store, CancellationToken.None); + transition.SwapToPromoted(); + await transition.RestoreBaselineAsync(directory.Store, CancellationToken.None); + + Assert.Equal(0, original.Disposals); + Assert.Equal(1, rejected.Disposals); + Assert.Throws(() => rejected.Session.Snapshot); + await using MutationAdmissionLease lease = await admission.AcquireOrdinaryAsync(CancellationToken.None); + SettingsAuthorityResult result = await manager.ExecuteAsync( + (session, generation, token) => + { + Assert.Same(original.Session, session); + Assert.True(baseline.Descriptor.IsSameGeneration(generation)); + return session.ChangeAdmittedAsync([Change("7890")], Guid.NewGuid(), lease, token); + }, CancellationToken.None); + Assert.True(result.IsSucceeded); + Assert.Equal(baseline.Descriptor.GenerationId, manager.CurrentManifest.Descriptor.GenerationId); + Assert.Equal(manager.CurrentManifest.ContentHash, (await directory.Store.LoadCurrentAsync(CancellationToken.None))!.ContentHash); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedResolutionOrOperation_ReleasesItsPinSoDrainCanComplete(bool operationThrows) + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationManifestSnapshot baseline = await directory.PromoteFirstAsync(); + MutationAdmissionBarrier admission = new(); + Lifetime lifetime = await Lifetime.CreateAsync(baseline.Descriptor, admission); + await using DataGenerationManager manager = new(); + manager.Initialize(baseline, new(baseline.Descriptor, lifetime)); + if (operationThrows) + { + IOException failure = new("owned operation failure"); + Assert.Same(failure, await Record.ExceptionAsync(() => manager.ExecuteAsync( + (_, _, _) => Task.FromException(failure), CancellationToken.None))); + } + else + { + await Assert.ThrowsAsync(() => manager.ExecuteAsync( + (_, _, _) => Task.FromResult(1), CancellationToken.None)); + } + + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + DataGenerationTransition transition = await manager.BeginDrainAsync(baseline.ContentHash, deadline.Token); + await transition.AbortAsync(); + Assert.Equal(baseline.ContentHash, manager.CurrentManifest.ContentHash); + } + + private static SettingValueChange Change(string port) => + new(SettingsRegistry.Keys.MixedPort, SettingsEnvelopeTestData.Value("MixedPort", port)); + + private sealed class Lifetime(SettingsAuthoritySession session) : IServiceProvider, IAsyncDisposable + { + public SettingsAuthoritySession Session { get; } = session; + public int Disposals { get; private set; } + public object? GetService(Type serviceType) => serviceType == typeof(SettingsAuthoritySession) ? Session : null; + + public static async Task CreateAsync(DataGenerationDescriptor generation, MutationAdmissionBarrier admission) + { + JsonSettingsRepository repository = new(generation, SettingsRegistry.Default); + Assert.True((await repository.SaveAsync(SettingsEnvelopeTestData.CreateMatchingEnvelope(), 0, CancellationToken.None)).IsSucceeded); + return new(new(repository, SettingsRegistry.Default, admission)); + } + + public async ValueTask DisposeAsync() + { + ++Disposals; + await Session.DisposeAsync(); + } + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsApplicationBatchEditorTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsApplicationBatchEditorTests.cs new file mode 100644 index 0000000..8027d13 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsApplicationBatchEditorTests.cs @@ -0,0 +1,144 @@ +using ClashSharp.Settings; + +namespace ClashSharp.Tests.Unit.Settings; + +/// Checks that uncertain effects, stale callbacks, and incomplete probes cannot manufacture applied settings. +public sealed class SettingsApplicationBatchEditorTests +{ + private readonly SettingsApplicationBatchEditor _editor = new(SettingsRegistry.Default); + private static readonly DateTimeOffset ObservedAt = new(2026, 9, 8, 9, 0, 0, TimeSpan.Zero); + + [Fact] + public void BeginAttempt_InvalidatesOnlyItsPreviousEvidenceAndBlocksTouchedEdits() + { + SettingsEnvelope source = Pending(); + SettingsApplicationBatch batch = source.PendingApplications[0]; + SettingsEnvelope running = AssertUpdated(_editor.BeginAttempt(source, batch.BatchId, batch.AttemptId)); + Assert.Equal(SettingsApplicationBatchState.Running, Assert.Single(running.PendingApplications).State); + Assert.Equal(SettingAppliedStateKind.Unknown, running.Applied[SettingsRegistry.Keys.AppThemeMode].Kind); + Assert.Equal(SettingAppliedStateKind.Verified, source.Applied[SettingsRegistry.Keys.AppThemeMode].Kind); + Assert.Same(source.Applied[SettingsRegistry.Keys.MixedPort], running.Applied[SettingsRegistry.Keys.MixedPort]); + Assert.All(source.Desired, item => Assert.Same(item.Value, running.Desired[item.Key])); + SettingsEnvelopeEditResult edit = new SettingsEnvelopeEditor(SettingsRegistry.Default).ApplyChanges(running, + [Change("AppThemeMode", "Light")], Guid.NewGuid()); + Assert.Equal(SettingsEnvelopeEditOutcome.Busy, edit.Outcome); + Assert.Same(running, edit.Envelope); + Assert.Same(running, _editor.BeginAttempt(running, batch.BatchId, batch.AttemptId).Envelope); + } + + [Fact] + public void CompleteAttempt_PreservesAnUnrelatedEditMadeWhileTheParticipantWasRunning() + { + SettingsEnvelope source = Pending(); + SettingsApplicationBatch batch = source.PendingApplications[0]; + SettingsEnvelope running = AssertUpdated(_editor.BeginAttempt(source, batch.BatchId, batch.AttemptId)); + SettingsEnvelope intervening = AssertUpdated(new SettingsEnvelopeEditor(SettingsRegistry.Default).ApplyChanges(running, + [Change("MixedPort", "7890")], Guid.NewGuid())); + SettingsApplicationBatch network = Assert.Single(intervening.PendingApplications, item => item.ApplicationKind == SettingApplicationKind.Network); + + SettingsEnvelope completed = AssertUpdated(_editor.CompleteAttempt(intervening, batch.BatchId, batch.AttemptId, + [Change("AppThemeMode", "Dark"), Change("AppAccentColorValue", "#FF001122")], + SettingAppliedValueSource.MutationVerification, ObservedAt)); + + Assert.Same(network, Assert.Single(completed.PendingApplications)); + Assert.Same(intervening.Desired[SettingsRegistry.Keys.MixedPort], completed.Desired[SettingsRegistry.Keys.MixedPort]); + Assert.Equal("7890", completed.Desired[SettingsRegistry.Keys.MixedPort].Value.CanonicalText); + Assert.Equal("Dark", completed.Applied[SettingsRegistry.Keys.AppThemeMode].Value!.CanonicalText); + Assert.Equal(ObservedAt, completed.Applied[SettingsRegistry.Keys.AppThemeMode].ObservedAt); + Assert.Equal(SettingAppliedValueSource.MutationVerification, completed.Applied[SettingsRegistry.Keys.AppThemeMode].Source); + } + + [Theory] + [InlineData("missing")] + [InlineData("duplicate")] + [InlineData("extra")] + [InlineData("mismatch")] + [InlineData("wrong_type")] + [InlineData("wrong_key")] + [InlineData("stale_attempt")] + [InlineData("default_source")] + [InlineData("legacy_source")] + [InlineData("non_utc")] + [InlineData("missing_time")] + public void UntrustworthyCompletion_CannotPartiallyPublishEvidence(string fault) + { + SettingsEnvelope source = Pending(); + SettingsApplicationBatch batch = source.PendingApplications[0]; + SettingsEnvelope running = AssertUpdated(_editor.BeginAttempt(source, batch.BatchId, batch.AttemptId)); + List values = [Change("AppThemeMode", "Dark"), Change("AppAccentColorValue", "#FF001122")]; + Guid attempt = batch.AttemptId; + SettingAppliedValueSource evidence = SettingAppliedValueSource.RuntimeProbe; + DateTimeOffset time = ObservedAt; + switch (fault) + { + case "missing": values.RemoveAt(1); break; + case "duplicate": values[1] = values[0]; break; + case "extra": values.Add(Change("MixedPort", "7890")); break; + case "mismatch": values[1] = Change("AppAccentColorValue", "#FF334455"); break; + case "wrong_type": values[1] = new(SettingsRegistry.Keys.AppAccentColorValue, SettingsRegistry.Default.Get("MixedPort").DefaultValue); break; + case "wrong_key": values[1] = Change("MixedPort", "7890"); break; + case "stale_attempt": attempt = Guid.NewGuid(); break; + case "default_source": evidence = SettingAppliedValueSource.DefaultInitialization; break; + case "legacy_source": evidence = SettingAppliedValueSource.LegacyMigration; break; + case "non_utc": time = ObservedAt.ToOffset(TimeSpan.FromHours(8)); break; + case "missing_time": time = default; break; + } + + SettingsEnvelopeEditResult result = _editor.CompleteAttempt(running, batch.BatchId, attempt, values, evidence, time); + Assert.False(result.IsSuccess); + Assert.Same(running, result.Envelope); + Assert.All(batch.Entries, entry => Assert.Equal(SettingAppliedStateKind.Unknown, result.Envelope.Applied[entry.Key].Kind)); + Assert.DoesNotContain("334455", result.ErrorCode!, StringComparison.Ordinal); + } + + [Fact] + public void RetryFailed_ChangesAttemptIdentityAndRejectsAllLateCallbacksFromThePreviousAttempt() + { + SettingsEnvelope source = Pending(); + SettingsApplicationBatch batch = source.PendingApplications[0]; + SettingsEnvelope running = AssertUpdated(_editor.BeginAttempt(source, batch.BatchId, batch.AttemptId)); + SettingsEnvelope failed = AssertUpdated(_editor.FailAttempt(running, batch.BatchId, batch.AttemptId, new("settings.participant.failed"))); + Assert.All(batch.Entries, entry => Assert.Equal(SettingAppliedUnknownReason.ProbeFailed, failed.Applied[entry.Key].UnknownReason)); + Assert.Equal(SettingsApplicationBatchState.Failed, Assert.Single(failed.PendingApplications).State); + Assert.False(_editor.RetryFailed(failed, batch.BatchId, batch.AttemptId, batch.AttemptId).IsSuccess); + Guid nextAttempt = Guid.NewGuid(); + SettingsEnvelope retry = AssertUpdated(_editor.RetryFailed(failed, batch.BatchId, batch.AttemptId, nextAttempt)); + SettingsApplicationBatch next = Assert.Single(retry.PendingApplications); + Assert.Equal(batch.BatchId, next.BatchId); + Assert.Equal(batch.CreationSequence, next.CreationSequence); + Assert.Equal(batch.Entries, next.Entries); + Assert.Null(next.LastError); + Assert.False(_editor.BeginAttempt(retry, batch.BatchId, batch.AttemptId).IsSuccess); + Assert.False(_editor.FailAttempt(retry, batch.BatchId, batch.AttemptId, new("settings.participant.failed")).IsSuccess); + Assert.False(_editor.CompleteAttempt(retry, batch.BatchId, batch.AttemptId, + [Change("AppThemeMode", "Dark"), Change("AppAccentColorValue", "#FF001122")], + SettingAppliedValueSource.RuntimeProbe, ObservedAt).IsSuccess); + AssertUpdated(_editor.BeginAttempt(retry, batch.BatchId, nextAttempt)); + } + + [Fact] + public void CompletedAttempt_CannotLaterBeFailedByAnOldCallback() + { + SettingsEnvelope source = Pending(); + SettingsApplicationBatch batch = source.PendingApplications[0]; + SettingsEnvelope running = AssertUpdated(_editor.BeginAttempt(source, batch.BatchId, batch.AttemptId)); + SettingsEnvelope completed = AssertUpdated(_editor.CompleteAttempt(running, batch.BatchId, batch.AttemptId, + [Change("AppThemeMode", "Dark"), Change("AppAccentColorValue", "#FF001122")], + SettingAppliedValueSource.RuntimeProbe, ObservedAt)); + SettingsEnvelopeEditResult late = _editor.FailAttempt(completed, batch.BatchId, batch.AttemptId, new("settings.participant.failed")); + Assert.False(late.IsSuccess); + Assert.Same(completed, late.Envelope); + } + + private static SettingsEnvelope Pending() => SettingsEnvelopeTestData.CreatePendingEnvelope( + [("AppThemeMode", "Dark"), ("AppAccentColorValue", "#FF001122")]); + + private static SettingValueChange Change(string key, string value) => new(new(key), SettingsEnvelopeTestData.Value(key, value)); + + private static SettingsEnvelope AssertUpdated(SettingsEnvelopeEditResult result) + { + Assert.Equal(SettingsEnvelopeEditOutcome.Updated, result.Outcome); + Assert.True(new SettingsEnvelopeValidator(SettingsRegistry.Default).Validate(result.Envelope).IsValid); + return result.Envelope; + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsMigrationPlannerTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsMigrationPlannerTests.cs new file mode 100644 index 0000000..2425b4d --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsMigrationPlannerTests.cs @@ -0,0 +1,188 @@ +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Model; +using ClashSharp.Settings; + +namespace ClashSharp.Tests.Unit.Settings; + +/// Checks legacy normalization without claiming unobserved runtime settings are already applied. +public sealed class SettingsMigrationPlannerTests +{ + private static readonly Guid MigrationId = new("286626cc-94a3-4470-9b80-b32c4f1df2a9"); + + [Fact] + public void EmptySource_PreservesDefaultsAndQueuesEveryUnobservedApplication() + { + SettingsMigrationPlan plan = Plan([]); + Assert.True(new SettingsEnvelopeValidator(SettingsRegistry.Default).Validate(plan.Envelope).IsValid); + Assert.Equal(1, plan.Envelope.EnvelopeRevision); + Assert.Equal(SettingsRegistry.Default.Definitions.Count, plan.Envelope.Desired.Count); + foreach (SettingDefinition definition in SettingsRegistry.Default.Definitions) + { + Assert.Equal(definition.DefaultValue, plan.Envelope.Desired[definition.Key].Value); + SettingAppliedState state = plan.Envelope.Applied[definition.Key]; + Assert.Equal(SettingAppliedStateKind.Unknown, state.Kind); + Assert.Equal(SettingAppliedUnknownReason.NotObserved, state.UnknownReason); + Assert.Null(state.Value); + Assert.Null(state.ObservedHash); + Assert.Single(plan.Envelope.PendingApplications.SelectMany(batch => batch.Entries), entry => entry.Key == definition.Key); + } + + Assert.All(plan.Diagnostics, value => Assert.Equal("settings.migration.default", value.Code)); + Assert.Equal(MigrationId, Assert.Single(plan.Envelope.MigrationHistory).MigrationId); + } + + [Theory] + [InlineData("LaunchAtStartupEnabled", true, "true")] + [InlineData("MixedPort", 7890, "7890")] + [InlineData("ConnectionSamplingIntervalSeconds", 3, "3")] + [InlineData("ConnectionSamplingIntervalSeconds", 300, "300")] + [InlineData("AppThemeMode", 1, "Light")] + [InlineData("AppAccentColorValue", "#00aa22", "#FF00AA22")] + public void CanonicalLegacyPrimitives_AreNormalized(string key, object raw, string canonical) + { + SettingsMigrationPlan plan = Plan(new() { [key] = raw }); + Assert.Equal(canonical, plan.Envelope.Desired[new(key)].Value.CanonicalText); + Assert.Equal("settings.migration.canonical", Assert.Single(plan.Diagnostics, item => item.Key.Value == key).Code); + } + + [Theory] + [InlineData("LaunchAtStartupEnabled", "true")] + [InlineData("MixedPort", 0)] + [InlineData("ConnectionSamplingIntervalSeconds", 301)] + [InlineData("AppThemeMode", 999)] + [InlineData("AppThemeMode", "Dark")] + public void InvalidOrMistypedSource_UsesDeclaredFallbackWithoutUncontrolledConversion(string key, object raw) + { + SettingsMigrationPlan plan = Plan(new() { [key] = raw }); + Assert.Equal(SettingsRegistry.Default.Get(key).SafeFallback, plan.Envelope.Desired[new(key)].Value); + Assert.Equal("settings.migration.invalid_fallback", Assert.Single(plan.Diagnostics, item => item.Key.Value == key).Code); + } + + [Fact] + public void EveryRegisteredLegacyEnum_PreservesEveryAllowedIntegerValue() + { + foreach (SettingDefinition definition in SettingsRegistry.Default.Definitions.Where(item => item.ValueType.IsEnum)) + { + foreach (SettingValue allowed in definition.AllowedValues) + { + int raw = Convert.ToInt32(Enum.Parse(definition.ValueType, allowed.CanonicalText), System.Globalization.CultureInfo.InvariantCulture); + SettingsMigrationPlan plan = Plan(new() { [definition.Key.Value] = raw }); + Assert.Equal(allowed, plan.Envelope.Desired[definition.Key].Value); + Assert.Equal("settings.migration.canonical", Assert.Single(plan.Diagnostics, item => item.Key == definition.Key).Code); + } + } + } + + [Fact] + public void MixedApplicationTimings_QueueLiveWorkBeforeRestartWork() + { + SettingsRegistry registry = SettingsEnvelopeTestData.CreateLiveAndRestartRegistry(); + SettingsMigrationPlan plan = new SettingsMigrationPlanner(registry).CreatePlan(new(registry, new Dictionary()), MigrationId); + Assert.True(new SettingsEnvelopeValidator(registry).Validate(plan.Envelope).IsValid); + Assert.Equal(SettingsApplicationBatchKind.LiveReconcile, plan.Envelope.PendingApplications[0].Kind); + Assert.Equal(SettingsApplicationBatchKind.Restart, plan.Envelope.PendingApplications[1].Kind); + } + + [Theory] + [InlineData(false, MainlandChinaFeatureMode.Disabled)] + [InlineData(true, MainlandChinaFeatureMode.FlagReplacementAndTextCompletion)] + public void LegacyRegionalBoolean_IsConvertedToCanonicalMode(bool alias, MainlandChinaFeatureMode expected) + { + SettingsMigrationPlan plan = Plan(new() { ["MainlandChinaDisplayEnabled"] = alias }); + Assert.Equal(expected, plan.Envelope.Desired[SettingsRegistry.Keys.MainlandChinaFeatureMode].Value.Get()); + Assert.DoesNotContain(SettingsRegistry.Keys.MainlandChinaDisplayEnabled, plan.Envelope.Desired.Keys); + } + + [Fact] + public void CanonicalRegionalMode_TakesPriorityOverTheLegacyAlias() + { + SettingsMigrationPlan plan = Plan(new() { ["MainlandChinaFeatureMode"] = 1, ["MainlandChinaDisplayEnabled"] = false }); + Assert.Equal(MainlandChinaFeatureMode.FlagReplacementOnly, + plan.Envelope.Desired[SettingsRegistry.Keys.MainlandChinaFeatureMode].Value.Get()); + } + + [Fact] + public void DeprecatedCombinedRegionalMode_PreservesBothEffectiveLegacyChoices() + { + SettingsMigrationPlan plan = Plan(new() { ["MainlandChinaFeatureMode"] = 4, ["MainlandChinaUrlBlockingEnabled"] = false }); + Assert.Equal(MainlandChinaFeatureMode.FlagTextCompletionAndKeywordFilter, + plan.Envelope.Desired[SettingsRegistry.Keys.MainlandChinaFeatureMode].Value.Get()); + Assert.True(plan.Envelope.Desired[SettingsRegistry.Keys.MainlandChinaUrlBlockingEnabled].Value.Get()); + Assert.Equal(2, plan.Diagnostics.Count(item => item.Code == "settings.migration.legacy_mode_split")); + } + + [Fact] + public void InternalCredentialsAndUnknownKeys_AreExcludedFromMigrationIdentityAndOutput() + { + LegacySettingsSnapshot clean = new(SettingsRegistry.Default, new Dictionary { ["MixedPort"] = 7890 }); + LegacySettingsSnapshot privateValues = new(SettingsRegistry.Default, new Dictionary + { + ["MixedPort"] = 7890, + ["MihomoControllerSecret"] = new ExplosiveValue(), + ["unregistered"] = new ExplosiveValue(), + }); + + Assert.Equal(clean.SourceHash, privateValues.SourceHash); + Assert.False(privateValues.TryGetValue("MihomoControllerSecret", out _)); + SettingsMigrationPlan plan = new SettingsMigrationPlanner(SettingsRegistry.Default).CreatePlan(privateValues, MigrationId); + Assert.DoesNotContain(plan.Diagnostics, item => item.Key.Value.Contains("Secret", StringComparison.Ordinal)); + } + + [Fact] + public void UnsupportedRegisteredObject_IsNotStringified() + { + SettingsMigrationPlan plan = Plan(new() { ["MixedPort"] = new ExplosiveValue() }); + Assert.Equal(10000, plan.Envelope.Desired[SettingsRegistry.Keys.MixedPort].Value.Get()); + } + + [Fact] + public void SourceComparer_CannotPromoteAnUnregisteredKeyCasingIntoTheAllowlist() + { + LegacySettingsSnapshot source = new(SettingsRegistry.Default, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["mixedport"] = 7890, + }); + Assert.False(source.TryGetValue("MixedPort", out _)); + Assert.Equal(new LegacySettingsSnapshot(SettingsRegistry.Default, new Dictionary()).SourceHash, source.SourceHash); + } + + [Fact] + public void SnapshotAndPlanIdentity_AreImmutableAndIndependentOfInputEnumerationOrder() + { + Dictionary source = new() { ["MixedPort"] = 7890, ["LaunchAtStartupEnabled"] = true }; + LegacySettingsSnapshot snapshot = new(SettingsRegistry.Default, source); + LegacySettingsSnapshot reordered = new(SettingsRegistry.Default, new Dictionary + { + ["LaunchAtStartupEnabled"] = true, + ["MixedPort"] = 7890, + }); + source["MixedPort"] = 10001; + SettingsMigrationPlanner planner = new(SettingsRegistry.Default); + SettingsMigrationPlan first = planner.CreatePlan(snapshot, MigrationId); + SettingsMigrationPlan second = planner.CreatePlan(reordered, MigrationId); + + Assert.Equal(snapshot.SourceHash, reordered.SourceHash); + Assert.Equal(7890, first.Envelope.Desired[SettingsRegistry.Keys.MixedPort].Value.Get()); + Assert.Equal(SettingsEnvelopeCodec.Encode(first.Envelope, SettingsRegistry.Default).ContentHash, + SettingsEnvelopeCodec.Encode(second.Envelope, SettingsRegistry.Default).ContentHash); + Assert.Equal(snapshot.SourceHash, Assert.Single(first.Envelope.MigrationHistory).SourceHash); + } + + [Fact] + public void OversizedRegisteredValue_RejectsWithoutIncludingTheValueInDiagnostics() + { + string input = new('x', 1024 * 1024 + 1); + ArgumentException result = Assert.Throws(() => + new LegacySettingsSnapshot(SettingsRegistry.Default, new Dictionary { ["ConnectionTestUrl"] = input })); + Assert.DoesNotContain(input, result.Message, StringComparison.Ordinal); + } + + private static SettingsMigrationPlan Plan(Dictionary values) => + new SettingsMigrationPlanner(SettingsRegistry.Default).CreatePlan(new LegacySettingsSnapshot(SettingsRegistry.Default, values), MigrationId); + + private sealed class ExplosiveValue + { + public override string ToString() => throw new InvalidOperationException("Arbitrary legacy objects must not be inspected."); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsStartupReconciliationTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsStartupReconciliationTests.cs new file mode 100644 index 0000000..2b0401b --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Settings/SettingsStartupReconciliationTests.cs @@ -0,0 +1,73 @@ +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Settings; + +namespace ClashSharp.Tests.Unit.Settings; + +/// Prevents historical applied values from being treated as observations of a new process. +public sealed class SettingsStartupReconciliationTests +{ + [Fact] + public void PreviousProcessEvidence_IsInvalidatedWithoutChangingDesiredValues() + { + SettingsEnvelope previous = SettingsEnvelopeTestData.CreateMatchingEnvelope(); + SettingsApplicationBatchEditor editor = new(SettingsRegistry.Default); + Guid startupId = Guid.NewGuid(); + SettingsEnvelopeEditResult prepared = editor.ScheduleStartupReconciliation(previous, startupId); + Assert.Equal(SettingsEnvelopeEditOutcome.Updated, prepared.Outcome); + Assert.True(new SettingsEnvelopeValidator(SettingsRegistry.Default).Validate(prepared.Envelope).IsValid); + Assert.All(prepared.Envelope.Applied.Values, value => Assert.Equal(SettingAppliedUnknownReason.NotObserved, value.UnknownReason)); + Assert.All(previous.Desired, item => Assert.Same(item.Value, prepared.Envelope.Desired[item.Key])); + Assert.Equal(previous.Desired.Count, prepared.Envelope.PendingApplications.SelectMany(batch => batch.Entries).Count()); + Assert.Equal(SettingsEnvelopeEditOutcome.NoChange, editor.ScheduleStartupReconciliation(prepared.Envelope, startupId).Outcome); + SettingsEnvelope repeated = editor.ScheduleStartupReconciliation(previous, startupId).Envelope; + Assert.Equal(SettingsEnvelopeCodec.Encode(prepared.Envelope, SettingsRegistry.Default).ContentHash, + SettingsEnvelopeCodec.Encode(repeated, SettingsRegistry.Default).ContentHash); + } + + [Theory] + [InlineData(SettingsApplicationBatchState.Running)] + [InlineData(SettingsApplicationBatchState.Failed)] + public void InterruptedAndFailedWork_RetainsItsIdentityAndExplicitRetryRequirement(SettingsApplicationBatchState state) + { + SettingsEnvelope previous = SettingsEnvelopeTestData.CreatePendingEnvelope([("AppThemeMode", "Dark")], state, + state == SettingsApplicationBatchState.Failed ? new("settings.application.verification_failed") : null); + SettingsApplicationBatch attempt = Assert.Single(previous.PendingApplications); + SettingsEnvelope prepared = new SettingsApplicationBatchEditor(SettingsRegistry.Default) + .ScheduleStartupReconciliation(previous, Guid.NewGuid()).Envelope; + Assert.True(new SettingsEnvelopeValidator(SettingsRegistry.Default).Validate(prepared).IsValid); + Assert.Same(attempt, Assert.Single(prepared.PendingApplications, batch => batch.BatchId == attempt.BatchId)); + Assert.Equal(SettingAppliedStateKind.Unknown, prepared.Applied[SettingsRegistry.Keys.AppThemeMode].Kind); + } + + [Theory] + [InlineData(SettingAppliedUnknownHandling.BlockOperation)] + [InlineData(SettingAppliedUnknownHandling.UseSafeFallback)] + public void BlockedExternalProbe_IsNotConvertedIntoAutomaticApplication(SettingAppliedUnknownHandling handling) + { + SettingsEnvelope source = SettingsEnvelopeTestData.CreateMatchingEnvelope(); + SettingKey key = SettingsRegistry.Keys.MixedPort; + Dictionary applied = new(source.Applied) + { + [key] = SettingAppliedState.Unknown(SettingAppliedUnknownReason.BlockedProbe, handling), + }; + SettingsEnvelope blocked = new(source.SchemaVersion, source.EnvelopeRevision, source.Desired, applied, + source.PendingApplications, source.MigrationHistory); + SettingsEnvelope prepared = new SettingsApplicationBatchEditor(SettingsRegistry.Default) + .ScheduleStartupReconciliation(blocked, Guid.NewGuid()).Envelope; + Assert.True(new SettingsEnvelopeValidator(SettingsRegistry.Default).Validate(prepared).IsValid); + Assert.Same(applied[key], prepared.Applied[key]); + Assert.DoesNotContain(prepared.PendingApplications.SelectMany(batch => batch.Entries), entry => entry.Key == key); + } + + [Fact] + public void JustMigratedSource_DoesNotCreateDuplicateWorkOrInventEffectiveValues() + { + SettingsEnvelope migrated = new SettingsMigrationPlanner(SettingsRegistry.Default).CreatePlan( + new(SettingsRegistry.Default, new Dictionary()), Guid.NewGuid()).Envelope; + SettingsEnvelopeEditResult result = new SettingsApplicationBatchEditor(SettingsRegistry.Default) + .ScheduleStartupReconciliation(migrated, Guid.NewGuid()); + Assert.Equal(SettingsEnvelopeEditOutcome.NoChange, result.Outcome); + Assert.Same(migrated, result.Envelope); + } +} diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md new file mode 100644 index 0000000..93960f9 --- /dev/null +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -0,0 +1,46 @@ +# Settings generation cutover + +版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转和代际内服务访问;生产 composition 仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 + +## 已实现的存储与迁移 + +`WindowsLegacySettingsSource` 在读取时才访问 LocalSettings,只请求 registry 的规范键和旧别名。它没有写入接口,未注册字段和控制端凭据不会进入迁移快照。`LegacySettingsSnapshot` 复制允许的不可变原始类型,严格匹配键大小写,限制字符串长度,并为排序后的偏好计算稳定摘要。 + +`SettingsMigrationPlanner` 将旧整数枚举转换为规范枚举值,验证范围和类型,保留地区模式旧布尔别名及组合模式拆分的实际行为。缺失值采用声明的默认值,非法值采用安全回退。初始 envelope 的 desired 与 key revision 为 1,记录 migration identity 和输入摘要;所有 applied 都是未观察状态,由唯一待办覆盖,迁移本身不声称 Windows 或运行时已应用这些值。 + +`SettingsAuthorityBootstrapper` 先打开指定代际的仓库,只在已确认空仓库时读取旧偏好。已有 JSON 权威优先;并发初始化采用经过重读验证的获胜提交。损坏、不可用和回执丢失均保留其持久分类,后续会话重新打开实际文件来确定结果。 + +修复了一个持久化缺口:主文件和备份被隔离为 `.corrupt.` 后,下一次打开曾将两个原文件名缺失误判为空仓库。仓库现在识别顶层、精确命名的隔离证据,继续返回 Corrupt;有效主文件或备份仍优先处理。两项回归先复现错误的 Succeeded,再验证修复。 + +## 应用状态与恢复 + +`SettingsApplicationBatchEditor` 是纯状态编辑器。开始应用时保留 desired 和批次身份,先将该批次记录为 Running,并撤销其旧生效证据。完成需要匹配 generation、batch、attempt,以及完整规范键、类型、值、revision 和摘要;部分结果、旧 attempt 和其他代际的结果不能清除待办。失败保留 Failed 和 unknown applied;重试显式分配新的 attempt identity。 + +`SettingsAuthoritySession` 在一个代际内串行执行异步修改和应用。持久 Running 确认前允许取消;确认后持有原许可,等待探测、效果及最终保存结束。参与者先探测,目标已生效时直接记录验证;需要应用时使用显式不可变目标,返回后再次探测。应用回执丢失但实际目标已验证时记录 `settings.application.reply_lost_resolved`。探测失败不授权盲目应用,致命异常图继续向上传递。 + +最终保存失败会使内存投影失效,调用者必须重读仓库。并发文件写入返回真实获胜 envelope,完整保留其他设置修改;Running 待办可在重开后先探测,再决定是否需要效果。会话退休拒绝新命令,并等待已开始的效果和保存完成。 + +`PrepareStartupAdmittedAsync` 需要独占许可,撤销上一进程留下的 verified applied,创建必要的观察待办。已有 Running 和 Failed 身份继续保留;明确阻止的外部探测保留其处理方式。Restart 批次在 Live 阶段保持待处理,必须由独占启动阶段应用。 + +## 代际服务寿命 + +`DataGenerationManager.ExecuteAsync` 在取得代际租约后,从该代际拥有的 `IServiceProvider` 解析服务,并等待完整操作结束才释放租约。`ReadSnapshot` 仅用于同步、无 I/O 的不可变内存快照,不阻塞异步任务。服务容器的异步释放仍由 `DataGenerationScope` 的原生命周期协议负责。 + +真实 JSON 仓库与 manifest 集成回归确认:进行中的设置操作阻止切换;提交后只解析新会话,旧会话拒绝写入;回滚恢复原会话并释放候选会话;解析或操作失败释放租约。生产 profile/log/trigger 的容器装配及导入、重置切换尚待完成。 + +## 本地验证 + +- 18 项目 Release x64 完整构建通过,零警告、零错误,用时 26.33 秒。 +- 主程序 2705 项通过,零失败、零跳过,用时 49 秒;本分支新增 84 项回归。 +- 完整 format 加载 1459 个文件,最终检查零处变更。 +- 记录保存在 `artifacts/verification/1.0.0-settings-generation-main.trx`、`build-settings-generation-complete.log`、`format-settings-generation-verified.log` 和 `local-validation-settings-generation-foundation.json`。 + +持久中断测试使用真实临时仓库、切点注入及新对象重开,运行时参与者为受控模拟。Windows 旧设置适配器已编译,未在开发机读取实际 LocalSettings。实际打包应用的迁移、进程崩溃、完整页面和安装器兼容验收将在生产切换后执行。开发机代理摘要保持 `95e97918ff6de70655b412568cd18dc81c5d6584c607bb9a71ddc72e22460447`。 + +## 完整切换的剩余依赖 + +1. 将偏好写入统一为应用层异步 change set;页面、磁贴、触发器和网络提交者使用同一个接口。控制端凭据迁移到独立的内部凭据端口。 +2. 为 Internal、Appearance、Network、StartupTask、Sampling、Triggers 实现真实 apply/probe 适配器,明确读取 desired、有效状态和待办的消费者。 +3. 在设置驱动的启动步骤之前完成旧事务恢复、代际打开和偏好迁移。profile/log/trigger 与 settings 必须由同一代际容器解析、排空和替换。 +4. 将导入、重置和回滚接入候选代际及 manifest 提交,完成生产消费者替换后,原子替换 `SettingsAuthorityArchitectureTests` 中的临时门禁。 +5. 运行新候选的 CI、打包应用及隔离 Windows 验收,再将完整节点推送 main。 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index 92f5829..dc504c7 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -14,6 +14,13 @@ 远程宿主为 Windows Server 2025 x64,具有 Desktop Experience、Git 和 PowerShell 7,仍用于绿色入口校验;私有 .NET SDK 10.0.201 已验证,没有修改全局 SDK 或 PATH。当前已使用本机现有 Windows Sandbox 提供独立 Windows 11 客体,完成实际包、窗口及证书能力验证,开发机代理保持。正式安装器仍限定 Windows 11 客户端;正常 WPF 安装及完整机器故障矩阵尚未完成,不能由组件探针结果推定。 +## M3n 设置代际切换开发分支(2026-09-08) + +- `feat/settings-generation` 已实现规范化旧偏好迁移、异步设置会话、Running/Failed/verified 流转、启动重新观察及代际内服务解析。隔离损坏文件后的错误初始化先由两项回归复现,再修复。 +- 新增 84 项回归;主程序 2705 项全部通过,零失败、零跳过。18 项目 Release x64 构建零警告、零错误;完整 format 检查 1459 个文件、0 处变更。收据为 `local-validation-settings-generation-foundation.json`。 +- 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。完整接入及验收继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 +- main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 + ## M3m 连接采样设置统一事务(2026-09-08) - 修复页面提前保存采样设置及失败覆盖后端结果的问题,四项回归先复现后通过。页面只暂存完整选择,应用后整组读取权威状态;连续请求和命令完成间隙的请求均由重置前排空等待。 From a08fc4bb36d56fca41b67ed9e0d401edce3650f5 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 18:12:13 +0800 Subject: [PATCH 02/22] feat: retain generation ownership across complete settings commands --- .../Settings/GenerationSettingsAuthority.cs | 160 +++++++++ .../Settings/ISettingsAuthority.cs | 52 +++ .../SettingsAuthoritySession.Application.cs | 16 +- .../Settings/SettingsAuthoritySession.cs | 9 +- .../Settings/SettingsAuthoritySnapshot.cs | 23 ++ .../Settings/SettingsGenerationContext.cs | 35 ++ .../GenerationSettingsAuthorityTests.cs | 310 ++++++++++++++++++ .../2026-09-08-settings-generation-cutover.md | 16 +- docs/reviews/1.0.0-execution-ledger.md | 2 + 9 files changed, 617 insertions(+), 6 deletions(-) create mode 100644 ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySnapshot.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/SettingsGenerationContext.cs create mode 100644 ClashSharp/ClashSharp.Tests/Integration/GenerationSettingsAuthorityTests.cs diff --git a/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs b/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs new file mode 100644 index 0000000..35d3c35 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs @@ -0,0 +1,160 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Owns admission and one generation pin across complete consumer commands, including every affected runtime batch. +public sealed class GenerationSettingsAuthority : ISettingsAuthority +{ + private readonly DataGenerationManager _generations; + private readonly MutationAdmissionBarrier _admission; + private readonly SemaphoreSlim _commandGate = new(1, 1); + + /// Creates the facade without resolving services, opening data, or publishing defaults. + /// Owner of current repositories and transition drain. + /// Shared mutation authority for ordinary and exclusive work. + public GenerationSettingsAuthority(DataGenerationManager generations, MutationAdmissionBarrier admission) + { + _generations = generations ?? throw new ArgumentNullException(nameof(generations)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + } + + /// + public SettingsAuthoritySnapshot CaptureSnapshot() => _generations.ReadSnapshot( + (context, generation) => new(generation, RequireSession(context, generation).Snapshot)); + + /// + public Task OpenAsync(CancellationToken cancellationToken) => + ExecuteOrdinaryAsync((context, lease, token) => context.Session.OpenAdmittedAsync(lease, token), cancellationToken); + + /// + public Task ApplyChangesAsync( + IEnumerable changes, Guid transactionId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(changes); + SettingValueChange[] snapshot = changes.ToArray(); + return ExecuteOrdinaryAsync((context, lease, token) => ChangeAndApplyAsync(context, snapshot, transactionId, lease, token), cancellationToken); + } + + /// + public Task ApplyChangesAdmittedAsync( + IEnumerable changes, Guid transactionId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(changes); + SettingValueChange[] snapshot = changes.ToArray(); + return ExecuteAdmittedAsync((context, lease, token) => ChangeAndApplyAsync(context, snapshot, transactionId, lease, token), + admissionLease, cancellationToken); + } + + /// + public Task RevertAsync(IEnumerable keys, Guid transactionId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(keys); + SettingKey[] snapshot = keys.ToArray(); + return ExecuteOrdinaryAsync(async (context, lease, token) => + { + SettingsAuthorityResult reverted = await context.Session.RevertAdmittedAsync(snapshot, transactionId, lease, token).ConfigureAwait(false); + return await ApplyAffectedAsync(context, reverted, snapshot.ToHashSet(), SettingsApplicationPhase.Live, lease).ConfigureAwait(false); + }, cancellationToken); + } + + /// + public Task RetryAsync(Guid batchId, Guid expectedAttemptId, Guid newAttemptId, CancellationToken cancellationToken) => + ExecuteOrdinaryAsync(async (context, lease, token) => + { + SettingsAuthorityResult retry = await context.Session.RetryAdmittedAsync(batchId, expectedAttemptId, newAttemptId, lease, token).ConfigureAwait(false); + if (!retry.IsSucceeded) { return retry; } + SettingsApplicationBatch batch = retry.Envelope!.PendingApplications.Single(item => item.BatchId == batchId); + return await ApplyOneAsync(context, retry.Envelope, batch, SettingsApplicationPhase.Live, lease).ConfigureAwait(false); + }, cancellationToken); + + /// + public Task ReconcileStartupAdmittedAsync( + Guid startupId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + _admission.EnsureActiveExclusiveLease(admissionLease); + return ExecuteAdmittedAsync(async (context, lease, token) => + { + SettingsAuthorityResult prepared = await context.Session.PrepareStartupAdmittedAsync(startupId, lease, token).ConfigureAwait(false); + return !prepared.IsSucceeded ? prepared : await ApplyAffectedAsync(context, prepared, + prepared.Envelope!.Desired.Keys.ToHashSet(), SettingsApplicationPhase.Startup, lease).ConfigureAwait(false); + }, admissionLease, cancellationToken); + } + + private static async Task ChangeAndApplyAsync( + SettingsGenerationContext context, SettingValueChange[] changes, Guid transactionId, + MutationAdmissionLease lease, CancellationToken cancellationToken) + { + SettingsAuthorityResult changed = await context.Session.ChangeAdmittedAsync(changes, transactionId, lease, cancellationToken).ConfigureAwait(false); + return !changed.IsSucceeded ? changed : await ApplyAffectedAsync(context, changed, + changes.Select(change => change.Key).ToHashSet(), SettingsApplicationPhase.Live, lease).ConfigureAwait(false); + } + + private static async Task ApplyAffectedAsync( + SettingsGenerationContext context, SettingsAuthorityResult committed, IReadOnlySet keys, + SettingsApplicationPhase phase, MutationAdmissionLease lease) + { + if (!committed.IsSucceeded) { return committed; } + SettingsApplicationBatch[] batches = committed.Envelope!.PendingApplications + .Where(batch => batch.Entries.Any(entry => keys.Contains(entry.Key))).ToArray(); + SettingsAuthorityResult result = committed; + bool deferred = false; + foreach (SettingsApplicationBatch batch in batches) + { + SettingsAuthorityResult applied = await ApplyOneAsync(context, result.Envelope!, batch, phase, lease).ConfigureAwait(false); + if (applied.Status == SettingsAuthorityStatus.DeferredToRestart) { deferred = true; result = applied; continue; } + if (!applied.IsSucceeded) { return applied; } + result = applied; + } + + return deferred ? new(SettingsAuthorityStatus.DeferredToRestart, result.Envelope, "settings.application.restart_required") : result; + } + + private static Task ApplyOneAsync( + SettingsGenerationContext context, SettingsEnvelope envelope, SettingsApplicationBatch batch, + SettingsApplicationPhase phase, MutationAdmissionLease lease) + { + if (!context.Participants.TryGetValue(batch.ApplicationKind, out ISettingsApplicationParticipant? participant)) + { + return Task.FromResult(new SettingsAuthorityResult(SettingsAuthorityStatus.Rejected, envelope, "settings.application.participant_missing")); + } + + return context.Session.ContinueCommittedBatchAdmittedAsync(batch.BatchId, batch.AttemptId, participant, phase, lease); + } + + private async Task ExecuteOrdinaryAsync( + Func> command, + CancellationToken cancellationToken) + { + await using MutationAdmissionLease lease = await _admission.AcquireOrdinaryAsync(cancellationToken).ConfigureAwait(false); + return await ExecuteAdmittedAsync(command, lease, cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteAdmittedAsync( + Func> command, + MutationAdmissionLease lease, CancellationToken cancellationToken) + { + _admission.EnsureActiveLease(lease); + using CancellationTokenSource waiting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lease.RevocationToken); + await _commandGate.WaitAsync(waiting.Token).ConfigureAwait(false); + try + { + _admission.EnsureActiveLease(lease); + waiting.Token.ThrowIfCancellationRequested(); + return await _generations.ExecuteAsync((context, generation, token) => + { + _ = RequireSession(context, generation); + return command(context, lease, token); + }, waiting.Token).ConfigureAwait(false); + } + finally + { + _commandGate.Release(); + } + } + + private static SettingsAuthoritySession RequireSession(SettingsGenerationContext context, DataGenerationDescriptor generation) => + context.Session.Generation.IsSameGeneration(generation) ? context.Session + : throw new InvalidOperationException("The resolved settings session does not belong to the pinned generation."); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs b/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs new file mode 100644 index 0000000..c15da8f --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs @@ -0,0 +1,52 @@ +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Provides generation-pinned snapshots and complete asynchronous desired/application commands to production consumers. +public interface ISettingsAuthority +{ + /// Captures current immutable state without storage I/O or retaining a repository reference. + SettingsAuthoritySnapshot CaptureSnapshot(); + + /// Opens the current initialized authority under ordinary admission. + /// Cancels acquisition and storage observation. + Task OpenAsync(CancellationToken cancellationToken); + + /// Commits a complete desired change set and verifies affected application batches under one generation pin. + /// Canonical typed desired changes copied before waiting. + /// Stable identity of the requested change set. + /// Cancels waiting and work before the desired publication boundary. + Task ApplyChangesAsync( + IEnumerable changes, Guid transactionId, CancellationToken cancellationToken); + + /// Performs the complete change and application using an existing caller-owned admission lease. + /// Canonical typed desired changes copied before waiting. + /// Stable identity of the change set. + /// Active lease retained by the caller until the complete command finishes. + /// Cancels work before durable desired publication. + Task ApplyChangesAdmittedAsync( + IEnumerable changes, Guid transactionId, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken); + + /// Reverts selected desired values to verified evidence or explicit safe fallbacks and applies affected work. + /// Canonical keys to revert as one change set. + /// Stable identity of the revert command. + /// Cancels work before publication. + Task RevertAsync(IEnumerable keys, Guid transactionId, CancellationToken cancellationToken); + + /// Explicitly retries a failed attempt under a fresh identity and verifies its effect. + /// Exact failed batch. + /// Identity of the failed attempt being replaced. + /// Fresh nonempty retry identity. + /// Cancels work before durable retry publication. + Task RetryAsync( + Guid batchId, Guid expectedAttemptId, Guid newAttemptId, CancellationToken cancellationToken); + + /// Reobserves previous-process evidence and reconciles startup work under exclusive admission. + /// Fresh nonempty startup identity. + /// Exclusive lease retained through startup application. + /// Cancels work before startup reconciliation is durable. + Task ReconcileStartupAdmittedAsync( + Guid startupId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs index 39ef0c7..db7ace3 100644 --- a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs @@ -15,13 +15,25 @@ public sealed partial class SettingsAuthoritySession /// Cancels waiting and work before running intent is durably acknowledged. public Task ApplyBatchAdmittedAsync( Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase, - MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) => + ApplyBatchEntryAsync(batchId, attemptId, participant, phase, admissionLease, honorRevocation: true, cancellationToken); + + /// Continues a facade-owned durable command while retaining its active lease across admission drain. + internal Task ContinueCommittedBatchAdmittedAsync( + Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase, + MutationAdmissionLease admissionLease) => + ApplyBatchEntryAsync(batchId, attemptId, participant, phase, admissionLease, honorRevocation: false, CancellationToken.None); + + private Task ApplyBatchEntryAsync( + Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase, + MutationAdmissionLease admissionLease, bool honorRevocation, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(participant); if (!Enum.IsDefined(phase)) { throw new ArgumentOutOfRangeException(nameof(phase)); } if (phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(admissionLease); } return ExecuteAdmittedAsync(admissionLease, - waiting => ApplyCoreAsync(batchId, attemptId, participant, phase, admissionLease, waiting), cancellationToken); + waiting => ApplyCoreAsync(batchId, attemptId, participant, phase, admissionLease, waiting), honorRevocation, cancellationToken); } private async Task ApplyCoreAsync( diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs index cb314e7..975b667 100644 --- a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs @@ -115,13 +115,18 @@ private Task EditAdmittedAsync( : await PersistEditAsync(read.Envelope!, edit(read.Envelope!), waiting).ConfigureAwait(false); }, token); - private async Task ExecuteAdmittedAsync( + private Task ExecuteAdmittedAsync( MutationAdmissionLease lease, Func> operation, + CancellationToken cancellationToken) => ExecuteAdmittedAsync(lease, operation, honorRevocation: true, cancellationToken); + + private async Task ExecuteAdmittedAsync( + MutationAdmissionLease lease, Func> operation, bool honorRevocation, CancellationToken cancellationToken) { ThrowIfClosing(); _admission.EnsureActiveLease(lease); - using CancellationTokenSource waiting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lease.RevocationToken); + using CancellationTokenSource waiting = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, honorRevocation ? lease.RevocationToken : CancellationToken.None); await _operationGate.WaitAsync(waiting.Token).ConfigureAwait(false); try { diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySnapshot.cs new file mode 100644 index 0000000..0b166a3 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySnapshot.cs @@ -0,0 +1,23 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Captures one immutable authority projection with its exact generation identity. +public sealed class SettingsAuthoritySnapshot +{ + /// Creates a projection from one verified generation and envelope. + /// Immutable storage owner of the captured envelope. + /// Complete immutable desired, applied, and pending state. + public SettingsAuthoritySnapshot(DataGenerationDescriptor generation, SettingsEnvelope envelope) + { + Generation = generation ?? throw new ArgumentNullException(nameof(generation)); + Envelope = envelope ?? throw new ArgumentNullException(nameof(envelope)); + } + + /// Gets the captured generation identity. + public DataGenerationDescriptor Generation { get; } + + /// Gets the complete immutable settings state. + public SettingsEnvelope Envelope { get; } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsGenerationContext.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsGenerationContext.cs new file mode 100644 index 0000000..87c26df --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsGenerationContext.cs @@ -0,0 +1,35 @@ +using System.Collections.ObjectModel; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Groups the settings session and runtime adapters resolved from one generation-owned service container. +/// The containing service lifetime owns disposal; participant constructors must not read storage or start effects. +public sealed class SettingsGenerationContext +{ + /// Creates an immutable generation-local participant catalog. + /// Settings authority session owned by the same service container. + /// At most one runtime adapter for each declared application kind. + public SettingsGenerationContext(SettingsAuthoritySession session, IEnumerable participants) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + ArgumentNullException.ThrowIfNull(participants); + Dictionary catalog = []; + foreach (ISettingsApplicationParticipant participant in participants) + { + if (participant is null || !Enum.IsDefined(participant.ApplicationKind) + || !catalog.TryAdd(participant.ApplicationKind, participant)) + { + throw new ArgumentException("Settings participants must be non-null and unique by declared application kind.", nameof(participants)); + } + } + + Participants = new ReadOnlyDictionary(catalog); + } + + /// Gets the generation's sole settings session. + public SettingsAuthoritySession Session { get; } + + /// Gets the immutable catalog used for generation-local runtime application. + public IReadOnlyDictionary Participants { get; } +} diff --git a/ClashSharp/ClashSharp.Tests/Integration/GenerationSettingsAuthorityTests.cs b/ClashSharp/ClashSharp.Tests/Integration/GenerationSettingsAuthorityTests.cs new file mode 100644 index 0000000..30de8bb --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/GenerationSettingsAuthorityTests.cs @@ -0,0 +1,310 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Settings; +using ClashSharp.Tests.Unit.Settings; + +namespace ClashSharp.Tests.Integration; + +/// Checks complete consumer commands through actual generation and settings stores. +public sealed class GenerationSettingsAuthorityTests +{ + [Fact] + public async Task CompleteChangeSet_VerifiesAllAffectedParticipantsWithinOneAuthorityCommand() + { + await using Fixture fixture = await Fixture.CreateAsync(); + SettingsAuthorityResult result = await fixture.Authority.ApplyChangesAsync( + [Change("MixedPort", "7890"), Change("AppThemeMode", "Dark")], Guid.NewGuid(), CancellationToken.None); + Assert.True(result.IsSucceeded, result.Code); + Assert.Empty(result.Envelope!.PendingApplications); + Assert.Equal("7890", result.Envelope.Applied[SettingsRegistry.Keys.MixedPort].Value!.CanonicalText); + Assert.Equal("Dark", result.Envelope.Applied[SettingsRegistry.Keys.AppThemeMode].Value!.CanonicalText); + Assert.Equal(1, fixture.Participants[SettingApplicationKind.Network].Applies); + Assert.Equal(1, fixture.Participants[SettingApplicationKind.Appearance].Applies); + Assert.Equal(fixture.Manifest.Descriptor.GenerationId, fixture.Authority.CaptureSnapshot().Generation.GenerationId); + Assert.Equal(Hash(result.Envelope), Hash((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!)); + } + + [Fact] + public async Task DrainAfterDesiredCommit_DoesNotAbandonTheCommittedApplication() + { + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(15)); + PublicationHook hook = new(); + await using Fixture fixture = await Fixture.CreateAsync(hook); + Task? drain = null; + hook.AfterFirstPublication = () => + { + drain = fixture.Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, deadline.Token).AsTask(); + Assert.False(drain.IsCompleted); + }; + + try + { + SettingsAuthorityResult result = await fixture.Authority.ApplyChangesAsync( + [Change("MixedPort", "7890"), Change("AppThemeMode", "Dark")], Guid.NewGuid(), deadline.Token); + Assert.True(result.IsSucceeded, result.Code); + Assert.Empty(result.Envelope!.PendingApplications); + Assert.Equal(1, fixture.Participants[SettingApplicationKind.Network].Applies); + Assert.Equal(1, fixture.Participants[SettingApplicationKind.Appearance].Applies); + } + finally + { + if (drain is not null) { await using MutationAdmissionLease lease = await drain; } + } + } + + [Fact] + public async Task PageCancellationAfterDesiredCommit_StillObservesTheCompleteCommand() + { + using CancellationTokenSource caller = new(); + PublicationHook hook = new(); + await using Fixture fixture = await Fixture.CreateAsync(hook); + hook.AfterFirstPublication = caller.Cancel; + SettingsAuthorityResult result = await fixture.Authority.ApplyChangesAsync([Change("MixedPort", "7890")], Guid.NewGuid(), caller.Token); + Assert.True(caller.IsCancellationRequested); + Assert.True(result.IsSucceeded, result.Code); + Assert.Empty(result.Envelope!.PendingApplications); + Assert.Equal("7890", result.Envelope.Applied[SettingsRegistry.Keys.MixedPort].Value!.CanonicalText); + } + + [Fact] + public async Task FailedLaterParticipant_PreservesEarlierEvidenceAndRetryTouchesOnlyTheFailedAttempt() + { + await using Fixture fixture = await Fixture.CreateAsync(); + fixture.Participants[SettingApplicationKind.Network].IgnoreEffects = true; + SettingsAuthorityResult failed = await fixture.Authority.ApplyChangesAsync( + [Change("AppThemeMode", "Dark"), Change("MixedPort", "7890")], Guid.NewGuid(), CancellationToken.None); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, failed.Status); + SettingsApplicationBatch batch = Assert.Single(failed.Envelope!.PendingApplications); + Assert.Equal(SettingApplicationKind.Network, batch.ApplicationKind); + Assert.Equal("Dark", failed.Envelope.Applied[SettingsRegistry.Keys.AppThemeMode].Value!.CanonicalText); + Assert.Equal(SettingAppliedStateKind.Unknown, failed.Envelope.Applied[SettingsRegistry.Keys.MixedPort].Kind); + fixture.Participants[SettingApplicationKind.Network].IgnoreEffects = false; + SettingsAuthorityResult retried = await fixture.Authority.RetryAsync(batch.BatchId, batch.AttemptId, Guid.NewGuid(), CancellationToken.None); + Assert.True(retried.IsSucceeded, retried.Code); + Assert.Empty(retried.Envelope!.PendingApplications); + Assert.Equal(1, fixture.Participants[SettingApplicationKind.Appearance].Applies); + Assert.Equal(2, fixture.Participants[SettingApplicationKind.Network].Applies); + } + + [Fact] + public async Task QueuedCallerChanges_AreCopiedBeforeTheFirstAsynchronousWait() + { + await using Fixture fixture = await Fixture.CreateAsync(); + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(15)); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Participants[SettingApplicationKind.Network].BeforeApply = async () => + { + entered.TrySetResult(); + await release.Task.WaitAsync(deadline.Token); + }; + Task first = fixture.Authority.ApplyChangesAsync([Change("MixedPort", "7890")], Guid.NewGuid(), deadline.Token); + Task? second = null; + try + { + await entered.Task.WaitAsync(deadline.Token); + List input = [Change("MixedPort", "10001")]; + second = fixture.Authority.ApplyChangesAsync(input, Guid.NewGuid(), deadline.Token); + input[0] = Change("MixedPort", "10002"); + Assert.False(second.IsCompleted); + } + finally + { + release.TrySetResult(); + await first; + if (second is not null) { await second; } + } + + Assert.True((await second!).IsSucceeded); + Assert.Equal("10001", fixture.Authority.CaptureSnapshot().Envelope.Applied[SettingsRegistry.Keys.MixedPort].Value!.CanonicalText); + } + + [Fact] + public async Task GenerationDrain_WaitsUntilTheRuntimeEffectAndFinalSaveAreBothFinished() + { + await using Fixture fixture = await Fixture.CreateAsync(); + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(15)); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Participants[SettingApplicationKind.Network].BeforeApply = async () => + { + entered.TrySetResult(); + await release.Task.WaitAsync(deadline.Token); + }; + Task command = fixture.Authority.ApplyChangesAsync([Change("MixedPort", "7890")], Guid.NewGuid(), deadline.Token); + Task? drain = null; + try + { + await entered.Task.WaitAsync(deadline.Token); + drain = fixture.Generations.BeginDrainAsync(fixture.Manifest.ContentHash, deadline.Token).AsTask(); + Assert.False(drain.IsCompleted); + Assert.Throws(() => fixture.Authority.CaptureSnapshot()); + } + finally + { + release.TrySetResult(); + await command; + } + + Assert.True((await command).IsSucceeded); + DataGenerationTransition transition = await drain!; + SettingsEnvelope durable = (await fixture.Repository.OpenAsync(deadline.Token)).Envelope!; + Assert.Empty(durable.PendingApplications); + Assert.Equal("7890", durable.Applied[SettingsRegistry.Keys.MixedPort].Value!.CanonicalText); + await transition.AbortAsync(); + } + + [Fact] + public async Task QueuedCommand_IsRevokedWithoutChangingTheCommittedTargetDuringDrain() + { + await using Fixture fixture = await Fixture.CreateAsync(); + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(15)); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Participants[SettingApplicationKind.Network].BeforeApply = async () => + { + entered.TrySetResult(); + await release.Task.WaitAsync(deadline.Token); + }; + Task first = fixture.Authority.ApplyChangesAsync([Change("MixedPort", "7890")], Guid.NewGuid(), deadline.Token); + Task? drain = null; + try + { + await entered.Task.WaitAsync(deadline.Token); + Task queued = fixture.Authority.ApplyChangesAsync([Change("MixedPort", "10001")], Guid.NewGuid(), deadline.Token); + Assert.False(queued.IsCompleted); + drain = fixture.Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, deadline.Token).AsTask(); + await Assert.ThrowsAnyAsync(() => queued); + Assert.False(drain.IsCompleted); + } + finally + { + release.TrySetResult(); + await first; + if (drain is not null) { await using MutationAdmissionLease lease = await drain; } + } + + Assert.True((await first).IsSucceeded); + Assert.Equal("7890", fixture.Authority.CaptureSnapshot().Envelope.Desired[SettingsRegistry.Keys.MixedPort].Value.CanonicalText); + Assert.Equal(1, fixture.Participants[SettingApplicationKind.Network].Applies); + } + + [Fact] + public async Task MisboundGenerationService_IsRejectedBeforeItCanOpenTheForeignRepository() + { + await using Fixture fixture = await Fixture.CreateAsync(misbind: true); + string foreignSettings = Path.Combine(fixture.Session.Generation.RootPath, "Settings"); + Assert.False(Directory.Exists(foreignSettings)); + await Assert.ThrowsAsync(() => fixture.Authority.OpenAsync(CancellationToken.None)); + Assert.Throws(() => fixture.Authority.CaptureSnapshot()); + Assert.False(Directory.Exists(foreignSettings)); + } + + [Fact] + public async Task Startup_ReobservesAllValuesThroughTheGenerationLocalParticipants() + { + await using Fixture fixture = await Fixture.CreateAsync(); + await using MutationAdmissionLease startup = await fixture.Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, CancellationToken.None); + SettingsAuthorityResult result = await fixture.Authority.ReconcileStartupAdmittedAsync(Guid.NewGuid(), startup, CancellationToken.None); + Assert.True(result.IsSucceeded, result.Code); + Assert.Empty(result.Envelope!.PendingApplications); + Assert.All(result.Envelope.Applied.Values, state => Assert.Equal(SettingAppliedValueSource.StartupReconciliation, state.Source)); + Assert.All(fixture.Participants.Values, participant => + { + Assert.Equal(0, participant.Applies); + Assert.Equal(1, participant.Probes); + }); + } + + private static SettingValueChange Change(string key, string value) => new(new(key), SettingsEnvelopeTestData.Value(key, value)); + private static string Hash(SettingsEnvelope value) => SettingsEnvelopeCodec.Encode(value, SettingsRegistry.Default).ContentHash; + + private sealed class Fixture : IAsyncDisposable, IServiceProvider + { + private readonly DataGenerationTestDirectory _directory = new(); + private SettingsGenerationContext? _context; + public DataGenerationManifestSnapshot Manifest { get; private set; } = null!; + public SettingsAuthoritySession Session { get; private set; } = null!; + public JsonSettingsRepository Repository { get; private set; } = null!; + public MutationAdmissionBarrier Admission { get; } = new(); + public DataGenerationManager Generations { get; } = new(); + public GenerationSettingsAuthority Authority { get; private set; } = null!; + public Dictionary Participants { get; } = Enum.GetValues().ToDictionary(kind => kind, kind => new Participant(kind)); + public object? GetService(Type serviceType) => serviceType == typeof(SettingsGenerationContext) ? _context : null; + + public static async Task CreateAsync(PublicationHook? hook = null, bool misbind = false) + { + Fixture fixture = new(); + try + { + fixture.Manifest = await fixture._directory.PromoteFirstAsync(); + JsonSettingsRepository seed = new(fixture.Manifest.Descriptor, SettingsRegistry.Default); + Assert.True((await seed.SaveAsync(SettingsEnvelopeTestData.CreateMatchingEnvelope(), 0, CancellationToken.None)).IsSucceeded); + fixture.Repository = new(fixture.Manifest.Descriptor, SettingsRegistry.Default, hook); + ISettingsRepository repository = misbind + ? new JsonSettingsRepository(fixture._directory.CreateGeneration(2), SettingsRegistry.Default) : fixture.Repository; + fixture.Session = new(repository, SettingsRegistry.Default, fixture.Admission); + fixture._context = new(fixture.Session, fixture.Participants.Values); + fixture.Generations.Initialize(fixture.Manifest, new(fixture.Manifest.Descriptor, new Lifetime(fixture))); + fixture.Authority = new(fixture.Generations, fixture.Admission); + return fixture; + } + catch + { + await fixture.DisposeAsync(); + throw; + } + } + + public async ValueTask DisposeAsync() + { + await Generations.DisposeAsync(); + await _directory.DisposeAsync(); + } + + private sealed class Lifetime(Fixture fixture) : IServiceProvider, IAsyncDisposable + { + public object? GetService(Type serviceType) => fixture.GetService(serviceType); + public ValueTask DisposeAsync() => fixture.Session.DisposeAsync(); + } + } + + private sealed class Participant(SettingApplicationKind kind) : ISettingsApplicationParticipant + { + private readonly Dictionary _values = SettingsRegistry.Default.Definitions.ToDictionary(definition => definition.Key, definition => definition.DefaultValue); + public SettingApplicationKind ApplicationKind => kind; + public int Applies { get; private set; } + public int Probes { get; private set; } + public bool IgnoreEffects { get; set; } + public Func? BeforeApply { get; set; } + public Task ProbeAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ++Probes; + return Task.FromResult(new SettingsApplicationObservation(request.Generation, request.Batch.BatchId, request.Batch.AttemptId, + request.Values.Keys.Select(key => new SettingValueChange(key, _values[key])))); + } + + public async Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ++Applies; + if (BeforeApply is not null) { await BeforeApply(); } + if (!IgnoreEffects) + { + foreach ((SettingKey key, SettingValue value) in request.Values) { _values[key] = value; } + } + } + } + + private sealed class PublicationHook : ISettingsPersistenceFaultInjector + { + private int _publications; + public Action? AfterFirstPublication { get; set; } + public Task InjectAsync(SettingsPersistenceFaultPoint point, CancellationToken cancellationToken) + { + if (point == SettingsPersistenceFaultPoint.AfterEnvelopePromotion && ++_publications == 1) { AfterFirstPublication?.Invoke(); } + return Task.CompletedTask; + } + } +} diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index 93960f9..1e06c60 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -1,6 +1,6 @@ # Settings generation cutover -版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转和代际内服务访问;生产 composition 仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 +版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问及公共异步入口;生产 composition 仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 ## 已实现的存储与迁移 @@ -22,19 +22,31 @@ `PrepareStartupAdmittedAsync` 需要独占许可,撤销上一进程留下的 verified applied,创建必要的观察待办。已有 Running 和 Failed 身份继续保留;明确阻止的外部探测保留其处理方式。Restart 批次在 Live 阶段保持待处理,必须由独占启动阶段应用。 +## 公共异步入口 + +`ISettingsAuthority` 与 `GenerationSettingsAuthority` 为页面、磁贴和触发器提供同一条完整命令路径:进入许可、解析当前代际的 `SettingsGenerationContext`、提交完整 desired、依次验证受影响的批次,然后释放代际租约和许可。上下文中的 session 必须匹配租约的完整 descriptor,错误装配在打开其他代际仓库之前被拒绝。快照返回不可变 envelope 和 generation identity,调用者不持有活仓库引用。 + +复原偏好、失败重试和启动重新观察也经过该入口。多个参与者中后续失败时,前面已验证的证据保留;重试只处理具有新 attempt identity 的失败批次。排队命令在开始前复制输入,外部集合随后变化不会更改已提交的命令内容。 + +新增回归复现了完整命令的一个衔接缺口:desired 已经提交后,退出开始排空并撤销等待许可,原 session 的普通入口会取消随后的运行时应用。现在 facade 使用内部的已提交命令续行路径,继续验证原许可的有效性,持有原代际,完成全部参与者和保存;后续排队命令仍受撤销控制。直接调用 session 的普通批次入口继续遵守原有 Running 提交前取消规则。 + ## 代际服务寿命 `DataGenerationManager.ExecuteAsync` 在取得代际租约后,从该代际拥有的 `IServiceProvider` 解析服务,并等待完整操作结束才释放租约。`ReadSnapshot` 仅用于同步、无 I/O 的不可变内存快照,不阻塞异步任务。服务容器的异步释放仍由 `DataGenerationScope` 的原生命周期协议负责。 真实 JSON 仓库与 manifest 集成回归确认:进行中的设置操作阻止切换;提交后只解析新会话,旧会话拒绝写入;回滚恢复原会话并释放候选会话;解析或操作失败释放租约。生产 profile/log/trigger 的容器装配及导入、重置切换尚待完成。 -## 本地验证 +## 本地验证与 CI - 18 项目 Release x64 完整构建通过,零警告、零错误,用时 26.33 秒。 - 主程序 2705 项通过,零失败、零跳过,用时 49 秒;本分支新增 84 项回归。 - 完整 format 加载 1459 个文件,最终检查零处变更。 - 记录保存在 `artifacts/verification/1.0.0-settings-generation-main.trx`、`build-settings-generation-complete.log`、`format-settings-generation-verified.log` 和 `local-validation-settings-generation-foundation.json`。 +基础提交 `2775afe` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34211372829)。实际下载并核验四份 TRX,共 4770 项通过、零失败、零跳过,其中新增 84 项均实际执行。PR 合并提交为 `8febfaf`,其 tree 与 `2775afe` 相同,摘要为 `d325bf6e849ec6edc13602388ae2b8860bc901f4`;收据为 `ci-validation-settings-generation-foundation.json` 和 `ci-settings-generation-foundation-regressions.json`。开发包构建成功,完整生产切换后的候选再执行原生验收。 + +公共入口追加 9 项回归后,主程序 2714 项全部通过,零失败、零跳过,用时 50 秒;18 项目完整构建零警告、零错误,用时 26.51 秒,format 检查 1464 个文件、零处变更。本分支累计新增 93 项回归。收据为 `local-validation-settings-generation-facade.json`、`1.0.0-settings-facade-main.trx` 及同前缀的构建、格式日志。首次红测有一项许可撤销问题和一项夹具对预建代际目录的错误假设,均保留原报告;错误装配验证以 Settings 目录未创建为实际边界。 + 持久中断测试使用真实临时仓库、切点注入及新对象重开,运行时参与者为受控模拟。Windows 旧设置适配器已编译,未在开发机读取实际 LocalSettings。实际打包应用的迁移、进程崩溃、完整页面和安装器兼容验收将在生产切换后执行。开发机代理摘要保持 `95e97918ff6de70655b412568cd18dc81c5d6584c607bb9a71ddc72e22460447`。 ## 完整切换的剩余依赖 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index dc504c7..99180a0 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -18,6 +18,8 @@ - `feat/settings-generation` 已实现规范化旧偏好迁移、异步设置会话、Running/Failed/verified 流转、启动重新观察及代际内服务解析。隔离损坏文件后的错误初始化先由两项回归复现,再修复。 - 新增 84 项回归;主程序 2705 项全部通过,零失败、零跳过。18 项目 Release x64 构建零警告、零错误;完整 format 检查 1459 个文件、0 处变更。收据为 `local-validation-settings-generation-foundation.json`。 +- 基础提交 `2775afe` 已推送开发分支并建立[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5),[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34211372829),实际四份 TRX 共 4770 项通过,新增 84 项均执行。合并提交 `8febfaf` 的 tree 与该基础提交相同,收据为 `ci-validation-settings-generation-foundation.json`。 +- 公共异步入口继续持有同一个代际,覆盖 desired 提交、全部受影响批次及验证;修复提交后开始退出排空会截断后续应用的衔接问题。追加 9 项回归,完整主程序 2714 项通过,构建零警告、零错误,format 检查 1464 个文件、0 处变更;本分支累计新增 93 项,收据为 `local-validation-settings-generation-facade.json`。 - 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。完整接入及验收继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 - main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 From 37f4c511ec888a4853745acff865d699c6a3ce05 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 18:44:25 +0800 Subject: [PATCH 03/22] feat: verify startup and sampling settings against runtime state --- .../ClashSharp.Tests/ClashSharp.Tests.csproj | 4 + .../SettingsRuntimeParticipantTests.cs | 427 ++++++++++++++++++ .../Settings/SamplingSettingsParticipant.cs | 46 ++ .../Settings/SettingsParticipantBinding.cs | 44 ++ .../StartupTaskSettingsParticipant.cs | 46 ++ ...ConnectionSamplingService.Configuration.cs | 44 ++ .../Service/ConnectionSamplingService.cs | 65 ++- .../Service/StartupLaunchService.cs | 6 +- .../2026-09-08-settings-generation-cutover.md | 18 +- docs/reviews/1.0.0-execution-ledger.md | 2 + 10 files changed, 677 insertions(+), 25 deletions(-) create mode 100644 ClashSharp/ClashSharp.Tests/Integration/SettingsRuntimeParticipantTests.cs create mode 100644 ClashSharp/ClashSharp/AppHost/Settings/SamplingSettingsParticipant.cs create mode 100644 ClashSharp/ClashSharp/AppHost/Settings/SettingsParticipantBinding.cs create mode 100644 ClashSharp/ClashSharp/AppHost/Settings/StartupTaskSettingsParticipant.cs create mode 100644 ClashSharp/ClashSharp/Service/ConnectionSamplingService.Configuration.cs diff --git a/ClashSharp/ClashSharp.Tests/ClashSharp.Tests.csproj b/ClashSharp/ClashSharp.Tests/ClashSharp.Tests.csproj index 7faf0fc..15c63d1 100644 --- a/ClashSharp/ClashSharp.Tests/ClashSharp.Tests.csproj +++ b/ClashSharp/ClashSharp.Tests/ClashSharp.Tests.csproj @@ -88,6 +88,7 @@ + @@ -160,6 +161,9 @@ + + + diff --git a/ClashSharp/ClashSharp.Tests/Integration/SettingsRuntimeParticipantTests.cs b/ClashSharp/ClashSharp.Tests/Integration/SettingsRuntimeParticipantTests.cs new file mode 100644 index 0000000..5605639 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/SettingsRuntimeParticipantTests.cs @@ -0,0 +1,427 @@ +using System.Threading.Channels; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.ApplicationModel.Supervision; +using ClashSharp.Hosting.Settings; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Model; +using ClashSharp.Service; +using ClashSharp.Settings; +using ClashSharp.Tests.Unit.Settings; + +namespace ClashSharp.Tests.Integration; + +/// Exercises actual runtime adapters, the sampling supervisor, and durable settings with isolated platform ports. +public sealed class SettingsRuntimeParticipantTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StartupChange_ProbesWindowsAndPersistsOnlyTheVerifiedRegistration(bool target) + { + await using Fixture fixture = await Fixture.CreateAsync(("LaunchAtStartupEnabled", target ? "true" : "false")); + fixture.Windows.State = target ? StartupLaunchTaskState.Disabled : StartupLaunchTaskState.Enabled; + SettingsAuthorityResult result = await fixture.ApplyAsync(fixture.Startup); + Assert.True(result.IsSucceeded, result.Code); + Assert.Empty(result.Envelope!.PendingApplications); + Assert.Equal(target, result.Envelope.Applied[SettingsRegistry.Keys.LaunchAtStartupEnabled].Value!.Get()); + Assert.Equal(1, fixture.Windows.Mutations); + Assert.True(fixture.Windows.Reads >= 4); + Assert.Equal(target ? StartupLaunchTaskState.Enabled : StartupLaunchTaskState.Disabled, fixture.Windows.State); + } + + [Fact] + public async Task StartupAlreadyApplied_DoesNotRepeatTheWindowsMutation() + { + await using Fixture fixture = await Fixture.CreateAsync(("LaunchAtStartupEnabled", "true")); + fixture.Windows.State = StartupLaunchTaskState.Enabled; + SettingsAuthorityResult result = await fixture.ApplyAsync(fixture.Startup); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal(0, fixture.Windows.Mutations); + Assert.Equal(SettingAppliedValueSource.RuntimeProbe, + result.Envelope!.Applied[SettingsRegistry.Keys.LaunchAtStartupEnabled].Source); + } + + [Fact] + public async Task StartupDenied_RetainsFailedIntentUntilAnExplicitRetryIsVerified() + { + await using Fixture fixture = await Fixture.CreateAsync(("LaunchAtStartupEnabled", "true")); + fixture.Windows.DenyEnable = true; + SettingsAuthorityResult failed = await fixture.ApplyAsync(fixture.Startup); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, failed.Status); + Assert.Equal(SettingAppliedStateKind.Unknown, failed.Envelope!.Applied[SettingsRegistry.Keys.LaunchAtStartupEnabled].Kind); + SettingsApplicationBatch batch = Assert.Single(failed.Envelope.PendingApplications); + Assert.Equal(SettingsApplicationBatchState.Failed, batch.State); + Assert.True((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.Desired[SettingsRegistry.Keys.LaunchAtStartupEnabled].Value.Get()); + + fixture.Windows.DenyEnable = false; + using (MutationAdmissionLease lease = fixture.Admission.AcquireOrdinary()) + { + Assert.True((await fixture.Session.RetryAdmittedAsync(batch.BatchId, batch.AttemptId, Guid.NewGuid(), lease, CancellationToken.None)).IsSucceeded); + } + + Assert.True((await fixture.ApplyAsync(fixture.Startup)).IsSucceeded); + Assert.Equal(2, fixture.Windows.Mutations); + Assert.Equal(StartupLaunchTaskState.Enabled, fixture.Windows.State); + } + + [Fact] + public async Task StartupUnknown_StopsBeforeAttemptingToChangeWindows() + { + await using Fixture fixture = await Fixture.CreateAsync(("LaunchAtStartupEnabled", "true")); + fixture.Windows.State = StartupLaunchTaskState.Other; + SettingsAuthorityResult result = await fixture.ApplyAsync(fixture.Startup); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, result.Status); + Assert.Equal("settings.application.probe_failed", result.Code); + Assert.Equal(0, fixture.Windows.Mutations); + } + + [Fact] + public async Task StartupReplyLost_IsResolvedByASeparatePlatformObservation() + { + await using Fixture fixture = await Fixture.CreateAsync(("LaunchAtStartupEnabled", "true")); + fixture.Windows.LoseEnableReply = true; + SettingsAuthorityResult result = await fixture.ApplyAsync(fixture.Startup); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal("settings.application.reply_lost_resolved", result.Code); + Assert.Empty(result.Envelope!.PendingApplications); + Assert.Equal(1, fixture.Windows.Mutations); + Assert.Equal(SettingAppliedValueSource.RuntimeProbe, + result.Envelope.Applied[SettingsRegistry.Keys.LaunchAtStartupEnabled].Source); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StartupFatalExceptionGraph_EscapesWithoutClaimingAnOrdinaryFailure(bool duringApply) + { + await using Fixture fixture = await Fixture.CreateAsync(("LaunchAtStartupEnabled", "true")); + InvalidOperationException fatal = new("Platform wrapper.", new AggregateException(Activator.CreateInstance())); + if (duringApply) { fixture.Windows.EnableFailure = fatal; } + else { fixture.Windows.ReadFailure = fatal; } + Exception observed = await Assert.ThrowsAsync(() => fixture.ApplyAsync(fixture.Startup)); + Assert.Same(fatal, observed); + SettingsEnvelope durable = (await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!; + Assert.Equal(SettingsApplicationBatchState.Running, Assert.Single(durable.PendingApplications).State); + Assert.Equal(SettingAppliedStateKind.Unknown, durable.Applied[SettingsRegistry.Keys.LaunchAtStartupEnabled].Kind); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ForeignGenerationOrAdmission_IsRejectedBeforeRuntimeAccess(bool sampling) + { + await using Fixture fixture = await Fixture.CreateAsync(sampling + ? [("ConnectionSamplingEnabled", "true"), ("ConnectionSamplingIntervalSeconds", "60")] + : [("LaunchAtStartupEnabled", "true")]); + ISettingsApplicationParticipant participant = sampling ? fixture.Sampling : fixture.Startup; + Capture capture = new(participant); + Assert.True((await fixture.ApplyAsync(capture)).IsSucceeded); + SettingsApplicationRequest request = Assert.IsType(capture.Request); + int reads = fixture.Windows.Reads; + int mutations = fixture.Windows.Mutations; + using MutationAdmissionLease foreign = new MutationAdmissionBarrier().AcquireOrdinary(); + await Assert.ThrowsAsync(() => participant.ProbeAsync(request, foreign, CancellationToken.None)); + await Assert.ThrowsAsync(() => participant.ApplyAsync(request, foreign, CancellationToken.None)); + + ISettingsApplicationParticipant misbound = sampling + ? new SamplingSettingsParticipant(fixture.Directory.CreateGeneration(2), fixture.Admission, fixture.Loop) + : new StartupTaskSettingsParticipant(fixture.Directory.CreateGeneration(2), fixture.Admission, fixture.StartupService); + using MutationAdmissionLease own = fixture.Admission.AcquireOrdinary(); + await Assert.ThrowsAsync(() => misbound.ProbeAsync(request, own, CancellationToken.None)); + await Assert.ThrowsAsync(() => misbound.ApplyAsync(request, own, CancellationToken.None)); + Assert.Equal(reads, fixture.Windows.Reads); + Assert.Equal(mutations, fixture.Windows.Mutations); + Assert.Equal(sampling, fixture.Loop.IsRunning); + } + + [Fact] + public async Task SamplingPair_ControlsTheActualLoopWithoutReadingLegacyPreferences() + { + await using Fixture fixture = await Fixture.CreateAsync( + ("ConnectionSamplingEnabled", "true"), ("ConnectionSamplingIntervalSeconds", "60")); + SettingsAuthorityResult result = await fixture.ApplyAsync(fixture.Sampling); + Assert.True(result.IsSucceeded, result.Code); + Assert.Empty(result.Envelope!.PendingApplications); + Assert.Equal(new(true, 60), await fixture.Loop.ReadConfigurationAsync(CancellationToken.None)); + Assert.Equal(TimeSpan.FromSeconds(60), (await fixture.Clock.ReadAsync()).Duration); + Assert.True(result.Envelope.Applied[SettingsRegistry.Keys.ConnectionSamplingEnabled].Value!.Get()); + Assert.Equal(60, result.Envelope.Applied[SettingsRegistry.Keys.ConnectionSamplingIntervalSeconds].Value!.Get()); + } + + [Fact] + public async Task SamplingDisableThenSingleKeyEnable_UsesTheCompanionIntervalAndRemainsRestartable() + { + await using Fixture fixture = await Fixture.CreateAsync( + ("ConnectionSamplingEnabled", "false"), ("ConnectionSamplingIntervalSeconds", "90")); + await fixture.Loop.ApplyConfigurationAsync(new(true, 30), CancellationToken.None); + _ = await fixture.Clock.ReadAsync(); + Assert.True((await fixture.ApplyAsync(fixture.Sampling)).IsSucceeded); + Assert.Equal(new(false, 90), await fixture.Loop.ReadConfigurationAsync(CancellationToken.None)); + using (MutationAdmissionLease lease = fixture.Admission.AcquireOrdinary()) + { + SettingsAuthorityResult desired = await fixture.Session.ChangeAdmittedAsync( + [new(SettingsRegistry.Keys.ConnectionSamplingEnabled, SettingsEnvelopeTestData.Value("ConnectionSamplingEnabled", "true"))], + Guid.NewGuid(), lease, CancellationToken.None); + Assert.True(desired.IsSucceeded); + Assert.Single(Assert.Single(desired.Envelope!.PendingApplications).Entries); + } + + Assert.True((await fixture.ApplyAsync(fixture.Sampling)).IsSucceeded); + Assert.Equal(TimeSpan.FromSeconds(90), (await fixture.Clock.ReadAsync()).Duration); + var prior = await fixture.Loop.QuiesceAsync(CancellationToken.None); + await fixture.Loop.ResumeAsync(prior, CancellationToken.None); + Assert.Equal(new(true, 90), await fixture.Loop.ReadConfigurationAsync(CancellationToken.None)); + Assert.Equal(TimeSpan.FromSeconds(90), (await fixture.Clock.ReadAsync()).Duration); + } + + [Fact] + public async Task SamplingInFlight_OwnsDrainAndFinalPublicationDespiteCancellationAndAdmissionClosure() + { + await using Fixture fixture = await Fixture.CreateAsync( + ("ConnectionSamplingEnabled", "false"), ("ConnectionSamplingIntervalSeconds", "90")); + using CancellationTokenSource caller = new(); + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(15)); + fixture.Source.Block = true; + await fixture.Loop.ApplyConfigurationAsync(new(true, 30), CancellationToken.None); + (await fixture.Clock.ReadAsync()).Complete(); + Task? apply = null; + Task? drain = null; + try + { + await fixture.Source.Entered.Task.WaitAsync(deadline.Token); + apply = fixture.ApplyAsync(fixture.Sampling, caller.Token); + await fixture.Source.CancellationObserved.Task.WaitAsync(deadline.Token); + caller.Cancel(); + drain = fixture.Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, deadline.Token).AsTask(); + Assert.False(apply.IsCompleted); + Assert.False(drain.IsCompleted); + SettingsEnvelope during = (await fixture.Repository.OpenAsync(deadline.Token)).Envelope!; + Assert.Equal(SettingsApplicationBatchState.Running, Assert.Single(during.PendingApplications).State); + Assert.Equal(SettingAppliedStateKind.Unknown, during.Applied[SettingsRegistry.Keys.ConnectionSamplingEnabled].Kind); + } + finally + { + fixture.Source.Release.TrySetResult(); + try { if (apply is not null) { await apply; } } + finally { if (drain is not null) { await using MutationAdmissionLease lease = await drain; } } + } + + Assert.True((await apply!).IsSucceeded); + Assert.Equal(new(false, 90), await fixture.Loop.ReadConfigurationAsync(CancellationToken.None)); + Assert.Empty((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.PendingApplications); + } + + [Fact] + public async Task SamplingTerminalStop_CannotTurnPersistedEnableIntentIntoFalseSuccess() + { + await using Fixture fixture = await Fixture.CreateAsync( + ("ConnectionSamplingEnabled", "true"), ("ConnectionSamplingIntervalSeconds", "60")); + await fixture.Loop.StopAsync(CancellationToken.None); + SettingsAuthorityResult result = await fixture.ApplyAsync(fixture.Sampling); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, result.Status); + Assert.Equal("settings.application.verification_failed", result.Code); + Assert.Equal(SettingsApplicationBatchState.Failed, Assert.Single(result.Envelope!.PendingApplications).State); + Assert.Equal(SettingAppliedStateKind.Unknown, result.Envelope.Applied[SettingsRegistry.Keys.ConnectionSamplingEnabled].Kind); + Assert.False(fixture.Loop.IsRunning); + } + + [Fact] + public async Task DesiredInterval_RemainsSeparateFromTheScheduleUntilApplicationCompletes() + { + await using Fixture fixture = await Fixture.CreateAsync(("ConnectionSamplingIntervalSeconds", "120")); + await fixture.Loop.ApplyConfigurationAsync(new(true, 30), CancellationToken.None); + Delay original = await fixture.Clock.ReadAsync(); + original.Complete(); + Assert.Equal(TimeSpan.FromSeconds(30), (await fixture.Clock.ReadAsync()).Duration); + Assert.Equal(new(true, 30), await fixture.Loop.ReadConfigurationAsync(CancellationToken.None)); + Assert.Equal(120, (await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope! + .Desired[SettingsRegistry.Keys.ConnectionSamplingIntervalSeconds].Value.Get()); + Assert.True((await fixture.ApplyAsync(fixture.Sampling)).IsSucceeded); + Assert.Equal(TimeSpan.FromSeconds(120), (await fixture.Clock.ReadAsync()).Duration); + } + + [Fact] + public async Task LegacyPreferenceChange_DoesNotReplaceAnActiveScheduleUntilTheOwnedRestart() + { + MutablePreferences preferences = new(); + Clock clock = new(); + ConnectionSamplingService loop = new(preferences, new Source(), new Storage(), key => key, clock); + try + { + await loop.StartAsync(CancellationToken.None); + Delay original = await clock.ReadAsync(); + Assert.Equal(TimeSpan.FromSeconds(30), original.Duration); + preferences.IntervalSeconds = 120; + original.Complete(); + Assert.Equal(TimeSpan.FromSeconds(30), (await clock.ReadAsync()).Duration); + await loop.RestartFromSettingsAsync(CancellationToken.None); + Assert.Equal(TimeSpan.FromSeconds(120), (await clock.ReadAsync()).Duration); + } + finally { await loop.StopAsync(CancellationToken.None); } + } + + private sealed class Fixture : IAsyncDisposable + { + public DataGenerationTestDirectory Directory { get; } = new(); + public MutationAdmissionBarrier Admission { get; } = new(); + public WindowsTask Windows { get; } = new(); + public Clock Clock { get; } = new(); + public Source Source { get; } = new(); + public JsonSettingsRepository Repository { get; private set; } = null!; + public SettingsAuthoritySession Session { get; private set; } = null!; + public ConnectionSamplingService Loop { get; private set; } = null!; + public StartupLaunchService StartupService { get; private set; } = null!; + public StartupTaskSettingsParticipant Startup { get; private set; } = null!; + public SamplingSettingsParticipant Sampling { get; private set; } = null!; + + public static async Task CreateAsync(params (string Key, string Value)[] changes) + { + Fixture fixture = new(); + try + { + var manifest = await fixture.Directory.PromoteFirstAsync(); + fixture.Repository = new(manifest.Descriptor, SettingsRegistry.Default); + Assert.True((await fixture.Repository.SaveAsync(SettingsEnvelopeTestData.CreateMatchingEnvelope(), 0, CancellationToken.None)).IsSucceeded); + SettingsEnvelope pending = SettingsEnvelopeTestData.CreatePendingEnvelope(changes); + Dictionary applied = pending.Applied.ToDictionary(); + foreach (SettingsApplicationBatchEntry entry in pending.PendingApplications.SelectMany(batch => batch.Entries)) + { + applied[entry.Key] = SettingAppliedState.Unknown(SettingAppliedUnknownReason.NotObserved, SettingAppliedUnknownHandling.QueueApplication); + } + + SettingsPersistenceResult seed = await fixture.Repository.SaveAsync(new(pending.SchemaVersion, pending.EnvelopeRevision, + pending.Desired, applied, pending.PendingApplications, pending.MigrationHistory), 1, CancellationToken.None); + Assert.True(seed.IsSucceeded, seed.Diagnostic?.Code); + fixture.Session = new(fixture.Repository, SettingsRegistry.Default, fixture.Admission); + fixture.Loop = new(new PoisonPreferences(), fixture.Source, new Storage(), key => key, fixture.Clock); + fixture.StartupService = new(fixture.Windows, new Storage(), key => key); + fixture.Startup = new(manifest.Descriptor, fixture.Admission, fixture.StartupService); + fixture.Sampling = new(manifest.Descriptor, fixture.Admission, fixture.Loop); + return fixture; + } + catch { await fixture.DisposeAsync(); throw; } + } + + public async Task ApplyAsync(ISettingsApplicationParticipant participant, CancellationToken cancellationToken = default) + { + using MutationAdmissionLease lease = Admission.AcquireOrdinary(cancellationToken); + SettingsApplicationBatch batch = Assert.Single((await Repository.OpenAsync(cancellationToken)).Envelope!.PendingApplications); + return await Session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, participant, SettingsApplicationPhase.Live, lease, cancellationToken); + } + + public async ValueTask DisposeAsync() + { + Source.Release.TrySetResult(); + if (Loop is not null) { await Loop.StopAsync(CancellationToken.None); } + if (Session is not null) { await Session.DisposeAsync(); } + await Directory.DisposeAsync(); + } + } + + private sealed class WindowsTask : IStartupLaunchTaskProvider, IStartupLaunchTask + { + public StartupLaunchTaskState State { get; set; } + public bool DenyEnable { get; set; } + public bool LoseEnableReply { get; set; } + public Exception? ReadFailure { get; set; } + public Exception? EnableFailure { get; set; } + public int Reads { get; private set; } + public int Mutations { get; private set; } + public Task GetAsync(string taskId) + { + Assert.Equal(StartupLaunchService.TaskId, taskId); + ++Reads; + if (ReadFailure is not null) { throw ReadFailure; } + return Task.FromResult(this); + } + + public Task RequestEnableAsync() + { + ++Mutations; + if (EnableFailure is not null) { throw EnableFailure; } + if (!DenyEnable) { State = StartupLaunchTaskState.Enabled; } + if (LoseEnableReply) { throw new InvalidOperationException("Lost platform reply."); } + return Task.FromResult(State); + } + + public void Disable() { ++Mutations; State = StartupLaunchTaskState.Disabled; } + } + + private sealed class PoisonPreferences : IConnectionSamplingSettings + { + public bool IsEnabled => throw new InvalidOperationException("Legacy preferences must not be read."); + public int IntervalSeconds => throw new InvalidOperationException("Legacy preferences must not be read."); + } + + private sealed class MutablePreferences : IConnectionSamplingSettings + { + public bool IsEnabled => true; + public int IntervalSeconds { get; set; } = 30; + } + + private sealed class Storage : IConnectionSamplingStorage, IStartupLaunchLog + { + public int AppendConnectionSnapshot(IReadOnlyList connections) => 0; + public void AppendLog(string level, string category, string message, string? detail) { } + } + + private sealed class Source : IConnectionSamplingSource + { + public bool Block { get; set; } + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource CancellationObserved { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public async Task> GetActiveConnectionsAsync(CancellationToken cancellationToken) + { + using CancellationTokenRegistration registration = cancellationToken.Register(() => CancellationObserved.TrySetResult()); + Entered.TrySetResult(); + if (Block) { await Release.Task; } + return []; + } + } + + private sealed class Clock : ISupervisorClock + { + private readonly Channel _delays = Channel.CreateUnbounded(); + public DateTimeOffset UtcNow => DateTimeOffset.UnixEpoch; + public async Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + Delay item = new(delay); + Assert.True(_delays.Writer.TryWrite(item)); + await item.WaitAsync(cancellationToken); + } + + public async Task ReadAsync() + { + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + return await _delays.Reader.ReadAsync(deadline.Token); + } + } + + private sealed class Delay(TimeSpan duration) + { + private readonly TaskCompletionSource _completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TimeSpan Duration { get; } = duration; + public void Complete() => _completed.TrySetResult(); + public async Task WaitAsync(CancellationToken cancellationToken) + { + using CancellationTokenRegistration registration = cancellationToken.Register(() => _completed.TrySetCanceled(cancellationToken)); + await _completed.Task; + } + } + + private sealed class Capture(ISettingsApplicationParticipant participant) : ISettingsApplicationParticipant + { + public SettingsApplicationRequest? Request { get; private set; } + public SettingApplicationKind ApplicationKind => participant.ApplicationKind; + public Task ProbeAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + Request = request; + return participant.ProbeAsync(request, admissionLease, cancellationToken); + } + + public Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) => + participant.ApplyAsync(request, admissionLease, cancellationToken); + } +} diff --git a/ClashSharp/ClashSharp/AppHost/Settings/SamplingSettingsParticipant.cs b/ClashSharp/ClashSharp/AppHost/Settings/SamplingSettingsParticipant.cs new file mode 100644 index 0000000..6bd1114 --- /dev/null +++ b/ClashSharp/ClashSharp/AppHost/Settings/SamplingSettingsParticipant.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Service; +using ClashSharp.Settings; + +namespace ClashSharp.Hosting.Settings; + +/// Installs a complete sampling pair without writing preferences or reacquiring mutation admission. +internal sealed class SamplingSettingsParticipant : ISettingsApplicationParticipant +{ + private readonly ConnectionSamplingService _sampling; + private readonly SettingsParticipantBinding _binding; + + public SamplingSettingsParticipant(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, + ConnectionSamplingService sampling) + { + _sampling = sampling ?? throw new ArgumentNullException(nameof(sampling)); + _binding = new(generation, admission, SettingApplicationKind.Sampling, + SettingsRegistry.Keys.ConnectionSamplingEnabled, SettingsRegistry.Keys.ConnectionSamplingIntervalSeconds); + } + + public SettingApplicationKind ApplicationKind => SettingApplicationKind.Sampling; + + public async Task ProbeAsync(SettingsApplicationRequest request, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + _binding.Validate(request, admissionLease); + ConnectionSamplingSettings observed = await _sampling.ReadConfigurationAsync(cancellationToken).ConfigureAwait(false); + return _binding.Observe(request, key => key == SettingsRegistry.Keys.ConnectionSamplingEnabled + ? (object)observed.Enabled : observed.IntervalSeconds); + } + + public Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, + CancellationToken cancellationToken) + { + _binding.Validate(request, admissionLease); + ConnectionSamplingSettings target = new( + request.Envelope.Desired[SettingsRegistry.Keys.ConnectionSamplingEnabled].Value.Get(), + request.Envelope.Desired[SettingsRegistry.Keys.ConnectionSamplingIntervalSeconds].Value.Get()); + return _sampling.ApplyConfigurationAsync(target, cancellationToken); + } +} diff --git a/ClashSharp/ClashSharp/AppHost/Settings/SettingsParticipantBinding.cs b/ClashSharp/ClashSharp/AppHost/Settings/SettingsParticipantBinding.cs new file mode 100644 index 0000000..3fd998e --- /dev/null +++ b/ClashSharp/ClashSharp/AppHost/Settings/SettingsParticipantBinding.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Settings; + +namespace ClashSharp.Hosting.Settings; + +/// Rejects foreign generations and admissions before accessing a generation-owned runtime component. +internal sealed class SettingsParticipantBinding +{ + private readonly DataGenerationDescriptor _generation; + private readonly MutationAdmissionBarrier _admission; + private readonly SettingApplicationKind _kind; + private readonly HashSet _keys; + + public SettingsParticipantBinding(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, + SettingApplicationKind kind, params SettingKey[] keys) + { + _generation = generation ?? throw new ArgumentNullException(nameof(generation)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + _kind = kind; + _keys = keys.ToHashSet(); + } + + public void Validate(SettingsApplicationRequest request, MutationAdmissionLease lease) + { + ArgumentNullException.ThrowIfNull(request); + _admission.EnsureActiveLease(lease); + if (!_generation.IsSameGeneration(request.Generation) || request.Batch.ApplicationKind != _kind + || request.Values.Count == 0 || request.Values.Keys.Any(key => !_keys.Contains(key))) + { + throw new InvalidOperationException("The settings attempt does not belong to this runtime participant."); + } + } + + public SettingsApplicationObservation Observe(SettingsApplicationRequest request, Func read) => + new(_generation, request.Batch.BatchId, request.Batch.AttemptId, + request.Values.Keys.Select(key => new SettingValueChange(key, + SettingsRegistry.Default.Get(key.Value).NormalizeValue(read(key)).Value + ?? throw new InvalidOperationException("The runtime returned a noncanonical settings value.")))); +} diff --git a/ClashSharp/ClashSharp/AppHost/Settings/StartupTaskSettingsParticipant.cs b/ClashSharp/ClashSharp/AppHost/Settings/StartupTaskSettingsParticipant.cs new file mode 100644 index 0000000..1dfcd0b --- /dev/null +++ b/ClashSharp/ClashSharp/AppHost/Settings/StartupTaskSettingsParticipant.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Service; +using ClashSharp.Settings; + +namespace ClashSharp.Hosting.Settings; + +/// Applies explicit startup intent and probes Windows independently of the preferences repository. +internal sealed class StartupTaskSettingsParticipant : ISettingsApplicationParticipant +{ + private readonly StartupLaunchService _startup; + private readonly SettingsParticipantBinding _binding; + + public StartupTaskSettingsParticipant(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, + StartupLaunchService startup) + { + _startup = startup ?? throw new ArgumentNullException(nameof(startup)); + _binding = new(generation, admission, SettingApplicationKind.StartupTask, SettingsRegistry.Keys.LaunchAtStartupEnabled); + } + + public SettingApplicationKind ApplicationKind => SettingApplicationKind.StartupTask; + + public async Task ProbeAsync(SettingsApplicationRequest request, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + _binding.Validate(request, admissionLease); + bool enabled = await _startup.TryGetStateAsync(cancellationToken).ConfigureAwait(false) switch + { + StartupLaunchTaskState.Enabled => true, + StartupLaunchTaskState.Disabled => false, + _ => throw new InvalidOperationException("Windows startup registration could not be observed."), + }; + return _binding.Observe(request, _ => enabled); + } + + public Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, + CancellationToken cancellationToken) + { + _binding.Validate(request, admissionLease); + return _startup.SetEnabledAsync(request.Values[SettingsRegistry.Keys.LaunchAtStartupEnabled].Get(), cancellationToken); + } +} diff --git a/ClashSharp/ClashSharp/Service/ConnectionSamplingService.Configuration.cs b/ClashSharp/ClashSharp/Service/ConnectionSamplingService.Configuration.cs new file mode 100644 index 0000000..f85c9af --- /dev/null +++ b/ClashSharp/ClashSharp/Service/ConnectionSamplingService.Configuration.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Settings; + +namespace ClashSharp.Service; + +public sealed partial class ConnectionSamplingService +{ + /// Applies an explicit pair after draining the old loop; the caller owns the surrounding settings transaction. + /// Once quiescence starts, retain ownership until the old iteration drains and the new configuration is installed. + internal async Task ApplyConfigurationAsync(ConnectionSamplingSettings settings, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + await _configurationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _supervisor.QuiesceAsync(CancellationToken.None).ConfigureAwait(false); + _explicitSettings = settings; + InstallConfiguredInterval(); + if (settings.Enabled) { await _supervisor.StartAsync(CancellationToken.None).ConfigureAwait(false); } + } + finally { _configurationGate.Release(); } + } + + /// Observes the owned loop and installed interval without consulting persisted desired settings. + internal async Task ReadConfigurationAsync(CancellationToken cancellationToken) + { + await _configurationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try { return new(_supervisor.IsRunning, Volatile.Read(ref _effectiveIntervalSeconds)); } + finally { _configurationGate.Release(); } + } + + private bool IsConfiguredEnabled => _explicitSettings?.Enabled ?? _settings.IsEnabled; + + private void InstallConfiguredInterval() => + Volatile.Write(ref _effectiveIntervalSeconds, _explicitSettings?.IntervalSeconds ?? _settings.IntervalSeconds); + + private async Task StartConfiguredLoopAsync(CancellationToken cancellationToken) + { + if (!_supervisor.IsRunning) { InstallConfiguredInterval(); } + await _supervisor.StartAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/ClashSharp/ClashSharp/Service/ConnectionSamplingService.cs b/ClashSharp/ClashSharp/Service/ConnectionSamplingService.cs index de797fa..a015961 100644 --- a/ClashSharp/ClashSharp/Service/ConnectionSamplingService.cs +++ b/ClashSharp/ClashSharp/Service/ConnectionSamplingService.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using ClashSharp.ApplicationModel.Lifecycle; +using ClashSharp.ApplicationModel.Settings; using ClashSharp.ApplicationModel.Supervision; using ClashSharp.Model; @@ -60,6 +61,14 @@ public sealed partial class ConnectionSamplingService : IRuntimeParticipant private readonly SupervisedLoop _supervisor; + /// Serializes complete configuration changes with ordinary lifecycle transitions. + private readonly SemaphoreSlim _configurationGate = new(1, 1); + + private ConnectionSamplingSettings? _explicitSettings; + + /// Installed loop interval; never reads a desired preference during an iteration. + private int _effectiveIntervalSeconds = 30; + private SupervisorHealthState _lastLoggedHealthState = SupervisorHealthState.Stopped; private int _lastInsertedCount; @@ -98,52 +107,66 @@ internal ConnectionSamplingService( public bool IsRunning => _supervisor.IsRunning; /// - public Task StartAsync(CancellationToken cancellationToken) + public async Task StartAsync(CancellationToken cancellationToken) { - if (!_settings.IsEnabled) + await _configurationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - cancellationToken.ThrowIfCancellationRequested(); - return Task.CompletedTask; + if (IsConfiguredEnabled) { await StartConfiguredLoopAsync(cancellationToken).ConfigureAwait(false); } } - - return _supervisor.StartAsync(cancellationToken); + finally { _configurationGate.Release(); } } /// - public Task QuiesceAsync(CancellationToken cancellationToken) + public async Task QuiesceAsync(CancellationToken cancellationToken) { - return _supervisor.QuiesceAsync(cancellationToken); + await _configurationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try { return await _supervisor.QuiesceAsync(cancellationToken).ConfigureAwait(false); } + finally { _configurationGate.Release(); } } /// - public Task ResumeAsync(QuiescedState priorState, CancellationToken cancellationToken) + public async Task ResumeAsync(QuiescedState priorState, CancellationToken cancellationToken) { - if (!_settings.IsEnabled) + ArgumentNullException.ThrowIfNull(priorState); + await _configurationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - cancellationToken.ThrowIfCancellationRequested(); - return Task.CompletedTask; + if (IsConfiguredEnabled && priorState.WasRunning) + { + if (!_supervisor.IsRunning) { InstallConfiguredInterval(); } + await _supervisor.ResumeAsync(priorState, cancellationToken).ConfigureAwait(false); + } } - - return _supervisor.ResumeAsync(priorState, cancellationToken); + finally { _configurationGate.Release(); } } /// - public Task StopAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) { - return _supervisor.StopAsync(cancellationToken); + await _configurationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try { await _supervisor.StopAsync(cancellationToken).ConfigureAwait(false); } + finally { _configurationGate.Release(); } } /// Starts an admitted settings transaction's loop, including a running baseline with a disabled preference. - internal Task StartLoopAsync(CancellationToken cancellationToken) => _supervisor.StartAsync(cancellationToken); + internal async Task StartLoopAsync(CancellationToken cancellationToken) + { + await _configurationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try { await StartConfiguredLoopAsync(cancellationToken).ConfigureAwait(false); } + finally { _configurationGate.Release(); } + } /// Re-evaluates current settings through an awaited stop-and-start transition. public async Task RestartFromSettingsAsync(CancellationToken cancellationToken) { - await _supervisor.QuiesceAsync(cancellationToken).ConfigureAwait(false); - if (_settings.IsEnabled) + await _configurationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - await _supervisor.StartAsync(cancellationToken).ConfigureAwait(false); + await _supervisor.QuiesceAsync(cancellationToken).ConfigureAwait(false); + if (IsConfiguredEnabled) { await StartConfiguredLoopAsync(cancellationToken).ConfigureAwait(false); } } + finally { _configurationGate.Release(); } } /// Samples active connections once and writes them to SQLite. @@ -227,7 +250,7 @@ private string FormatString(string key, params object[] args) private TimeSpan GetSamplingInterval() { - return TimeSpan.FromSeconds(Math.Max(0, _settings.IntervalSeconds)); + return TimeSpan.FromSeconds(Math.Max(0, Volatile.Read(ref _effectiveIntervalSeconds))); } private void OnHealthChanged(SupervisorHealth health) diff --git a/ClashSharp/ClashSharp/Service/StartupLaunchService.cs b/ClashSharp/ClashSharp/Service/StartupLaunchService.cs index edef507..6ccc840 100644 --- a/ClashSharp/ClashSharp/Service/StartupLaunchService.cs +++ b/ClashSharp/ClashSharp/Service/StartupLaunchService.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Diagnostics; namespace ClashSharp.Service; @@ -212,11 +213,12 @@ public async Task SetEnabledAsync(bool isEnabled, CancellationToken cancellation private static bool IsPlatformFailure(Exception exception) { - return exception is InvalidOperationException or + return !ExceptionGraphClassifier.IsProcessFatal(exception) + && exception is (InvalidOperationException or UnauthorizedAccessException or ArgumentException or NotSupportedException or - COMException; + COMException); } private StartupLaunchUpdateException CreateUpdateException( diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index 1e06c60..b607cdb 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -1,6 +1,6 @@ # Settings generation cutover -版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问及公共异步入口;生产 composition 仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 +版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口,以及 StartupTask、Sampling 的实际服务适配器;生产 composition 仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 ## 已实现的存储与迁移 @@ -30,6 +30,16 @@ 新增回归复现了完整命令的一个衔接缺口:desired 已经提交后,退出开始排空并撤销等待许可,原 session 的普通入口会取消随后的运行时应用。现在 facade 使用内部的已提交命令续行路径,继续验证原许可的有效性,持有原代际,完成全部参与者和保存;后续排队命令仍受撤销控制。直接调用 session 的普通批次入口继续遵守原有 Running 提交前取消规则。 +## StartupTask 与 Sampling 的实际服务适配 + +`StartupTaskSettingsParticipant` 和 `SamplingSettingsParticipant` 在访问运行时之前检查完整 generation descriptor、应用类别、允许的键和原许可的有效性。两者都不写偏好、不重新申请普通许可。StartupTask 通过生产 `StartupLaunchService` 读取 Windows 注册状态;已满足目标时不重复注册,拒绝或未知状态保留待办。应用回执丢失由后续独立平台探测判断。 + +`ConnectionSamplingService` 现在串行拥有完整的配置与生命周期转换。运行中的循环使用已经安装的间隔;偏好变化不会在下一轮采样中提前生效。显式配置入口等待旧循环及未完成的采样结束,再同时安装启用状态和间隔,随后启动新循环。该入口开始排空后不会因页面取消而放弃任务。探测读取实际循环和已安装的间隔,不访问旧偏好;永久停止的服务不能被报告为已启用。只修改一个采样键时,适配器从同一不可变请求读取配套值,暂停、恢复及重新启用也使用该完整配置。 + +新增回归使用真实 JSON 会话、生产启动服务和采样 supervisor,Windows、mihomo 及统计存储边界采用隔离模拟。测试覆盖启用、禁用、拒绝后显式重试、回执丢失、错误代际和许可、单键修改、旧循环排空与退出许可关闭,以及尚未应用的 desired 不改变实际间隔。它们没有修改开发机启动任务或访问实际 mihomo。 + +另外修复了启动服务的异常分类:探测和设置入口先检查完整异常图,嵌套致命异常保持原异常传播,不能返回未知状态或包装成 `StartupLaunchUpdateException`。两项回归先复现旧行为,再验证修复;持久 Running 记录保留给后续进程重新观察。 + ## 代际服务寿命 `DataGenerationManager.ExecuteAsync` 在取得代际租约后,从该代际拥有的 `IServiceProvider` 解析服务,并等待完整操作结束才释放租约。`ReadSnapshot` 仅用于同步、无 I/O 的不可变内存快照,不阻塞异步任务。服务容器的异步释放仍由 `DataGenerationScope` 的原生命周期协议负责。 @@ -47,12 +57,16 @@ 公共入口追加 9 项回归后,主程序 2714 项全部通过,零失败、零跳过,用时 50 秒;18 项目完整构建零警告、零错误,用时 26.51 秒,format 检查 1464 个文件、零处变更。本分支累计新增 93 项回归。收据为 `local-validation-settings-generation-facade.json`、`1.0.0-settings-facade-main.trx` 及同前缀的构建、格式日志。首次红测有一项许可撤销问题和一项夹具对预建代际目录的错误假设,均保留原报告;错误装配验证以 Settings 目录未创建为实际边界。 +公共入口提交 `a08fc4b` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34214328575),实际下载的四份 TRX 共 4779 项通过,零失败、零跳过。合并提交 `e31dd2c` 与源提交的 tree 同为 `78fe7a3dd89e81d4ba3203ca5dbb16ab4d6b4e35`,验证收据为 `ci-validation-settings-generation-facade.json`。开发安装器包构建成功,尚未为这份包追加原生验收。 + +实际服务适配和启动异常修复新增 16 项回归,本分支累计新增 109 项。最终完整主程序 2730 项通过,零失败、零跳过,用时 51 秒;18 项目 Release x64 构建零警告、零错误,用时 22.29 秒,format 检查 1473 个文件、零处变更。收据为 `local-validation-settings-generation-runtime.json`、`1.0.0-settings-runtime-final.trx`、`build-settings-runtime-final.log` 和 `format-settings-runtime-verified.log`。两项异常图红测保存在 `1.0.0-settings-runtime-fatal-red.trx`;此前夹具对初始 revision 和匹配 applied/pending 的错误构造也保留独立失败报告,不计为产品缺陷复现。 + 持久中断测试使用真实临时仓库、切点注入及新对象重开,运行时参与者为受控模拟。Windows 旧设置适配器已编译,未在开发机读取实际 LocalSettings。实际打包应用的迁移、进程崩溃、完整页面和安装器兼容验收将在生产切换后执行。开发机代理摘要保持 `95e97918ff6de70655b412568cd18dc81c5d6584c607bb9a71ddc72e22460447`。 ## 完整切换的剩余依赖 1. 将偏好写入统一为应用层异步 change set;页面、磁贴、触发器和网络提交者使用同一个接口。控制端凭据迁移到独立的内部凭据端口。 -2. 为 Internal、Appearance、Network、StartupTask、Sampling、Triggers 实现真实 apply/probe 适配器,明确读取 desired、有效状态和待办的消费者。 +2. 完成 Internal、Appearance、Network、Triggers 的实际 apply/probe 适配器,并将已实现的 StartupTask、Sampling 一起装配;明确读取 desired、有效状态和待办的消费者。 3. 在设置驱动的启动步骤之前完成旧事务恢复、代际打开和偏好迁移。profile/log/trigger 与 settings 必须由同一代际容器解析、排空和替换。 4. 将导入、重置和回滚接入候选代际及 manifest 提交,完成生产消费者替换后,原子替换 `SettingsAuthorityArchitectureTests` 中的临时门禁。 5. 运行新候选的 CI、打包应用及隔离 Windows 验收,再将完整节点推送 main。 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index 99180a0..a4e3efc 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -20,6 +20,8 @@ - 新增 84 项回归;主程序 2705 项全部通过,零失败、零跳过。18 项目 Release x64 构建零警告、零错误;完整 format 检查 1459 个文件、0 处变更。收据为 `local-validation-settings-generation-foundation.json`。 - 基础提交 `2775afe` 已推送开发分支并建立[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5),[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34211372829),实际四份 TRX 共 4770 项通过,新增 84 项均执行。合并提交 `8febfaf` 的 tree 与该基础提交相同,收据为 `ci-validation-settings-generation-foundation.json`。 - 公共异步入口继续持有同一个代际,覆盖 desired 提交、全部受影响批次及验证;修复提交后开始退出排空会截断后续应用的衔接问题。追加 9 项回归,完整主程序 2714 项通过,构建零警告、零错误,format 检查 1464 个文件、0 处变更;本分支累计新增 93 项,收据为 `local-validation-settings-generation-facade.json`。 +- 公共入口提交 `a08fc4b` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34214328575),实际四份 TRX 共 4779 项通过,零失败、零跳过;收据为 `ci-validation-settings-generation-facade.json`。 +- StartupTask 和 Sampling 适配器使用生产服务进行应用与独立观察;采样循环持有已安装的间隔,并串行完成旧任务排空和新配置安装。修复启动探测吞掉嵌套致命异常、设置入口包装该异常的问题,两项回归先红后绿。追加 16 项回归后,完整主程序 2730 项通过,构建零警告、零错误,format 检查 1473 个文件、0 处变更;累计新增 109 项,收据为 `local-validation-settings-generation-runtime.json`。平台边界采用隔离模拟,尚未装配进生产设置权威。 - 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。完整接入及验收继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 - main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 From e9026f8eecaef11086b8effbd12203a4be84a4e2 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 19:29:40 +0800 Subject: [PATCH 04/22] refactor: separate controller credentials from preference authority --- .../Security/ControllerCredentialException.cs | 16 ++ .../Security/ControllerCredentialService.cs | 107 +++++++++ .../Security/IControllerCredentialProvider.cs | 9 + .../Security/IControllerCredentialStore.cs | 17 ++ .../Security/ControllerCredentialPolicy.cs | 18 ++ .../WindowsControllerCredentialStore.cs | 36 +++ .../SettingsAuthorityArchitectureTests.cs | 21 ++ .../ClashSharp.Tests/ClashSharp.Tests.csproj | 2 + .../WindowsControllerCredentialStoreTests.cs | 58 +++++ .../AppDataMaintenanceServiceTests.cs | 99 +++++++- .../Unit/Services/AppSettingsServiceTests.cs | 16 -- .../ControllerCredentialServiceTests.cs | 216 ++++++++++++++++++ .../ControllerCredentialStartupStepTests.cs | 56 +++++ .../Services/ControllerCredentialTestStore.cs | 43 ++++ .../Services/CoreConfigurationServiceTests.cs | 6 +- .../FixedControllerCredentialProvider.cs | 8 + .../RuntimeConfigurationTransactionTests.cs | 4 +- .../AppHost/ClashSharpAppHostFactory.cs | 10 +- .../ControllerCredentialStartupStep.cs | 40 ++++ .../Service/AppDataMaintenanceService.cs | 15 +- .../AppDataMaintenanceServiceFactory.cs | 1 + .../Service/AppSettingsService.Mutations.cs | 1 - .../ClashSharp/Service/AppSettingsService.cs | 47 +--- .../Service/ApplicationActionService.cs | 12 +- ...ConfigurationService.RuntimeTransaction.cs | 2 +- .../Service/CoreConfigurationService.cs | 13 +- .../CoreConfigurationServiceFactory.cs | 3 +- .../Service/MihomoControllerClient.cs | 4 +- .../Service/MihomoControllerCredentials.cs | 41 ++++ .../Service/MihomoControllerEndpoint.cs | 19 +- .../2026-09-08-settings-generation-cutover.md | 20 +- docs/reviews/1.0.0-execution-ledger.md | 2 + 32 files changed, 855 insertions(+), 107 deletions(-) create mode 100644 ClashSharp/ClashSharp.Application/Security/ControllerCredentialException.cs create mode 100644 ClashSharp/ClashSharp.Application/Security/ControllerCredentialService.cs create mode 100644 ClashSharp/ClashSharp.Application/Security/IControllerCredentialProvider.cs create mode 100644 ClashSharp/ClashSharp.Application/Security/IControllerCredentialStore.cs create mode 100644 ClashSharp/ClashSharp.Core/Security/ControllerCredentialPolicy.cs create mode 100644 ClashSharp/ClashSharp.Infrastructure/Security/WindowsControllerCredentialStore.cs create mode 100644 ClashSharp/ClashSharp.Tests/Integration/WindowsControllerCredentialStoreTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialServiceTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialStartupStepTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialTestStore.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Services/FixedControllerCredentialProvider.cs create mode 100644 ClashSharp/ClashSharp/AppHost/Startup/ControllerCredentialStartupStep.cs create mode 100644 ClashSharp/ClashSharp/Service/MihomoControllerCredentials.cs diff --git a/ClashSharp/ClashSharp.Application/Security/ControllerCredentialException.cs b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialException.cs new file mode 100644 index 0000000..52e76dc --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialException.cs @@ -0,0 +1,16 @@ +namespace ClashSharp.ApplicationModel.Security; + +/// Reports an unavailable credential using a stable code without carrying private storage values. +public sealed class ControllerCredentialException : InvalidOperationException +{ + /// Creates a value-free credential failure. + /// Stable diagnostic code. + public ControllerCredentialException(string code) : base(code) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + Code = code; + } + + /// Gets the stable failure code. + public string Code { get; } +} diff --git a/ClashSharp/ClashSharp.Application/Security/ControllerCredentialService.cs b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialService.cs new file mode 100644 index 0000000..7e8b70e --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialService.cs @@ -0,0 +1,107 @@ +using System.Security.Cryptography; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Security; + +namespace ClashSharp.ApplicationModel.Security; + +/// Owns verified initialization and explicit deletion of the process controller credential. +/// Constructors and runtime reads have no storage effects. Only admitted startup and data deletion may write. +public sealed class ControllerCredentialService : IControllerCredentialProvider, IDisposable +{ + private readonly object _gate = new(); + private readonly IControllerCredentialStore _store; + private readonly MutationAdmissionBarrier _admission; + private string? _secret; + private bool _disposed; + + /// Creates an uninitialized credential owner without opening storage. + /// Independent private credential slot. + /// Process-wide mutation admission. + public ControllerCredentialService(IControllerCredentialStore store, MutationAdmissionBarrier admission) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + } + + /// + public string GetSecret() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _secret ?? throw new ControllerCredentialException("controller.credential.not_initialized"); + } + } + + /// Loads an existing valid credential or creates and independently verifies its durable replacement. + /// Active startup authority retained through verification, including a lost write reply. + /// Cancels before the first storage mutation; verification after a write is mandatory. + public void InitializeAdmitted(MutationAdmissionLease lease, CancellationToken cancellationToken) + { + _admission.EnsureActiveLease(lease); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + if (_secret is not null) { return; } + if (TryRead(out string? stored) && ControllerCredentialPolicy.IsValid(stored)) + { + _secret = stored; + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + _admission.EnsureActiveLease(lease); + string generated = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)); + bool failed = false; + try { _store.Write(generated); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { failed = true; } + if (!TryRead(out string? verified) || !StringComparer.Ordinal.Equals(generated, verified)) + { + throw new ControllerCredentialException(failed + ? "controller.credential.write_failed" : "controller.credential.verification_failed"); + } + + _secret = verified; + } + } + + /// Invalidates the cached credential and verifies removal after the runtime has been stopped. + /// Active data-deletion authority; ordinary settings resets must never call this method. + /// Cancels before deletion begins; absence must be verified after the attempt. + public void ClearAdmitted(MutationAdmissionLease lease, CancellationToken cancellationToken) + { + _admission.EnsureActiveLease(lease); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + _admission.EnsureActiveLease(lease); + _secret = null; + bool failed = false; + try { _store.Delete(); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { failed = true; } + if (TryRead(out _)) + { + throw new ControllerCredentialException(failed + ? "controller.credential.delete_failed" : "controller.credential.delete_not_verified"); + } + } + } + + /// Retires the process projection without changing durable storage. + public void Dispose() + { + lock (_gate) { _disposed = true; _secret = null; } + } + + private bool TryRead(out string? secret) + { + try { return _store.TryRead(out secret); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) + { + throw new ControllerCredentialException("controller.credential.read_failed"); + } + } +} diff --git a/ClashSharp/ClashSharp.Application/Security/IControllerCredentialProvider.cs b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialProvider.cs new file mode 100644 index 0000000..90fa10a --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialProvider.cs @@ -0,0 +1,9 @@ +namespace ClashSharp.ApplicationModel.Security; + +/// Supplies the independently initialized App-owned controller credential to runtime consumers. +public interface IControllerCredentialProvider +{ + /// Reads the verified process credential without opening storage or acquiring mutation admission. + /// The private credential; callers must not log, export, or include it in preference snapshots. + string GetSecret(); +} diff --git a/ClashSharp/ClashSharp.Application/Security/IControllerCredentialStore.cs b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialStore.cs new file mode 100644 index 0000000..656e6ae --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialStore.cs @@ -0,0 +1,17 @@ +namespace ClashSharp.ApplicationModel.Security; + +/// Persists exactly one controller credential outside the preferences authority. +public interface IControllerCredentialStore +{ + /// Reads the credential slot without enumerating user preferences. + /// Stored string, or null when absent or present with an invalid storage type. + /// Whether the slot exists; a present invalid value remains distinct from absence. + bool TryRead(out string? secret); + + /// Writes one canonical private credential; return alone does not prove persistence. + /// Canonical lowercase hexadecimal credential. + void Write(string secret); + + /// Removes only the credential slot; callers independently verify absence. + void Delete(); +} diff --git a/ClashSharp/ClashSharp.Core/Security/ControllerCredentialPolicy.cs b/ClashSharp/ClashSharp.Core/Security/ControllerCredentialPolicy.cs new file mode 100644 index 0000000..99d3329 --- /dev/null +++ b/ClashSharp/ClashSharp.Core/Security/ControllerCredentialPolicy.cs @@ -0,0 +1,18 @@ +namespace ClashSharp.Security; + +/// Defines the private App-owned controller credential independently of user preferences. +public static class ControllerCredentialPolicy +{ + /// Checks the canonical 256-bit lowercase hexadecimal representation. + /// Untrusted persisted credential; never included in diagnostics. + public static bool IsValid(string? secret) + { + if (secret is not { Length: 64 }) { return false; } + foreach (char character in secret) + { + if (character is not (>= '0' and <= '9') and not (>= 'a' and <= 'f')) { return false; } + } + + return true; + } +} diff --git a/ClashSharp/ClashSharp.Infrastructure/Security/WindowsControllerCredentialStore.cs b/ClashSharp/ClashSharp.Infrastructure/Security/WindowsControllerCredentialStore.cs new file mode 100644 index 0000000..2c8a3c4 --- /dev/null +++ b/ClashSharp/ClashSharp.Infrastructure/Security/WindowsControllerCredentialStore.cs @@ -0,0 +1,36 @@ +using ClashSharp.ApplicationModel.Security; +using ClashSharp.Security; +using Windows.Storage; + +namespace ClashSharp.Infrastructure.Security; + +/// Owns only the existing packaged-app controller credential slot, independently of preferences migration. +public sealed class WindowsControllerCredentialStore : IControllerCredentialStore +{ + internal const string CredentialKey = "MihomoControllerSecret"; + private readonly Func> _getValues; + + /// Creates the packaged Windows boundary without opening LocalSettings. + public WindowsControllerCredentialStore() : this(static () => ApplicationData.Current.LocalSettings.Values) { } + + internal WindowsControllerCredentialStore(Func> getValues) => + _getValues = getValues ?? throw new ArgumentNullException(nameof(getValues)); + + /// + public bool TryRead(out string? secret) + { + bool present = _getValues().TryGetValue(CredentialKey, out object? value); + secret = value as string; + return present; + } + + /// + public void Write(string secret) + { + if (!ControllerCredentialPolicy.IsValid(secret)) { throw new ArgumentException("Invalid controller credential shape.", nameof(secret)); } + _getValues()[CredentialKey] = secret; + } + + /// + public void Delete() => _getValues().Remove(CredentialKey); +} diff --git a/ClashSharp/ClashSharp.Tests/Architecture/SettingsAuthorityArchitectureTests.cs b/ClashSharp/ClashSharp.Tests/Architecture/SettingsAuthorityArchitectureTests.cs index ef432db..8b97451 100644 --- a/ClashSharp/ClashSharp.Tests/Architecture/SettingsAuthorityArchitectureTests.cs +++ b/ClashSharp/ClashSharp.Tests/Architecture/SettingsAuthorityArchitectureTests.cs @@ -49,6 +49,27 @@ public void ProductionApp_DoesNotActivateEnvelopeBesideLocalSettingsAuthority() + string.Join(Environment.NewLine, offenders)); } + [Fact] + public void ControllerCredentials_AreOwnedSeparatelyFromPreferencesAndOpenedBeforeRuntimeRecovery() + { + string preferences = ReadApplicationSource("Service/AppSettingsService.cs") + + ReadApplicationSource("Service/AppSettingsService.Mutations.cs"); + Assert.DoesNotContain("MihomoControllerSecret", preferences, StringComparison.Ordinal); + Assert.DoesNotContain("GetOrCreateInternalSecret", preferences, StringComparison.Ordinal); + Assert.DoesNotContain("RandomNumberGenerator", preferences, StringComparison.Ordinal); + string configuration = ReadApplicationSource("Service/CoreConfigurationService.cs"); + Assert.Contains("IControllerCredentialProvider", configuration, StringComparison.Ordinal); + Assert.DoesNotContain("_settings.MihomoControllerSecret", configuration, StringComparison.Ordinal); + string host = ReadApplicationSource("AppHost/ClashSharpAppHostFactory.cs"); + Assert.Contains("AddSingleton()", host, StringComparison.Ordinal); + Assert.Contains("AddSingleton()", host, StringComparison.Ordinal); + string startup = ReadApplicationSource("AppHost/Startup/ControllerCredentialStartupStep.cs"); + Assert.Contains("Order => 140", startup, StringComparison.Ordinal); + Assert.Contains("Order => 125", ReadApplicationSource("AppHost/Startup/InstallerTransactionStartupGate.cs"), StringComparison.Ordinal); + Assert.Contains("Order => 150", ReadApplicationSource("AppHost/Startup/MutationRecoveryStartupStep.cs"), StringComparison.Ordinal); + Assert.Contains("_credentials.ClearAll", ReadApplicationSource("Service/AppDataMaintenanceService.cs"), StringComparison.Ordinal); + } + [Fact] public void SettingsImportAndReset_UseTheScopeOwnedExclusiveAuthority() { diff --git a/ClashSharp/ClashSharp.Tests/ClashSharp.Tests.csproj b/ClashSharp/ClashSharp.Tests/ClashSharp.Tests.csproj index 15c63d1..9832cb1 100644 --- a/ClashSharp/ClashSharp.Tests/ClashSharp.Tests.csproj +++ b/ClashSharp/ClashSharp.Tests/ClashSharp.Tests.csproj @@ -86,6 +86,8 @@ + + diff --git a/ClashSharp/ClashSharp.Tests/Integration/WindowsControllerCredentialStoreTests.cs b/ClashSharp/ClashSharp.Tests/Integration/WindowsControllerCredentialStoreTests.cs new file mode 100644 index 0000000..4374780 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/WindowsControllerCredentialStoreTests.cs @@ -0,0 +1,58 @@ +using ClashSharp.Infrastructure.Security; +using ClashSharp.Tests.Unit.Services; + +namespace ClashSharp.Tests.Integration; + +/// Exercises the production Windows slot adapter with an isolated property-set boundary. +public sealed class WindowsControllerCredentialStoreTests +{ + [Fact] + public void ConstructionIsLazyAndSlotOperationsPreserveEveryOtherValue() + { + Dictionary values = new() + { + ["DisplayLanguage"] = 3, + ["UnknownPrivateValue"] = new object(), + [WindowsControllerCredentialStore.CredentialKey] = ControllerCredentialTestStore.ExistingSecret, + }; + int opens = 0; + WindowsControllerCredentialStore store = new(() => { ++opens; return values; }); + Assert.Equal(0, opens); + Assert.True(store.TryRead(out string? original)); + Assert.Equal(ControllerCredentialTestStore.ExistingSecret, original); + string replacement = new('a', 64); + store.Write(replacement); + Assert.True(store.TryRead(out string? verified)); + Assert.Equal(replacement, verified); + store.Delete(); + Assert.False(store.TryRead(out _)); + Assert.Equal(2, values.Count); + Assert.Equal(3, values["DisplayLanguage"]); + Assert.Equal(5, opens); + } + + [Fact] + public void PresentInvalidType_IsDistinctFromAbsenceAndDoesNotCallToString() + { + Dictionary values = new() { [WindowsControllerCredentialStore.CredentialKey] = new PrivateValue() }; + WindowsControllerCredentialStore store = new(() => values); + Assert.True(store.TryRead(out string? value)); + Assert.Null(value); + store.Delete(); + Assert.False(store.TryRead(out _)); + } + + [Theory] + [InlineData("")] + [InlineData("0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF")] + public void InvalidWrite_IsRejectedBeforeOpeningWindowsStorage(string value) + { + WindowsControllerCredentialStore store = new(() => throw new InvalidOperationException("Storage must not open.")); + Assert.Throws(() => store.Write(value)); + } + + private sealed class PrivateValue + { + public override string ToString() => throw new InvalidOperationException("Private values must not be rendered."); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/AppDataMaintenanceServiceTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/AppDataMaintenanceServiceTests.cs index bbd3577..47b87e7 100644 --- a/ClashSharp/ClashSharp.Tests/Unit/Services/AppDataMaintenanceServiceTests.cs +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/AppDataMaintenanceServiceTests.cs @@ -1,3 +1,5 @@ +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Security; using ClashSharp.Service; namespace ClashSharp.Tests.Unit.Services; @@ -30,6 +32,7 @@ public async Task ClearDataAsync_RunsMaintenanceStepsInOrder() [ "runtime.shutdown", "settings.clear", + "credentials.clear", "logs.clear", "local.clear", "logs.reset", @@ -75,13 +78,86 @@ await Assert.ThrowsAnyAsync(() => Assert.Equal(["runtime.shutdown"], calls); } + [Fact] + public async Task PreferenceReset_PreservesCredentialsAndFullDeletionRemovesThemAfterShutdown() + { + List calls = []; + ControllerCredentialTestStore store = new() { Present = true, Value = ControllerCredentialTestStore.ExistingSecret }; + store.BeforeDelete = () => { Assert.Contains("runtime.shutdown", calls); calls.Add("credentials.clear"); }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService owner = new(store, admission); + using (MutationAdmissionLease lease = admission.AcquireOrdinary()) { owner.InitializeAdmitted(lease, CancellationToken.None); } + MihomoControllerCredentials credentials = new(); + credentials.Bind(owner, admission); + AppDataMaintenanceService service = CreateService(calls, credentials: credentials); + service.ResetSettings(); + Assert.Equal(ControllerCredentialTestStore.ExistingSecret, credentials.GetSecret()); + Assert.Equal(0, store.Deletes); + calls.Clear(); + await service.ClearDataAsync(CancellationToken.None); + Assert.Equal(["runtime.shutdown", "settings.clear", "credentials.clear", "logs.clear", "local.clear", "logs.reset", "profiles.reset"], calls); + Assert.False(store.Present); + Assert.Throws(credentials.GetSecret); + } + + [Fact] + public async Task TerminalDataDeletion_UsesMaintenanceAdmissionForTheIndependentCredentialSlot() + { + List calls = []; + ControllerCredentialTestStore store = new() { Present = true, Value = ControllerCredentialTestStore.ExistingSecret }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService owner = new(store, admission); + using (MutationAdmissionLease lease = admission.AcquireOrdinary()) { owner.InitializeAdmitted(lease, CancellationToken.None); } + MihomoControllerCredentials credentials = new(); + credentials.Bind(owner, admission); + await using (MutationAdmissionLease lease = await admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, CancellationToken.None)) { lease.CommitShutdown(); } + store.BeforeDelete = () => calls.Add("credentials.clear"); + AppDataMaintenanceService service = CreateService(calls, credentials: credentials); + service.ClearDataAfterRuntimeShutdown(CancellationToken.None, useTerminalSettingsAdmission: true); + Assert.Equal(["settings.clear-terminal", "credentials.clear", "logs.clear", "local.clear", "logs.reset", "profiles.reset"], calls); + Assert.False(store.Present); + Assert.Throws(() => admission.AcquireOrdinary()); + } + + [Fact] + public async Task UnverifiedCredentialDeletion_StopsBeforeDeletingTheRemainingData() + { + List calls = []; + ControllerCredentialTestStore store = new() { Present = true, Value = ControllerCredentialTestStore.ExistingSecret, IgnoreDeletes = true }; + store.BeforeDelete = () => calls.Add("credentials.clear"); + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService owner = new(store, admission); + using (MutationAdmissionLease lease = admission.AcquireOrdinary()) { owner.InitializeAdmitted(lease, CancellationToken.None); } + MihomoControllerCredentials credentials = new(); + credentials.Bind(owner, admission); + AppDataMaintenanceService service = CreateService(calls, credentials: credentials); + await Assert.ThrowsAsync(() => service.ClearDataAsync(CancellationToken.None)); + Assert.Equal(["runtime.shutdown", "settings.clear", "credentials.clear"], calls); + Assert.True(store.Present); + } + + [Fact] + public async Task CancellationAfterPreferenceDeletion_StillCompletesCredentialAndFileCleanup() + { + List calls = []; + using CancellationTokenSource cancellation = new(); + AppDataMaintenanceService service = CreateService(calls, + settings: new CancellingSettings(calls, cancellation)); + await service.ClearDataAsync(cancellation.Token); + Assert.True(cancellation.IsCancellationRequested); + Assert.Equal(["runtime.shutdown", "settings.clear", "credentials.clear", "logs.clear", "local.clear", "logs.reset", "profiles.reset"], calls); + } + private static AppDataMaintenanceService CreateService( List calls, FakeAppDataMaintenanceLogStorage? logStorage = null, - IAppDataMaintenanceRuntime? runtime = null) + IAppDataMaintenanceRuntime? runtime = null, + IAppDataMaintenanceCredentials? credentials = null, + IAppDataMaintenanceSettings? settings = null) { return new AppDataMaintenanceService( - new FakeAppDataMaintenanceSettings(calls), + settings ?? new FakeAppDataMaintenanceSettings(calls), + credentials ?? new FakeCredentials(calls), runtime ?? new FakeAppDataMaintenanceRuntime(calls), logStorage ?? new FakeAppDataMaintenanceLogStorage(calls), new FakeAppDataMaintenanceLocalData(calls), @@ -89,7 +165,7 @@ private static AppDataMaintenanceService CreateService( key => key == "Maintenance.LogClearFailed" ? "localized log clear failed" : key); } - private sealed class FakeAppDataMaintenanceSettings(List calls) : IAppDataMaintenanceSettings + private sealed class FakeAppDataMaintenanceSettings(List calls) : IAppDataMaintenanceSettings, ITerminalShutdownSettingsMaintenance { public void ResetAllSettings() { @@ -100,6 +176,23 @@ public void ClearAllSettings() { calls.Add("settings.clear"); } + + public void ClearAllSettingsAfterShutdown() => calls.Add("settings.clear-terminal"); + } + + private sealed class FakeCredentials(List calls) : IAppDataMaintenanceCredentials + { + public void ClearAll(bool useTerminalAdmission, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + calls.Add("credentials.clear"); + } + } + + private sealed class CancellingSettings(List calls, CancellationTokenSource cancellation) : IAppDataMaintenanceSettings + { + public void ResetAllSettings() => calls.Add("settings.reset"); + public void ClearAllSettings() { calls.Add("settings.clear"); cancellation.Cancel(); } } private sealed class FakeAppDataMaintenanceRuntime(List calls) : IAppDataMaintenanceRuntime diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/AppSettingsServiceTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/AppSettingsServiceTests.cs index bb2cd67..5e4ada7 100644 --- a/ClashSharp/ClashSharp.Tests/Unit/Services/AppSettingsServiceTests.cs +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/AppSettingsServiceTests.cs @@ -506,22 +506,6 @@ public void ResetAllSettings_RestoresDefaults() Assert.True(ReadShowStartupGuideOnStartup()); } - /// Verifies ordinary reset preserves the internal credential while clear-all rotates it. - [Fact] - public void ClearAllSettings_RemovesInternalControllerCredential() - { - AppSettingsService.Instance.ClearAllSettings(); - string firstSecret = AppSettingsService.Instance.MihomoControllerSecret; - - AppSettingsService.Instance.ResetAllSettings(); - Assert.Equal(firstSecret, AppSettingsService.Instance.MihomoControllerSecret); - - AppSettingsService.Instance.ClearAllSettings(); - string rotatedSecret = AppSettingsService.Instance.MihomoControllerSecret; - - Assert.NotEqual(firstSecret, rotatedSecret); - } - /// Verifies settings writes expose one auditable change event and suppress no-op writes. [Fact] public void SettingChanged_RaisesForChangedValuesOnly() diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialServiceTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialServiceTests.cs new file mode 100644 index 0000000..de610a4 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialServiceTests.cs @@ -0,0 +1,216 @@ +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Security; +using ClashSharp.Security; + +namespace ClashSharp.Tests.Unit.Services; + +/// Verifies private credential lifecycle without accessing the host's Windows credential slot. +public sealed class ControllerCredentialServiceTests +{ + [Fact] + public async Task VerifiedCredential_IsReadOnlyDuringExclusiveAdmissionAndRequiresNoFurtherStorage() + { + ControllerCredentialTestStore store = new() { Present = true, Value = ControllerCredentialTestStore.ExistingSecret }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + Assert.Throws(service.GetSecret); + Assert.Equal(0, store.Reads); + using (MutationAdmissionLease lease = admission.AcquireOrdinary()) { service.InitializeAdmitted(lease, CancellationToken.None); } + await using MutationAdmissionLease exclusive = await admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, CancellationToken.None); + store.BeforeRead = () => throw new IOException("Storage is no longer readable."); + Assert.Equal(ControllerCredentialTestStore.ExistingSecret, service.GetSecret()); + Assert.Equal(1, store.Reads); + Assert.Equal(0, store.Writes); + } + + [Theory] + [InlineData(null)] + [InlineData("invalid")] + [InlineData(42)] + public void MissingOrInvalidSlot_IsReplacedOnlyByAVerifiedCanonicalCredential(object? original) + { + ControllerCredentialTestStore store = new() { Present = original is not null, Value = original }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + service.InitializeAdmitted(lease, CancellationToken.None); + Assert.True(ControllerCredentialPolicy.IsValid(service.GetSecret())); + Assert.Equal(store.Value, service.GetSecret()); + Assert.Equal(1, store.Writes); + Assert.Equal(2, store.Reads); + } + + [Fact] + public void LostWriteReply_IsResolvedFromTheDurableSlot() + { + ControllerCredentialTestStore store = new() { AfterWrite = () => throw new IOException("Lost write reply.") }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + service.InitializeAdmitted(lease, CancellationToken.None); + Assert.Equal(store.Value, service.GetSecret()); + Assert.Equal(1, store.Writes); + } + + [Fact] + public void UnverifiedWrite_DoesNotPublishAnEphemeralCredential() + { + ControllerCredentialTestStore store = new() { IgnoreWrites = true }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + Assert.Equal("controller.credential.verification_failed", + Assert.Throws(() => service.InitializeAdmitted(lease, CancellationToken.None)).Code); + Assert.Throws(service.GetSecret); + Assert.False(store.Present); + } + + [Fact] + public void UnavailableStorage_DoesNotFallBackToAnInMemoryCredentialOrExposePrivateErrorText() + { + ControllerCredentialTestStore store = new() { BeforeRead = () => throw new IOException("private-value-marker") }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + ControllerCredentialException failure = Assert.Throws(() => service.InitializeAdmitted(lease, CancellationToken.None)); + Assert.Equal("controller.credential.read_failed", failure.Code); + Assert.DoesNotContain("private-value-marker", failure.ToString(), StringComparison.Ordinal); + Assert.Throws(service.GetSecret); + Assert.Equal(0, store.Writes); + } + + [Fact] + public void LostVerification_IsResolvedByAFreshOwnerWithoutRotatingTheCommittedCredential() + { + ControllerCredentialTestStore store = new(); + store.AfterWrite = () => store.BeforeRead = () => throw new IOException("Verification unavailable."); + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService first = new(store, admission); + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + Assert.Throws(() => first.InitializeAdmitted(lease, CancellationToken.None)); + Assert.Throws(first.GetSecret); + object? committed = store.Value; + store.BeforeRead = null; + using ControllerCredentialService second = new(store, admission); + second.InitializeAdmitted(lease, CancellationToken.None); + Assert.Equal(committed, second.GetSecret()); + Assert.Equal(1, store.Writes); + } + + [Fact] + public void CancellationAfterWrite_DoesNotAbandonVerification() + { + using CancellationTokenSource cancellation = new(); + ControllerCredentialTestStore store = new() { AfterWrite = cancellation.Cancel }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + service.InitializeAdmitted(lease, cancellation.Token); + Assert.True(cancellation.IsCancellationRequested); + Assert.Equal(store.Value, service.GetSecret()); + Assert.Equal(2, store.Reads); + } + + [Fact] + public async Task ConcurrentInitialization_PublishesOneCredentialAndOneWrite() + { + ControllerCredentialTestStore store = new(); + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + string[] values = await Task.WhenAll(Enumerable.Range(0, 12).Select(_ => Task.Run(() => + { + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + service.InitializeAdmitted(lease, CancellationToken.None); + return service.GetSecret(); + }))); + Assert.Single(values.Distinct(StringComparer.Ordinal)); + Assert.Equal(1, store.Writes); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Deletion_IsVerifiedAndInvalidatesTheProcessProjectionEvenWhenItsReplyIsLost(bool loseReply) + { + ControllerCredentialTestStore store = new() { Present = true, Value = ControllerCredentialTestStore.ExistingSecret }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + service.InitializeAdmitted(lease, CancellationToken.None); + if (loseReply) { store.AfterDelete = () => throw new IOException("Lost deletion reply."); } + service.ClearAdmitted(lease, CancellationToken.None); + Assert.False(store.Present); + Assert.Throws(service.GetSecret); + service.InitializeAdmitted(lease, CancellationToken.None); + Assert.NotEqual(ControllerCredentialTestStore.ExistingSecret, service.GetSecret()); + } + + [Fact] + public void UnverifiedDeletion_CannotBeReportedAsClearedAndDoesNotLeaveAUsableCache() + { + ControllerCredentialTestStore store = new() { Present = true, Value = ControllerCredentialTestStore.ExistingSecret, IgnoreDeletes = true }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + service.InitializeAdmitted(lease, CancellationToken.None); + Assert.Equal("controller.credential.delete_not_verified", + Assert.Throws(() => service.ClearAdmitted(lease, CancellationToken.None)).Code); + Assert.True(store.Present); + Assert.Throws(service.GetSecret); + } + + [Fact] + public void ForeignDisposedAndCancelledAuthority_CannotAccessStorage() + { + ControllerCredentialTestStore store = new(); + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + using MutationAdmissionLease foreign = new MutationAdmissionBarrier().AcquireOrdinary(); + Assert.Throws(() => service.InitializeAdmitted(foreign, CancellationToken.None)); + Assert.Throws(() => service.ClearAdmitted(foreign, CancellationToken.None)); + MutationAdmissionLease disposed = admission.AcquireOrdinary(); + disposed.Dispose(); + Assert.Throws(() => service.InitializeAdmitted(disposed, CancellationToken.None)); + using MutationAdmissionLease valid = admission.AcquireOrdinary(); + Assert.ThrowsAny(() => service.InitializeAdmitted(valid, new CancellationToken(true))); + Assert.Equal((0, 0, 0), (store.Reads, store.Writes, store.Deletes)); + } + + [Theory] + [InlineData("read")] + [InlineData("write")] + [InlineData("delete")] + public void FatalExceptionGraphs_PreserveTheirIdentity(string stage) + { + InvalidOperationException fatal = new("Platform wrapper.", new AggregateException(Activator.CreateInstance())); + ControllerCredentialTestStore store = new(); + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService service = new(store, admission); + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + if (stage == "read") { store.BeforeRead = () => throw fatal; } + if (stage == "write") { store.BeforeWrite = () => throw fatal; } + if (stage == "delete") { store.BeforeDelete = () => throw fatal; } + Exception observed = Assert.Throws(() => + { + if (stage == "delete") { service.ClearAdmitted(lease, CancellationToken.None); } + else { service.InitializeAdmitted(lease, CancellationToken.None); } + }); + Assert.Same(fatal, observed); + } + + [Fact] + public void RetiredOwner_RejectsEveryOperationWithoutDeletingDurableCredentials() + { + ControllerCredentialTestStore store = new() { Present = true, Value = ControllerCredentialTestStore.ExistingSecret }; + MutationAdmissionBarrier admission = new(); + ControllerCredentialService service = new(store, admission); + using MutationAdmissionLease lease = admission.AcquireOrdinary(); + service.InitializeAdmitted(lease, CancellationToken.None); + service.Dispose(); + Assert.Throws(service.GetSecret); + Assert.Throws(() => service.InitializeAdmitted(lease, CancellationToken.None)); + Assert.Throws(() => service.ClearAdmitted(lease, CancellationToken.None)); + Assert.True(store.Present); + Assert.Equal(0, store.Deletes); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialStartupStepTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialStartupStepTests.cs new file mode 100644 index 0000000..78585d2 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialStartupStepTests.cs @@ -0,0 +1,56 @@ +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Security; +using ClashSharp.ApplicationModel.Startup; +using ClashSharp.Hosting.Startup; +using ClashSharp.Service; + +namespace ClashSharp.Tests.Unit.Services; + +/// Checks the production startup binding with isolated credential storage. +public sealed class ControllerCredentialStartupStepTests +{ + [Fact] + public async Task Startup_BindsOnlyTheVerifiedCredentialBeforeRuntimeRecovery() + { + ControllerCredentialTestStore store = new() { Present = true, Value = ControllerCredentialTestStore.ExistingSecret }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService owner = new(store, admission); + MihomoControllerCredentials binding = new(); + ControllerCredentialStartupStep step = new(owner, admission, binding); + Assert.Throws(binding.GetSecret); + Assert.Equal(0, store.Reads); + Assert.InRange(step.Order, 126, 149); + Assert.Equal(StartupStepOutcome.Succeeded, (await step.ExecuteAsync(new AppLaunchRequest(string.Empty), CancellationToken.None)).Outcome); + Assert.Equal(ControllerCredentialTestStore.ExistingSecret, binding.GetSecret()); + Assert.Equal(1, store.Reads); + Assert.Equal(0, store.Writes); + Assert.Equal(StartupStepOutcome.Succeeded, (await step.ExecuteAsync(new AppLaunchRequest(string.Empty), CancellationToken.None)).Outcome); + Assert.Equal(1, store.Reads); + } + + [Fact] + public async Task UnavailableCredential_PreventsStartupAndLeavesRuntimeConsumersUnbound() + { + ControllerCredentialTestStore store = new() { BeforeRead = () => throw new IOException("private-marker") }; + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService owner = new(store, admission); + MihomoControllerCredentials binding = new(); + ControllerCredentialStartupStep step = new(owner, admission, binding); + StartupStepResult result = await step.ExecuteAsync(new AppLaunchRequest(string.Empty), CancellationToken.None); + Assert.Equal(StartupStepOutcome.Fatal, result.Outcome); + Assert.Equal("controller.credential.read_failed", result.DiagnosticCode); + Assert.Throws(binding.GetSecret); + Assert.Equal(0, store.Writes); + } + + [Fact] + public async Task CancelledStartup_NeverOpensThePrivateSlot() + { + ControllerCredentialTestStore store = new(); + MutationAdmissionBarrier admission = new(); + using ControllerCredentialService owner = new(store, admission); + ControllerCredentialStartupStep step = new(owner, admission, new MihomoControllerCredentials()); + await Assert.ThrowsAnyAsync(() => step.ExecuteAsync(new AppLaunchRequest(string.Empty), new CancellationToken(true))); + Assert.Equal((0, 0, 0), (store.Reads, store.Writes, store.Deletes)); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialTestStore.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialTestStore.cs new file mode 100644 index 0000000..1a365e9 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/ControllerCredentialTestStore.cs @@ -0,0 +1,43 @@ +using ClashSharp.ApplicationModel.Security; + +namespace ClashSharp.Tests.Unit.Services; + +internal sealed class ControllerCredentialTestStore : IControllerCredentialStore +{ + public const string ExistingSecret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + public bool Present { get; set; } + public object? Value { get; set; } + public int Reads { get; private set; } + public int Writes { get; private set; } + public int Deletes { get; private set; } + public bool IgnoreWrites { get; set; } + public bool IgnoreDeletes { get; set; } + public Action? BeforeRead { get; set; } + public Action? BeforeWrite { get; set; } + public Action? AfterWrite { get; set; } + public Action? BeforeDelete { get; set; } + public Action? AfterDelete { get; set; } + public bool TryRead(out string? secret) + { + ++Reads; + BeforeRead?.Invoke(); + secret = Value as string; + return Present; + } + + public void Write(string secret) + { + ++Writes; + BeforeWrite?.Invoke(); + if (!IgnoreWrites) { Present = true; Value = secret; } + AfterWrite?.Invoke(); + } + + public void Delete() + { + ++Deletes; + BeforeDelete?.Invoke(); + if (!IgnoreDeletes) { Present = false; Value = null; } + AfterDelete?.Invoke(); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/CoreConfigurationServiceTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/CoreConfigurationServiceTests.cs index b7bf185..593f4ec 100644 --- a/ClashSharp/ClashSharp.Tests/Unit/Services/CoreConfigurationServiceTests.cs +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/CoreConfigurationServiceTests.cs @@ -29,6 +29,7 @@ public void EnsureConfiguration_UsesInjectedSettingsAndWritesRuntimeConfiguratio Assert.Contains("mixed-port: 19090", configurationText, StringComparison.Ordinal); Assert.Contains("mode: global", configurationText, StringComparison.Ordinal); Assert.Contains("tun:\n", configurationText, StringComparison.Ordinal); + Assert.Contains(new FixedControllerCredentialProvider().GetSecret(), configurationText, StringComparison.Ordinal); } [Fact] @@ -141,6 +142,7 @@ public async Task TryReadProfileConfigurationText_SerializesWithProfileOverwrite CoreConfigurationService service = new( tempDirectory.Path, new FakeCoreConfigurationSettings(), + new FixedControllerCredentialProvider(), metrics, new FakeCoreConfigurationValidator(), static key => key, @@ -392,6 +394,7 @@ private static CoreConfigurationService CreateService( return new CoreConfigurationService( configurationDirectory, settings ?? new FakeCoreConfigurationSettings(), + new FixedControllerCredentialProvider(), metrics ?? new FakeCoreConfigurationProfileMetrics(), validator ?? new FakeCoreConfigurationValidator(), key => key switch @@ -440,9 +443,6 @@ private sealed class FakeCoreConfigurationSettings : ICoreConfigurationSettings public int MixedPort { get; init; } = 7890; public string ActiveProfileId { get; init; } = ProfileCatalogIds.BuiltInDirect; - - public string MihomoControllerSecret { get; init; } = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; } private sealed class FakeCoreConfigurationProfileMetrics : ICoreConfigurationProfileMetrics diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/FixedControllerCredentialProvider.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/FixedControllerCredentialProvider.cs new file mode 100644 index 0000000..a65f50c --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/FixedControllerCredentialProvider.cs @@ -0,0 +1,8 @@ +using ClashSharp.ApplicationModel.Security; + +namespace ClashSharp.Tests.Unit.Services; + +internal sealed class FixedControllerCredentialProvider : IControllerCredentialProvider +{ + public string GetSecret() => "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/RuntimeConfigurationTransactionTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/RuntimeConfigurationTransactionTests.cs index 4205d6f..25e7f14 100644 --- a/ClashSharp/ClashSharp.Tests/Unit/Services/RuntimeConfigurationTransactionTests.cs +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/RuntimeConfigurationTransactionTests.cs @@ -571,6 +571,7 @@ private static CoreConfigurationService CreateService( return new CoreConfigurationService( configurationDirectory, new FakeSettings(), + new FixedControllerCredentialProvider(), new EmptyMetrics(), validator, static key => key); @@ -590,9 +591,6 @@ private sealed class FakeSettings : ICoreConfigurationSettings public int MixedPort => 7890; public string ActiveProfileId => ProfileCatalogIds.BuiltInDirect; - - public string MihomoControllerSecret { get; } = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; } private sealed class EmptyMetrics : ICoreConfigurationProfileMetrics diff --git a/ClashSharp/ClashSharp/AppHost/ClashSharpAppHostFactory.cs b/ClashSharp/ClashSharp/AppHost/ClashSharpAppHostFactory.cs index 052fbfc..5acb607 100644 --- a/ClashSharp/ClashSharp/AppHost/ClashSharpAppHostFactory.cs +++ b/ClashSharp/ClashSharp/AppHost/ClashSharpAppHostFactory.cs @@ -5,12 +5,14 @@ using ClashSharp.ApplicationModel.Mutations; using ClashSharp.ApplicationModel.Network; using ClashSharp.ApplicationModel.Presentation; +using ClashSharp.ApplicationModel.Security; using ClashSharp.ApplicationModel.Settings; using ClashSharp.ApplicationModel.Startup; using ClashSharp.ApplicationModel.Triggers; using ClashSharp.Hosting.Compatibility; using ClashSharp.Hosting.Startup; using ClashSharp.Infrastructure.Recovery; +using ClashSharp.Infrastructure.Security; using ClashSharp.Infrastructure.Triggers; using ClashSharp.Presentation.Composition; using ClashSharp.Presentation.Navigation; @@ -96,6 +98,10 @@ public static AppHost Build( services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(mutationAdmission); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(_ => MihomoControllerCredentials.Instance); + services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(_ => MutationDeadlines.Default); services.AddSingleton(_ => new FileMutationJournalStore( @@ -133,7 +139,8 @@ public static AppHost Build( provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService())); + provider.GetRequiredService(), + provider.GetRequiredService())); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); @@ -265,6 +272,7 @@ public static AppHost Build( installerTransactionState, provider.GetRequiredService())); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/ClashSharp/ClashSharp/AppHost/Startup/ControllerCredentialStartupStep.cs b/ClashSharp/ClashSharp/AppHost/Startup/ControllerCredentialStartupStep.cs new file mode 100644 index 0000000..54c6f29 --- /dev/null +++ b/ClashSharp/ClashSharp/AppHost/Startup/ControllerCredentialStartupStep.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Security; +using ClashSharp.ApplicationModel.Startup; +using ClashSharp.Service; + +namespace ClashSharp.Hosting.Startup; + +/// Verifies private credentials after the Installer gate and before runtime recovery can generate configuration. +internal sealed class ControllerCredentialStartupStep( + ControllerCredentialService credentials, + MutationAdmissionBarrier admission, + MihomoControllerCredentials binding) : IStartupStep +{ + public string Name => "controller-credential"; + + public int Order => 140; + + public Task ExecuteAsync(AppLaunchRequest request, CancellationToken cancellationToken) + { + try + { + using MutationAdmissionLease lease = admission.AcquireOrdinary(cancellationToken); + credentials.InitializeAdmitted(lease, cancellationToken); + binding.Bind(credentials, admission); + return Task.FromResult(StartupStepResult.Succeeded()); + } + catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested + && !ExceptionGraphClassifier.IsProcessFatal(exception)) + { throw; } + catch (ControllerCredentialException exception) { return Task.FromResult(StartupStepResult.Fatal(exception.Code)); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) + { + return Task.FromResult(StartupStepResult.Fatal("controller.credential.startup_failed")); + } + } +} diff --git a/ClashSharp/ClashSharp/Service/AppDataMaintenanceService.cs b/ClashSharp/ClashSharp/Service/AppDataMaintenanceService.cs index 9030da2..0ffa4b2 100644 --- a/ClashSharp/ClashSharp/Service/AppDataMaintenanceService.cs +++ b/ClashSharp/ClashSharp/Service/AppDataMaintenanceService.cs @@ -11,10 +11,17 @@ internal interface IAppDataMaintenanceSettings /// Resets all settings to defaults. void ResetAllSettings(); - /// Clears both user settings and internal credentials. + /// Clears only user preferences. void ClearAllSettings(); } +/// Deletes private credentials separately from user preference reset. +internal interface IAppDataMaintenanceCredentials +{ + /// Verifies credential deletion after the runtime is stopped, using the appropriate maintenance admission. + void ClearAll(bool useTerminalAdmission, CancellationToken cancellationToken); +} + /// Clears settings under terminal shutdown admission owned by the host. internal interface ITerminalShutdownSettingsMaintenance { @@ -67,6 +74,7 @@ internal sealed partial class AppDataMaintenanceService private readonly IAppDataMaintenanceSettings _settings; private readonly IAppDataMaintenanceRuntime _runtime; + private readonly IAppDataMaintenanceCredentials _credentials; private readonly IAppDataMaintenanceLogStorage _logStorage; @@ -78,6 +86,7 @@ internal sealed partial class AppDataMaintenanceService internal AppDataMaintenanceService( IAppDataMaintenanceSettings settings, + IAppDataMaintenanceCredentials credentials, IAppDataMaintenanceRuntime runtime, IAppDataMaintenanceLogStorage logStorage, IAppDataMaintenanceLocalDataStore localData, @@ -85,6 +94,7 @@ internal AppDataMaintenanceService( Func getString) { _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _credentials = credentials ?? throw new ArgumentNullException(nameof(credentials)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _logStorage = logStorage ?? throw new ArgumentNullException(nameof(logStorage)); _localData = localData ?? throw new ArgumentNullException(nameof(localData)); @@ -121,6 +131,9 @@ internal void ClearDataAfterRuntimeShutdown( _settings.ClearAllSettings(); } + // Preference deletion has started. Finish the owned cleanup even if the page + // cancels while observing the reset; the runtime is already stopped. + _credentials.ClearAll(useTerminalSettingsAdmission, CancellationToken.None); TryClearLogStorage(); _localData.ClearAll(); _logStorage.ResetAfterDataDeletion(); diff --git a/ClashSharp/ClashSharp/Service/AppDataMaintenanceServiceFactory.cs b/ClashSharp/ClashSharp/Service/AppDataMaintenanceServiceFactory.cs index 53f90b0..de83dfa 100644 --- a/ClashSharp/ClashSharp/Service/AppDataMaintenanceServiceFactory.cs +++ b/ClashSharp/ClashSharp/Service/AppDataMaintenanceServiceFactory.cs @@ -34,6 +34,7 @@ public static AppDataMaintenanceService CreateDefault() return new AppDataMaintenanceService( new AppDataMaintenanceSettingsAdapter( AppSettingsService.Instance), + MihomoControllerCredentials.Instance, new LegacyAppDataMaintenanceRuntimeAdapter( ConnectionSamplingService.Instance, async cancellationToken => diff --git a/ClashSharp/ClashSharp/Service/AppSettingsService.Mutations.cs b/ClashSharp/ClashSharp/Service/AppSettingsService.Mutations.cs index 71831e7..5102201 100644 --- a/ClashSharp/ClashSharp/Service/AppSettingsService.Mutations.cs +++ b/ClashSharp/ClashSharp/Service/AppSettingsService.Mutations.cs @@ -331,7 +331,6 @@ private void RemoveOwnedKey(string key) internal void ClearAllSettings() { ResetAllSettings(); - _pending[KeyMihomoControllerSecret] = null; } internal IReadOnlyList Commit() diff --git a/ClashSharp/ClashSharp/Service/AppSettingsService.cs b/ClashSharp/ClashSharp/Service/AppSettingsService.cs index 9bb37b4..9bc3401 100644 --- a/ClashSharp/ClashSharp/Service/AppSettingsService.cs +++ b/ClashSharp/ClashSharp/Service/AppSettingsService.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Security.Cryptography; using System.Threading; using ClashSharp.ApplicationModel.Mutations; using ClashSharp.Model; @@ -68,10 +67,6 @@ public sealed partial class AppSettingsService : /// Storage key for the local mixed proxy port. private const string KeyMixedPort = "MixedPort"; - /// Storage key for the private mihomo controller bearer secret. - /// This internal credential is intentionally excluded from user settings reset, export, and audit events. - private const string KeyMihomoControllerSecret = "MihomoControllerSecret"; - /// Storage key for background connection sampling. private const string KeyConnectionSamplingEnabled = "ConnectionSamplingEnabled"; @@ -302,15 +297,6 @@ public int MixedPort } } - /// Gets the private bearer secret shared with the local mihomo controller. - /// A persistent 256-bit secret encoded as 64 lowercase hexadecimal characters. - internal string MihomoControllerSecret - { - get => GetOrCreateInternalSecret( - KeyMihomoControllerSecret, - MihomoControllerEndpoint.IsValidSecret); - } - /// Gets or sets whether active connections are periodically sampled into SQLite. /// True when background connection sampling is enabled; defaults to true. public bool ConnectionSamplingEnabled @@ -564,7 +550,7 @@ or SettingsResetScope.Triggers or SettingsResetScope.Tray WriteOrdinary(editor => editor.ResetDefinitions(definitions)); } - /// Clears user settings and internal credentials for the destructive clear-all-data operation. + /// Clears user preferences; private credentials belong to separate data-maintenance authority. internal void ClearAllSettings() { WriteOrdinary(static editor => editor.ClearAllSettings()); @@ -689,37 +675,6 @@ private TEnum GetEnum(string key, TEnum defaultValue) return _fallbackValues.TryGetValue(key, out object? fallbackValue) ? fallbackValue : null; } - /// Reads or atomically creates one internal 256-bit credential while the settings lock is held. - private string GetOrCreateInternalSecret( - string key, - Func validator) - { - lock (_syncLock) - { - if (GetValue(key) is string storedSecret && validator(storedSecret)) - { - return storedSecret; - } - } - - MutationAdmissionBarrier admission = Volatile.Read(ref _mutationAdmission); - using MutationAdmissionLease lease = admission.AcquireOrdinary(); - admission.EnsureActiveLease(lease); - lock (_syncLock) - { - admission.EnsureActiveLease(lease); - if (GetValue(key) is string storedSecret && validator(storedSecret)) - { - return storedSecret; - } - - string generatedSecret = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)) - .ToLowerInvariant(); - _ = SetValue(key, generatedSecret); - return generatedSecret; - } - } - /// Writes a raw setting value to the preferred backing store. /// Storage key. Must not be null. /// Value to persist. Must be supported by Windows local settings. diff --git a/ClashSharp/ClashSharp/Service/ApplicationActionService.cs b/ClashSharp/ClashSharp/Service/ApplicationActionService.cs index 685d2b4..3edf005 100644 --- a/ClashSharp/ClashSharp/Service/ApplicationActionService.cs +++ b/ClashSharp/ClashSharp/Service/ApplicationActionService.cs @@ -6,6 +6,7 @@ using ClashSharp.ApplicationModel.Hosting; using ClashSharp.ApplicationModel.Mutations; using ClashSharp.ApplicationModel.Network; +using ClashSharp.ApplicationModel.Security; using ClashSharp.ApplicationModel.Settings; using ClashSharp.Diagnostics; using ClashSharp.Model; @@ -22,6 +23,7 @@ internal sealed class ApplicationActionService : IApplicationActionDispatcher ?? throw new InvalidOperationException("Application actions are unavailable before primary host startup."); private readonly AppSettingsService _settings; + private readonly IControllerCredentialProvider _controllerCredentials; private readonly MutationAdmissionBarrier _admissionBarrier; private readonly NetworkStateCoordinator _network; private readonly ConnectionSamplingService _sampling; @@ -51,9 +53,11 @@ internal ApplicationActionService( IApplicationShutdownCoordinator shutdown, StartupLaunchService startupLaunch, StartupSettingsCoordinator startupSettings, - ConnectionSamplingSettingsCoordinator samplingSettings) + ConnectionSamplingSettingsCoordinator samplingSettings, + IControllerCredentialProvider controllerCredentials) { _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _controllerCredentials = controllerCredentials ?? throw new ArgumentNullException(nameof(controllerCredentials)); _admissionBarrier = admissionBarrier ?? throw new ArgumentNullException(nameof(admissionBarrier)); _network = network ?? throw new ArgumentNullException(nameof(network)); _sampling = sampling ?? throw new ArgumentNullException(nameof(sampling)); @@ -158,9 +162,9 @@ internal async Task ApplyNetworkSettingsAsync( internal ValueTask BeginSettingsDestructiveMutationAsync( CancellationToken cancellationToken) { - // Materialize the App-owned controller credential before closing ordinary settings - // admission. The Installer-owned service credential is never stored in App settings. - _ = _settings.MihomoControllerSecret; + // Require the independently verified startup credential before destructive recovery + // can generate runtime configuration under exclusive admission. This is a pure read. + _ = _controllerCredentials.GetSecret(); return _admissionBarrier.CloseAndDrainAsync( MutationAdmissionClosure.Destructive, cancellationToken); diff --git a/ClashSharp/ClashSharp/Service/CoreConfigurationService.RuntimeTransaction.cs b/ClashSharp/ClashSharp/Service/CoreConfigurationService.RuntimeTransaction.cs index 4ac95b4..07a66a6 100644 --- a/ClashSharp/ClashSharp/Service/CoreConfigurationService.RuntimeTransaction.cs +++ b/ClashSharp/ClashSharp/Service/CoreConfigurationService.RuntimeTransaction.cs @@ -211,7 +211,7 @@ internal async Task ApplyRuntimeConfigura mixedPort, mode, transparentProxyEnabled, - _settings.MihomoControllerSecret); + _controllerCredentials.GetSecret()); await _validator .ValidateAsync(_configurationDirectoryPath, stagingPath, cancellationToken) .ConfigureAwait(false); diff --git a/ClashSharp/ClashSharp/Service/CoreConfigurationService.cs b/ClashSharp/ClashSharp/Service/CoreConfigurationService.cs index c72d2de..5ea9394 100644 --- a/ClashSharp/ClashSharp/Service/CoreConfigurationService.cs +++ b/ClashSharp/ClashSharp/Service/CoreConfigurationService.cs @@ -3,6 +3,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Security; using ClashSharp.Model; namespace ClashSharp.Service; @@ -18,9 +19,6 @@ internal interface ICoreConfigurationSettings /// Gets the active profile identifier. string ActiveProfileId { get; } - - /// Gets the private bearer secret for the Clash#-owned mihomo controller. - string MihomoControllerSecret { get; } } /// Counts profile preview rows from configuration text. @@ -61,6 +59,7 @@ public sealed partial class CoreConfigurationService private readonly string _configurationFilePath; private readonly ICoreConfigurationSettings _settings; + private readonly IControllerCredentialProvider _controllerCredentials; private readonly ICoreConfigurationProfileMetrics _profileMetrics; @@ -76,12 +75,14 @@ public sealed partial class CoreConfigurationService internal CoreConfigurationService( string configurationDirectoryPath, ICoreConfigurationSettings settings, + IControllerCredentialProvider controllerCredentials, ICoreConfigurationProfileMetrics profileMetrics, ICoreConfigurationValidator validator, Func getString) : this( configurationDirectoryPath, settings, + controllerCredentials, profileMetrics, validator, getString, @@ -94,6 +95,7 @@ internal CoreConfigurationService( internal CoreConfigurationService( string configurationDirectoryPath, ICoreConfigurationSettings settings, + IControllerCredentialProvider controllerCredentials, ICoreConfigurationProfileMetrics profileMetrics, ICoreConfigurationValidator validator, Func getString, @@ -105,6 +107,7 @@ internal CoreConfigurationService( _configurationDirectoryPath = Path.GetFullPath(configurationDirectoryPath); _configurationFilePath = Path.Combine(_configurationDirectoryPath, "config.yaml"); _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _controllerCredentials = controllerCredentials ?? throw new ArgumentNullException(nameof(controllerCredentials)); _profileMetrics = profileMetrics ?? throw new ArgumentNullException(nameof(profileMetrics)); _validator = validator ?? throw new ArgumentNullException(nameof(validator)); _getString = getString ?? throw new ArgumentNullException(nameof(getString)); @@ -424,7 +427,7 @@ private string BuildRuntimeConfiguration( mixedPort, mode, transparentProxyEnabled, - _settings.MihomoControllerSecret); + _controllerCredentials.GetSecret()); } string profileConfigPath = GetProfileConfigurationPath(profileId); @@ -441,7 +444,7 @@ private string BuildRuntimeConfiguration( mixedPort, mode, transparentProxyEnabled, - _settings.MihomoControllerSecret); + _controllerCredentials.GetSecret()); } /// Restores the previous committed configuration and removes only this transaction's sidecars. diff --git a/ClashSharp/ClashSharp/Service/CoreConfigurationServiceFactory.cs b/ClashSharp/ClashSharp/Service/CoreConfigurationServiceFactory.cs index 7d0c05f..197428f 100644 --- a/ClashSharp/ClashSharp/Service/CoreConfigurationServiceFactory.cs +++ b/ClashSharp/ClashSharp/Service/CoreConfigurationServiceFactory.cs @@ -23,6 +23,7 @@ public static CoreConfigurationService CreateDefault() return new CoreConfigurationService( Path.Combine(AppDataPathService.ResolveLocalDataDirectory(), "mihomo"), new CoreConfigurationSettingsAdapter(AppSettingsService.Instance), + MihomoControllerCredentials.Instance, new CoreConfigurationProfileMetricsAdapter(), new CoreConfigurationValidator(), LocalizationService.Instance.GetString); @@ -36,8 +37,6 @@ internal sealed class CoreConfigurationSettingsAdapter(AppSettingsService settin public int MixedPort => settings.MixedPort; public string ActiveProfileId => settings.ActiveProfileId; - - public string MihomoControllerSecret => settings.MihomoControllerSecret; } internal sealed class CoreConfigurationProfileMetricsAdapter : ICoreConfigurationProfileMetrics diff --git a/ClashSharp/ClashSharp/Service/MihomoControllerClient.cs b/ClashSharp/ClashSharp/Service/MihomoControllerClient.cs index 8228984..31c540e 100644 --- a/ClashSharp/ClashSharp/Service/MihomoControllerClient.cs +++ b/ClashSharp/ClashSharp/Service/MihomoControllerClient.cs @@ -43,7 +43,7 @@ public sealed class MihomoControllerClient public static MihomoControllerClient Instance { get; } = new( SharedHttpClient, MihomoControllerEndpoint.BaseUri, - static () => AppSettingsService.Instance.MihomoControllerSecret, + static () => MihomoControllerCredentials.Instance.GetSecret(), static () => SharedAppControllerTransport.Capture() is not null, new MihomoControllerServiceBroker(MihomoServiceManager.Instance), SharedAppControllerTransport, @@ -75,7 +75,7 @@ public MihomoControllerClient() : this( SharedHttpClient, MihomoControllerEndpoint.BaseUri, - static () => AppSettingsService.Instance.MihomoControllerSecret, + static () => MihomoControllerCredentials.Instance.GetSecret(), static () => SharedAppControllerTransport.Capture() is not null, new MihomoControllerServiceBroker(MihomoServiceManager.Instance), SharedAppControllerTransport, diff --git a/ClashSharp/ClashSharp/Service/MihomoControllerCredentials.cs b/ClashSharp/ClashSharp/Service/MihomoControllerCredentials.cs new file mode 100644 index 0000000..9f328c5 --- /dev/null +++ b/ClashSharp/ClashSharp/Service/MihomoControllerCredentials.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Security; + +namespace ClashSharp.Service; + +/// Connects legacy runtime factories to the primary host's independent credential owner. +/// Binding occurs explicitly after verified startup, never from a constructor or a preferences getter. +internal sealed class MihomoControllerCredentials : IControllerCredentialProvider, IAppDataMaintenanceCredentials +{ + public static MihomoControllerCredentials Instance { get; } = new(); + private Binding? _binding; + + public void Bind(ControllerCredentialService credentials, MutationAdmissionBarrier admission) + { + ArgumentNullException.ThrowIfNull(credentials); + ArgumentNullException.ThrowIfNull(admission); + Binding? existing = Interlocked.CompareExchange(ref _binding, new(credentials, admission), null); + if (existing is not null && (!ReferenceEquals(existing.Credentials, credentials) || !ReferenceEquals(existing.Admission, admission))) + { + throw new InvalidOperationException("Controller credentials are already bound to another primary host."); + } + } + + public string GetSecret() => GetBinding().Credentials.GetSecret(); + + public void ClearAll(bool useTerminalAdmission, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Binding binding = GetBinding(); + using MutationAdmissionLease lease = useTerminalAdmission + ? binding.Admission.AcquireShutdownMaintenance() : binding.Admission.AcquireOrdinary(cancellationToken); + binding.Credentials.ClearAdmitted(lease, cancellationToken); + } + + private Binding GetBinding() => Volatile.Read(ref _binding) + ?? throw new ControllerCredentialException("controller.credential.not_initialized"); + + private sealed record Binding(ControllerCredentialService Credentials, MutationAdmissionBarrier Admission); +} diff --git a/ClashSharp/ClashSharp/Service/MihomoControllerEndpoint.cs b/ClashSharp/ClashSharp/Service/MihomoControllerEndpoint.cs index 506049f..27805db 100644 --- a/ClashSharp/ClashSharp/Service/MihomoControllerEndpoint.cs +++ b/ClashSharp/ClashSharp/Service/MihomoControllerEndpoint.cs @@ -1,4 +1,5 @@ using System; +using ClashSharp.Security; namespace ClashSharp.Service; @@ -17,21 +18,5 @@ internal static class MihomoControllerEndpoint public static Uri BaseUri { get; } = new($"http://{ListenAddress}/"); /// Returns whether a persisted controller secret has the generated 256-bit hex shape. - public static bool IsValidSecret(string? secret) - { - if (secret is not { Length: 64 }) - { - return false; - } - - foreach (char character in secret) - { - if (character is not (>= '0' and <= '9') and not (>= 'a' and <= 'f')) - { - return false; - } - } - - return true; - } + public static bool IsValidSecret(string? secret) => ControllerCredentialPolicy.IsValid(secret); } diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index b607cdb..2509e3a 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -1,6 +1,6 @@ # Settings generation cutover -版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口,以及 StartupTask、Sampling 的实际服务适配器;生产 composition 仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 +版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口,以及 StartupTask、Sampling 的实际服务适配器。控制端凭据已从偏好中拆分并接入生产启动、运行时和数据清理;生产偏好仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 ## 已实现的存储与迁移 @@ -40,6 +40,18 @@ 另外修复了启动服务的异常分类:探测和设置入口先检查完整异常图,嵌套致命异常保持原异常传播,不能返回未知状态或包装成 `StartupLaunchUpdateException`。两项回归先复现旧行为,再验证修复;持久 Running 记录保留给后续进程重新观察。 +## 独立控制端凭据 + +`AppSettingsService` 及其 editor 不再生成、读取或删除控制端凭据,核心配置偏好端口也不再携带 secret。`IControllerCredentialProvider` 只读取启动时已验证的进程凭据;HTTP、WebSocket 和配置生成使用同一提供者,独占设置操作期间的读取不访问存储、不获取新许可。生产主机拥有 `ControllerCredentialService`,现有静态运行时工厂通过显式启动绑定访问它。 + +`WindowsControllerCredentialStore` 只访问原来的 `MihomoControllerSecret` 槽位,不枚举偏好或私有未知值。存在且合法的旧凭据原样保留;缺失或类型、格式非法时生成 256 位随机凭据,写入后独立重读一致才发布。存储不可用或结果无法验证时阻止启动,不产生临时内存替代凭据;丢失写入回执但实际值已经匹配时完成初始化。异常只对外报告稳定代码,致命异常图继续传播。此 Windows API 使用主程序的包身份,符合[系统管理应用数据的适用范围](https://learn.microsoft.com/en-us/windows/apps/develop/data/store-and-retrieve-app-data)。 + +新增启动步骤顺序为 140,位于 Installer 事务门禁(125)之后、运行时恢复(150)之前。构造过程没有存储副作用,初始化失败时不绑定运行时消费者。主机释放会撤销进程中的可用凭据投影。 + +普通偏好重置保留凭据。清除全部数据先停止运行时,再通过独立维护能力删除并确认槽位不存在,随后清理其他数据。进入终态后使用 shutdown maintenance 许可;偏好删除已经开始时,页面取消不会中断后续凭据和文件清理。凭据删除无法确认会使清理失败并停止后续文件删除,避免声称已全部清除。凭据服务的释放发生在主机停止及数据维护之后。 + +当前生产装配已接入这项拆分;JSON 偏好权威和数据代际整体切换仍未激活。实际打包候选的启动验收将在对应 CI 包产出后执行。 + ## 代际服务寿命 `DataGenerationManager.ExecuteAsync` 在取得代际租约后,从该代际拥有的 `IServiceProvider` 解析服务,并等待完整操作结束才释放租约。`ReadSnapshot` 仅用于同步、无 I/O 的不可变内存快照,不阻塞异步任务。服务容器的异步释放仍由 `DataGenerationScope` 的原生命周期协议负责。 @@ -61,11 +73,15 @@ 实际服务适配和启动异常修复新增 16 项回归,本分支累计新增 109 项。最终完整主程序 2730 项通过,零失败、零跳过,用时 51 秒;18 项目 Release x64 构建零警告、零错误,用时 22.29 秒,format 检查 1473 个文件、零处变更。收据为 `local-validation-settings-generation-runtime.json`、`1.0.0-settings-runtime-final.trx`、`build-settings-runtime-final.log` 和 `format-settings-runtime-verified.log`。两项异常图红测保存在 `1.0.0-settings-runtime-fatal-red.trx`;此前夹具对初始 revision 和匹配 applied/pending 的错误构造也保留独立失败报告,不计为产品缺陷复现。 +运行时适配提交 `37f4c51` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34217135381),实际四份 TRX 共 4795 项通过、零失败、零跳过。合并提交 `95ee8d8` 与源提交的 tree 同为 `a85ebf2b51881d3a38f3e9321100edec68c4b014`,收据为 `ci-validation-settings-generation-runtime.json`。安装器包构建成功,此份包仅核验构建结果及元数据。 + +凭据拆分验证包含 30 项新用例,并将原先设置类中的凭据删除回归替换为独立数据维护职责的检查,净增 29 项。本分支主程序累计净增 138 项,完整 2759 项通过,零失败、零跳过,用时 52 秒;18 项目构建零警告、零错误,用时 27.21 秒,format 检查 1488 个文件、零处变更。定向 99 项通过;收据为 `local-validation-controller-credentials.json`、`1.0.0-controller-credentials-main.trx`、`build-controller-credentials-complete.log` 和 `format-controller-credentials-verified.log`。首次定向验证的退出夹具重复提交已经终态的许可,调整为可提交退出的独占许可后通过,原报告保留。 + 持久中断测试使用真实临时仓库、切点注入及新对象重开,运行时参与者为受控模拟。Windows 旧设置适配器已编译,未在开发机读取实际 LocalSettings。实际打包应用的迁移、进程崩溃、完整页面和安装器兼容验收将在生产切换后执行。开发机代理摘要保持 `95e97918ff6de70655b412568cd18dc81c5d6584c607bb9a71ddc72e22460447`。 ## 完整切换的剩余依赖 -1. 将偏好写入统一为应用层异步 change set;页面、磁贴、触发器和网络提交者使用同一个接口。控制端凭据迁移到独立的内部凭据端口。 +1. 将偏好写入统一为应用层异步 change set;页面、磁贴、触发器和网络提交者使用同一个接口。独立控制端凭据已接入生产调用,后续代际重置继续使用该能力。 2. 完成 Internal、Appearance、Network、Triggers 的实际 apply/probe 适配器,并将已实现的 StartupTask、Sampling 一起装配;明确读取 desired、有效状态和待办的消费者。 3. 在设置驱动的启动步骤之前完成旧事务恢复、代际打开和偏好迁移。profile/log/trigger 与 settings 必须由同一代际容器解析、排空和替换。 4. 将导入、重置和回滚接入候选代际及 manifest 提交,完成生产消费者替换后,原子替换 `SettingsAuthorityArchitectureTests` 中的临时门禁。 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index a4e3efc..e71a944 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -22,6 +22,8 @@ - 公共异步入口继续持有同一个代际,覆盖 desired 提交、全部受影响批次及验证;修复提交后开始退出排空会截断后续应用的衔接问题。追加 9 项回归,完整主程序 2714 项通过,构建零警告、零错误,format 检查 1464 个文件、0 处变更;本分支累计新增 93 项,收据为 `local-validation-settings-generation-facade.json`。 - 公共入口提交 `a08fc4b` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34214328575),实际四份 TRX 共 4779 项通过,零失败、零跳过;收据为 `ci-validation-settings-generation-facade.json`。 - StartupTask 和 Sampling 适配器使用生产服务进行应用与独立观察;采样循环持有已安装的间隔,并串行完成旧任务排空和新配置安装。修复启动探测吞掉嵌套致命异常、设置入口包装该异常的问题,两项回归先红后绿。追加 16 项回归后,完整主程序 2730 项通过,构建零警告、零错误,format 检查 1473 个文件、0 处变更;累计新增 109 项,收据为 `local-validation-settings-generation-runtime.json`。平台边界采用隔离模拟,尚未装配进生产设置权威。 +- 运行时适配提交 `37f4c51` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34217135381),实际四份 TRX 共 4795 项通过、零失败、零跳过;收据为 `ci-validation-settings-generation-runtime.json`。 +- 控制端凭据已从偏好及核心配置设置端口中移出,生产启动、控制端请求、配置生成和全数据清理改用独立凭据服务。写入、删除均独立重读确认;存储不可用阻止启动,普通偏好重置保留凭据,终态清理使用专门维护许可。30 项新用例替换一个旧设置类职责测试后净增 29 项,完整主程序 2759 项通过,构建零警告、零错误,format 检查 1488 个文件、0 处变更;收据为 `local-validation-controller-credentials.json`。Windows 存储边界使用隔离属性集测试,新的实际打包候选启动验收待 CI 包产出。 - 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。完整接入及验收继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 - main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 From f5a4502a68594fb57403689ace22938b76078b73 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 19:48:31 +0800 Subject: [PATCH 05/22] test: require completed startup in packaged Windows smoke --- .github/workflows/ci.yml | 8 +- ClashSharp/SandboxTest/Run-SandboxTest.ps1 | 2 +- .../SandboxTest/SandboxReportContract.psm1 | 8 +- .../SandboxTest/SandboxStartupEvidence.psm1 | 114 ++++++++++++++++++ .../Test-SandboxReportContract.ps1 | 12 +- .../Test-SandboxStartupEvidence.ps1 | 107 ++++++++++++++++ .../SandboxTest/scripts/Run-InSandbox.ps1 | 10 +- .../2026-09-08-settings-generation-cutover.md | 4 + docs/reviews/1.0.0-execution-ledger.md | 1 + 9 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 ClashSharp/SandboxTest/SandboxStartupEvidence.psm1 create mode 100644 ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f2f252..78a6ccb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,11 +60,15 @@ jobs: - name: Verify Sandbox report contract (Windows PowerShell 5.1) shell: powershell - run: ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 + run: | + ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 + ./ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 - name: Verify Sandbox report contract (PowerShell 7) shell: pwsh - run: ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 + run: | + ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 + ./ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 - name: Build Release run: dotnet build ClashSharp/ClashSharp.slnx -c Release -p:Platform=x64 --no-restore diff --git a/ClashSharp/SandboxTest/Run-SandboxTest.ps1 b/ClashSharp/SandboxTest/Run-SandboxTest.ps1 index c8fac5f..c5a9d0e 100644 --- a/ClashSharp/SandboxTest/Run-SandboxTest.ps1 +++ b/ClashSharp/SandboxTest/Run-SandboxTest.ps1 @@ -52,7 +52,7 @@ try { $outputPath = Join-Path $runPath 'reports' $null = [IO.Directory]::CreateDirectory($inputPath) $null = [IO.Directory]::CreateDirectory($outputPath) - foreach ($source in @('scripts\Run-InSandbox.ps1', 'SandboxInputContract.psm1')) { + foreach ($source in @('scripts\Run-InSandbox.ps1', 'SandboxInputContract.psm1', 'SandboxStartupEvidence.psm1')) { $scriptSource = Assert-ClashSharpOrdinaryPath (Join-Path $PSScriptRoot $source) -RequireFile Copy-Item -LiteralPath $scriptSource -Destination (Join-Path $inputPath ([IO.Path]::GetFileName($source))) } diff --git a/ClashSharp/SandboxTest/SandboxReportContract.psm1 b/ClashSharp/SandboxTest/SandboxReportContract.psm1 index ff57a79..ab7e202 100644 --- a/ClashSharp/SandboxTest/SandboxReportContract.psm1 +++ b/ClashSharp/SandboxTest/SandboxReportContract.psm1 @@ -1,4 +1,5 @@ Set-StrictMode -Version Latest +Import-Module (Join-Path $PSScriptRoot 'SandboxStartupEvidence.psm1') -Force $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'SandboxInputContract.psm1') @@ -95,9 +96,14 @@ function Assert-SandboxScenarioReport { if ($ExpectedPlan.scenario -ceq 'launch-no-proxy') { $launch = $Report.checks.launch Assert-SandboxObject $launch @('processId', 'packageFullName', 'executableSha256', - 'mainWindowObserved', 'stabilizationMs', 'termination') + 'mainWindowObserved', 'stabilizationMs', 'termination', 'startup') Assert-SandboxStringFields $launch @('packageFullName', 'executableSha256', 'termination') $launchStep = @($Report.steps | Where-Object { $_.name -ceq 'launch-package' })[0] + Assert-SandboxStartupEvidence $launch.startup + $launchStart = [DateTimeOffset]::ParseExact($launchStep.startedAt, 'o', [Globalization.CultureInfo]::InvariantCulture) + $launchFinish = [DateTimeOffset]::ParseExact($launchStep.finishedAt, 'o', [Globalization.CultureInfo]::InvariantCulture) + if ($launch.startup.startedAtUnixTime -lt $launchStart.ToUnixTimeSeconds() -or + $launch.startup.startedAtUnixTime -gt $launchFinish.ToUnixTimeSeconds()) { throw 'sandbox.report.startup_time' } if (($launch.processId -isnot [int] -and $launch.processId -isnot [long]) -or $launch.processId -le 0 -or $launch.packageFullName -cne $candidate.fullName -or $launch.executableSha256 -cne $candidate.executableSha256 -or diff --git a/ClashSharp/SandboxTest/SandboxStartupEvidence.psm1 b/ClashSharp/SandboxTest/SandboxStartupEvidence.psm1 new file mode 100644 index 0000000..2d34f56 --- /dev/null +++ b/ClashSharp/SandboxTest/SandboxStartupEvidence.psm1 @@ -0,0 +1,114 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-SandboxStartupEvidence { + <# + .SYNOPSIS + Requires completed credential, interactive shell, and final startup steps without failures. + .PARAMETER Evidence + Aggregate observations from the candidate's startup log after this launch began. + #> + param([object]$Evidence) + $names = @('credentialCompletions', 'windowCompletions', 'pipelineCompletions', 'failures', 'startedAtUnixTime') + if ($Evidence -isnot [pscustomobject] -or @($Evidence.PSObject.Properties).Count -ne $names.Count) { + throw 'sandbox.startup.evidence_shape' + } + foreach ($name in $names) { + if (@($Evidence.PSObject.Properties.Name) -cnotcontains $name -or + ($Evidence.$name -isnot [int] -and $Evidence.$name -isnot [long])) { + throw 'sandbox.startup.evidence_type' + } + } + if ($Evidence.credentialCompletions -ne 1 -or $Evidence.windowCompletions -ne 1 -or + $Evidence.pipelineCompletions -ne 1 -or $Evidence.failures -ne 0 -or $Evidence.startedAtUnixTime -le 0) { + throw 'sandbox.startup.not_ready' + } +} + +function Get-SandboxStartupEvidence { + <# + .SYNOPSIS + Reads only aggregate startup evidence from the newly launched candidate's SQLite log. + .DESCRIPTION + Opens the existing database read-only with the Windows system SQLite library. Never reads + credential values or returns log text. Native resources are closed on every result. + See https://learn.microsoft.com/dotnet/standard/data/sqlite/custom-versions and + https://sqlite.org/c3ref/open.html for the system provider and read-only open contract. + .PARAMETER LiteralPath + Exact existing log database in the owned guest's candidate LocalState directory. + .PARAMETER StartedAtUnixTime + UTC seconds captured immediately before starting this candidate process. + #> + param([Parameter(Mandatory)][string]$LiteralPath, + [Parameter(Mandatory)][ValidateRange(1, [long]::MaxValue)][long]$StartedAtUnixTime) + if (-not [IO.Path]::IsPathRooted($LiteralPath) -or -not (Test-Path -LiteralPath $LiteralPath -PathType Leaf)) { + throw 'sandbox.startup.database_missing' + } + if (-not ('ClashSharpSandboxStartupReader' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; +public static class ClashSharpSandboxStartupReader { + private const string Library = @"C:\Windows\System32\winsqlite3.dll"; + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_open_v2(byte[] file, out IntPtr db, int flags, IntPtr vfs); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_close(IntPtr db); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_db_readonly(IntPtr db, byte[] name); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_busy_timeout(IntPtr db, int milliseconds); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_prepare_v2(IntPtr db, byte[] sql, int length, out IntPtr statement, IntPtr tail); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_bind_int64(IntPtr statement, int index, long value); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_step(IntPtr statement); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern long sqlite3_column_int64(IntPtr statement, int column); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_finalize(IntPtr statement); + private static byte[] Utf8(string value) { return Encoding.UTF8.GetBytes(value + "\0"); } + public static long[] Read(string path, long startedAt) { + IntPtr db = IntPtr.Zero; + IntPtr statement = IntPtr.Zero; + try { + // SQLITE_OPEN_READONLY; no CREATE, URI, extension loading, or write fallback. + if (sqlite3_open_v2(Utf8(path), out db, 1, IntPtr.Zero) != 0 || + sqlite3_db_readonly(db, Utf8("main")) != 1 || sqlite3_busy_timeout(db, 1000) != 0) { + throw new InvalidOperationException("sandbox.startup.database_open"); + } + const string sql = @"SELECT + COUNT(CASE WHEN Message = 'Startup step ''controller-credential'' completed.' AND + Detail GLOB 'order=140; stage=Completed; outcome=Succeeded; code=; elapsedMs=*' THEN 1 END), + COUNT(CASE WHEN Message = 'Startup step ''window-shell'' completed.' AND + Detail GLOB 'order=600; stage=Completed; outcome=Succeeded; code=; elapsedMs=*' THEN 1 END), + COUNT(CASE WHEN Message = 'Startup step ''profile-subscription-updates'' completed.' AND + Detail GLOB 'order=710; stage=Completed; outcome=Succeeded; code=; elapsedMs=*' THEN 1 END), + COUNT(CASE WHEN Level = 'Error' THEN 1 END) + FROM Logs WHERE Source = 'StartupPipeline' AND CreatedAtUnixTime >= ?1"; + if (sqlite3_prepare_v2(db, Utf8(sql), -1, out statement, IntPtr.Zero) != 0 || + sqlite3_bind_int64(statement, 1, startedAt) != 0 || sqlite3_step(statement) != 100) { + throw new InvalidOperationException("sandbox.startup.database_query"); + } + long[] counts = new long[4]; + for (int index = 0; index < counts.Length; index++) { counts[index] = sqlite3_column_int64(statement, index); } + if (sqlite3_step(statement) != 101) { throw new InvalidOperationException("sandbox.startup.database_result"); } + return counts; + } finally { + if (statement != IntPtr.Zero) { sqlite3_finalize(statement); } + if (db != IntPtr.Zero) { sqlite3_close(db); } + } + } +} +'@ + } + $counts = [ClashSharpSandboxStartupReader]::Read($LiteralPath, $StartedAtUnixTime) + $evidence = [pscustomobject]@{ credentialCompletions = $counts[0]; windowCompletions = $counts[1] + pipelineCompletions = $counts[2]; failures = $counts[3]; startedAtUnixTime = $StartedAtUnixTime } + Assert-SandboxStartupEvidence $evidence + return $evidence +} + +Export-ModuleMember -Function Assert-SandboxStartupEvidence, Get-SandboxStartupEvidence diff --git a/ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 b/ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 index d04d207..f6f4a97 100644 --- a/ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 +++ b/ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 @@ -86,7 +86,9 @@ function New-SandboxReportFixture { } if ($Scenario -ceq 'launch-no-proxy') { $checks.launch = @{ processId = 123; packageFullName = $fullName; executableSha256 = ('a' * 64) - mainWindowObserved = $true; stabilizationMs = 30000; termination = 'owned-process' } + mainWindowObserved = $true; stabilizationMs = 30000; termination = 'owned-process' + startup = @{ credentialCompletions = 1; windowCompletions = 1; pipelineCompletions = 1 + failures = 0; startedAtUnixTime = $start.AddSeconds(7).ToUnixTimeSeconds() } } } return (Copy-SandboxFixture ([ordered]@{ schemaVersion = 2; scenario = $Scenario runId = $validPlan.runId; sandboxId = $validPlan.sandboxId; planSha256 = $planHash @@ -169,6 +171,14 @@ $reportCases = @( @{ Name = 'unpackaged process'; Change = { param($r) $r.checks.launch.packageFullName = '' } }, @{ Name = 'wrong executable'; Change = { param($r) $r.checks.launch.executableSha256 = 'd' * 64 } }, @{ Name = 'no window'; Change = { param($r) $r.checks.launch.mainWindowObserved = $false } }, + @{ Name = 'startup shell only'; Change = { param($r) $r.checks.launch.startup.windowCompletions = 0 } }, + @{ Name = 'credentials not initialized'; Change = { param($r) $r.checks.launch.startup.credentialCompletions = 0 } }, + @{ Name = 'pipeline incomplete'; Change = { param($r) $r.checks.launch.startup.pipelineCompletions = 0 } }, + @{ Name = 'startup failure'; Change = { param($r) $r.checks.launch.startup.failures = 1 } }, + @{ Name = 'duplicate startup'; Change = { param($r) $r.checks.launch.startup.credentialCompletions = 2 } }, + @{ Name = 'string readiness'; Change = { param($r) $r.checks.launch.startup.windowCompletions = '1' } }, + @{ Name = 'stale readiness'; Change = { param($r) $r.checks.launch.startup.startedAtUnixTime-- } }, + @{ Name = 'future readiness'; Change = { param($r) $r.checks.launch.startup.startedAtUnixTime += 31 } }, @{ Name = 'no stabilization'; Change = { param($r) $r.checks.launch.stabilizationMs = 29999 } }, @{ Name = 'observation exceeds step duration'; Change = { param($r) $r.checks.launch.stabilizationMs = 30001 } }, @{ Name = 'claims graceful exit'; Change = { param($r) $r.checks.launch.termination = 'graceful' } } diff --git a/ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 b/ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 new file mode 100644 index 0000000..3e42412 --- /dev/null +++ b/ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 @@ -0,0 +1,107 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Verifies startup evidence using isolated SQLite fixtures without opening application data. +#> +[CmdletBinding()] +param() +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'SandboxStartupEvidence.psm1') -Force +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; +public static class ClashSharpSandboxStartupFixture { + private const string Library = @"C:\Windows\System32\winsqlite3.dll"; + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_open_v2(byte[] file, out IntPtr db, int flags, IntPtr vfs); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_exec(IntPtr db, byte[] sql, IntPtr callback, IntPtr context, IntPtr error); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_close(IntPtr db); + public static void Create(string path, string sql) { + IntPtr db = IntPtr.Zero; + try { + if (sqlite3_open_v2(Encoding.UTF8.GetBytes(path + "\0"), out db, 6, IntPtr.Zero) != 0 || + sqlite3_exec(db, Encoding.UTF8.GetBytes(sql + "\0"), IntPtr.Zero, IntPtr.Zero, IntPtr.Zero) != 0) { + throw new InvalidOperationException("sandbox.fixture.database_failed"); + } + } finally { if (db != IntPtr.Zero) { sqlite3_close(db); } } + } +} +'@ +$testParent = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '.sandbox\startup-evidence-tests')) +$testRoot = Join-Path $testParent ([Guid]::NewGuid().ToString('N')) +$null = [IO.Directory]::CreateDirectory($testRoot) +$startedAt = 1788860000L +$assertions = 0 +$fixtureSql = @' +CREATE TABLE Logs (CreatedAtUnixTime INTEGER, Level TEXT, Source TEXT, Message TEXT, Detail TEXT); +INSERT INTO Logs VALUES (1788860000, 'Info', 'StartupPipeline', 'Startup step ''controller-credential'' completed.', 'order=140; stage=Completed; outcome=Succeeded; code=; elapsedMs=1.000; exceptionType=; exceptionMessage='); +INSERT INTO Logs VALUES (1788860000, 'Info', 'StartupPipeline', 'Startup step ''window-shell'' completed.', 'order=600; stage=Completed; outcome=Succeeded; code=; elapsedMs=1.000; exceptionType=; exceptionMessage='); +INSERT INTO Logs VALUES (1788860000, 'Info', 'StartupPipeline', 'Startup step ''profile-subscription-updates'' completed.', 'order=710; stage=Completed; outcome=Succeeded; code=; elapsedMs=1.000; exceptionType=; exceptionMessage='); +'@ +function Assert-StartupReadRejected { + <# + .SYNOPSIS + Requires a real SQLite read to reject incomplete or unavailable startup evidence. + .PARAMETER Path + Owned fixture database path. + #> + param([string]$Path) + $rejected = $false + try { $null = Get-SandboxStartupEvidence -LiteralPath $Path -StartedAtUnixTime $startedAt } + catch { $rejected = $true } + if (-not $rejected) { throw 'Accepted incomplete startup fixture.' } + $script:assertions++ +} +try { + $validPath = Join-Path $testRoot "candidate ' unicode-$([char]0x4E2D).sqlite3" + [ClashSharpSandboxStartupFixture]::Create($validPath, $fixtureSql) + $beforeHash = (Get-FileHash -LiteralPath $validPath).Hash + $result = Get-SandboxStartupEvidence -LiteralPath $validPath -StartedAtUnixTime $startedAt + if ($result.credentialCompletions -ne 1 -or $result.windowCompletions -ne 1 -or + $result.pipelineCompletions -ne 1 -or $result.failures -ne 0 -or + (Get-FileHash -LiteralPath $validPath).Hash -cne $beforeHash) { throw 'Read-only startup query mismatch.' } + $assertions++ + [IO.File]::SetAttributes($validPath, [IO.FileAttributes]::ReadOnly) + try { $null = Get-SandboxStartupEvidence -LiteralPath $validPath -StartedAtUnixTime $startedAt; $assertions++ } + finally { [IO.File]::SetAttributes($validPath, [IO.FileAttributes]::Normal) } + $locked = [IO.File]::Open($validPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) + try { Assert-StartupReadRejected $validPath } finally { $locked.Dispose() } + $null = Get-SandboxStartupEvidence -LiteralPath $validPath -StartedAtUnixTime $startedAt + $assertions++ + $invalidCases = @( + 'UPDATE Logs SET CreatedAtUnixTime = 1788859999;', + "DELETE FROM Logs WHERE Detail LIKE 'order=140;%';", + "DELETE FROM Logs WHERE Detail LIKE 'order=600;%';", + "DELETE FROM Logs WHERE Detail LIKE 'order=710;%';", + "UPDATE Logs SET Detail = REPLACE(Detail, 'outcome=Succeeded', 'outcome=Fatal');", + "UPDATE Logs SET Detail = REPLACE(Detail, 'stage=Completed', 'stage=Started');", + "UPDATE Logs SET Source = 'Unrelated';", + "INSERT INTO Logs SELECT * FROM Logs WHERE Detail LIKE 'order=140;%';", + "INSERT INTO Logs VALUES (1788860000, 'Error', 'StartupPipeline', 'failed', 'private-fixture-detail');", + 'DROP TABLE Logs; CREATE TABLE Logs (wrong TEXT);' + ) + for ($index = 0; $index -lt $invalidCases.Count; $index++) { + $invalidPath = Join-Path $testRoot ('invalid-' + $index + '.sqlite3') + [ClashSharpSandboxStartupFixture]::Create($invalidPath, ($fixtureSql + [Environment]::NewLine + $invalidCases[$index])) + $invalidHash = (Get-FileHash -LiteralPath $invalidPath).Hash + Assert-StartupReadRejected $invalidPath + if ((Get-FileHash -LiteralPath $invalidPath).Hash -cne $invalidHash) { throw 'Rejected evidence was modified.' } + } + $missingPath = Join-Path $testRoot 'missing.sqlite3' + Assert-StartupReadRejected $missingPath + if (Test-Path -LiteralPath $missingPath) { throw 'Read created a missing database.' } + $corruptPath = Join-Path $testRoot 'corrupt.sqlite3' + [IO.File]::WriteAllText($corruptPath, 'private-invalid-database-fixture') + Assert-StartupReadRejected $corruptPath + if ([IO.File]::ReadAllText($corruptPath) -cne 'private-invalid-database-fixture') { throw 'Corrupt evidence was overwritten.' } + Write-Output "Sandbox startup evidence: $assertions assertions passed." +} finally { + $resolved = [IO.Path]::GetFullPath($testRoot) + if (-not $resolved.StartsWith($testParent + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or + (Get-Item -LiteralPath $resolved).Attributes.HasFlag([IO.FileAttributes]::ReparsePoint)) { throw 'Unexpected fixture cleanup path.' } + Remove-Item -LiteralPath $resolved -Recurse -Force -ErrorAction Stop +} diff --git a/ClashSharp/SandboxTest/scripts/Run-InSandbox.ps1 b/ClashSharp/SandboxTest/scripts/Run-InSandbox.ps1 index 65a3cd8..474c12b 100644 --- a/ClashSharp/SandboxTest/scripts/Run-InSandbox.ps1 +++ b/ClashSharp/SandboxTest/scripts/Run-InSandbox.ps1 @@ -219,6 +219,7 @@ public static class SandboxProcessIdentity { $startInfo = [Diagnostics.ProcessStartInfo]::new($executable) $startInfo.UseShellExecute = $false $startInfo.WorkingDirectory = $installedPackage.InstallLocation + $launchStartedAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $script:launched = [Diagnostics.Process]::Start($startInfo) if ([SandboxProcessIdentity]::Read([uint32]$launched.Id) -cne $candidate.fullName) { throw 'sandbox.process.wrong_package' @@ -239,9 +240,16 @@ public static class SandboxProcessIdentity { throw 'sandbox.window.unstable' } } + Import-Module 'C:\ClashSharpTestInput\SandboxStartupEvidence.psm1' -Force -ErrorAction Stop + $logPath = Join-Path $env:LOCALAPPDATA ('Packages\' + $candidate.familyName + '\LocalState\ClashSharpLogs.sqlite3') + $startup = Get-SandboxStartupEvidence -LiteralPath $logPath -StartedAtUnixTime $launchStartedAt + $launched.Refresh() + if ($launched.HasExited -or $launched.MainWindowHandle -eq [IntPtr]::Zero) { + throw 'sandbox.window.unstable' + } $checks.launch = [ordered]@{ processId = $launched.Id; packageFullName = $candidate.fullName executableSha256 = (Get-SandboxFileSha256 $executable); mainWindowObserved = $true - stabilizationMs = [long]$stable.Elapsed.TotalMilliseconds; termination = 'owned-process' } + stabilizationMs = [long]$stable.Elapsed.TotalMilliseconds; termination = 'owned-process'; startup = $startup } } } } catch { diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index 2509e3a..e5bc7ba 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -50,6 +50,8 @@ 普通偏好重置保留凭据。清除全部数据先停止运行时,再通过独立维护能力删除并确认槽位不存在,随后清理其他数据。进入终态后使用 shutdown maintenance 许可;偏好删除已经开始时,页面取消不会中断后续凭据和文件清理。凭据删除无法确认会使清理失败并停止后续文件删除,避免声称已全部清除。凭据服务的释放发生在主机停止及数据维护之后。 +原有 `launch-no-proxy` 验收只确认包身份、窗口存在及稳定时长;启动错误页也可能满足该条件。现补充读取候选本次启动之后的 SQLite 日志聚合,要求 controller-credential(140)、window-shell(600)和最后的 profile-subscription-updates(710)各完成一次且成功,并且没有启动错误。查询使用系统 SQLite 只读连接,不读取凭据值、不输出日志内容,结果时间范围绑定实际 launch 步骤。PowerShell 5.1 与 7 均通过 111 项报告断言和 16 项真实隔离 SQLite 断言,覆盖错误页、未完成流程、重复或过期记录、锁定及损坏数据库;日志为 `startup-evidence-powershell51.log` 和 `startup-evidence-powershell7.log`。这些检查仍不代表全部页面交互或正常安装器流程已验收。 + 当前生产装配已接入这项拆分;JSON 偏好权威和数据代际整体切换仍未激活。实际打包候选的启动验收将在对应 CI 包产出后执行。 ## 代际服务寿命 @@ -77,6 +79,8 @@ 凭据拆分验证包含 30 项新用例,并将原先设置类中的凭据删除回归替换为独立数据维护职责的检查,净增 29 项。本分支主程序累计净增 138 项,完整 2759 项通过,零失败、零跳过,用时 52 秒;18 项目构建零警告、零错误,用时 27.21 秒,format 检查 1488 个文件、零处变更。定向 99 项通过;收据为 `local-validation-controller-credentials.json`、`1.0.0-controller-credentials-main.trx`、`build-controller-credentials-complete.log` 和 `format-controller-credentials-verified.log`。首次定向验证的退出夹具重复提交已经终态的许可,调整为可提交退出的独占许可后通过,原报告保留。 +凭据拆分提交 `e9026f8` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34221242635),实际四份 TRX 共 4824 项通过、零失败、零跳过,30 项新凭据用例均核验身份并实际执行。合并提交 `46154b7` 与源提交 tree 同为 `769cfda3cc5b10234366a11dd6e24102e1bc4ab0`,收据为 `ci-validation-controller-credentials.json`。候选开发安装器包已经构建成功;新增启动完成状态检查将在这份候选上进行隔离验收。 + 持久中断测试使用真实临时仓库、切点注入及新对象重开,运行时参与者为受控模拟。Windows 旧设置适配器已编译,未在开发机读取实际 LocalSettings。实际打包应用的迁移、进程崩溃、完整页面和安装器兼容验收将在生产切换后执行。开发机代理摘要保持 `95e97918ff6de70655b412568cd18dc81c5d6584c607bb9a71ddc72e22460447`。 ## 完整切换的剩余依赖 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index e71a944..41a7656 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -24,6 +24,7 @@ - StartupTask 和 Sampling 适配器使用生产服务进行应用与独立观察;采样循环持有已安装的间隔,并串行完成旧任务排空和新配置安装。修复启动探测吞掉嵌套致命异常、设置入口包装该异常的问题,两项回归先红后绿。追加 16 项回归后,完整主程序 2730 项通过,构建零警告、零错误,format 检查 1473 个文件、0 处变更;累计新增 109 项,收据为 `local-validation-settings-generation-runtime.json`。平台边界采用隔离模拟,尚未装配进生产设置权威。 - 运行时适配提交 `37f4c51` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34217135381),实际四份 TRX 共 4795 项通过、零失败、零跳过;收据为 `ci-validation-settings-generation-runtime.json`。 - 控制端凭据已从偏好及核心配置设置端口中移出,生产启动、控制端请求、配置生成和全数据清理改用独立凭据服务。写入、删除均独立重读确认;存储不可用阻止启动,普通偏好重置保留凭据,终态清理使用专门维护许可。30 项新用例替换一个旧设置类职责测试后净增 29 项,完整主程序 2759 项通过,构建零警告、零错误,format 检查 1488 个文件、0 处变更;收据为 `local-validation-controller-credentials.json`。Windows 存储边界使用隔离属性集测试,新的实际打包候选启动验收待 CI 包产出。 +- 凭据拆分提交 `e9026f8` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34221242635),实际四份 TRX 共 4824 项通过,30 项新凭据用例全部实际执行;收据为 `ci-validation-controller-credentials.json`。已补充 Sandbox 启动完成状态检查,避免把持续显示的启动错误页算作成功;PowerShell 两版本各通过 111 项报告断言及 16 项隔离 SQLite 断言。新候选的实际运行证据仍待取得。 - 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。完整接入及验收继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 - main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 From 3dbbb4eacfe352da8fbeaef25aad8d45d881e12e Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 20:10:07 +0800 Subject: [PATCH 06/22] docs: complete Sandbox helper help and record native acceptance --- ClashSharp/SandboxTest/SandboxStartupEvidence.psm1 | 2 ++ ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 | 2 ++ docs/design/2026-09-08-settings-generation-cutover.md | 6 +++++- docs/reviews/1.0.0-execution-ledger.md | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/ClashSharp/SandboxTest/SandboxStartupEvidence.psm1 b/ClashSharp/SandboxTest/SandboxStartupEvidence.psm1 index 2d34f56..d299745 100644 --- a/ClashSharp/SandboxTest/SandboxStartupEvidence.psm1 +++ b/ClashSharp/SandboxTest/SandboxStartupEvidence.psm1 @@ -5,6 +5,8 @@ function Assert-SandboxStartupEvidence { <# .SYNOPSIS Requires completed credential, interactive shell, and final startup steps without failures. + .DESCRIPTION + Rejects partial, duplicated, failed, or incorrectly typed aggregate observations. .PARAMETER Evidence Aggregate observations from the candidate's startup log after this launch began. #> diff --git a/ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 b/ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 index 3e42412..0f3beb5 100644 --- a/ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 +++ b/ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 @@ -46,6 +46,8 @@ function Assert-StartupReadRejected { <# .SYNOPSIS Requires a real SQLite read to reject incomplete or unavailable startup evidence. + .DESCRIPTION + Counts a negative assertion only when the production reader rejects the owned fixture. .PARAMETER Path Owned fixture database path. #> diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index e5bc7ba..321b552 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -79,7 +79,11 @@ 凭据拆分验证包含 30 项新用例,并将原先设置类中的凭据删除回归替换为独立数据维护职责的检查,净增 29 项。本分支主程序累计净增 138 项,完整 2759 项通过,零失败、零跳过,用时 52 秒;18 项目构建零警告、零错误,用时 27.21 秒,format 检查 1488 个文件、零处变更。定向 99 项通过;收据为 `local-validation-controller-credentials.json`、`1.0.0-controller-credentials-main.trx`、`build-controller-credentials-complete.log` 和 `format-controller-credentials-verified.log`。首次定向验证的退出夹具重复提交已经终态的许可,调整为可提交退出的独占许可后通过,原报告保留。 -凭据拆分提交 `e9026f8` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34221242635),实际四份 TRX 共 4824 项通过、零失败、零跳过,30 项新凭据用例均核验身份并实际执行。合并提交 `46154b7` 与源提交 tree 同为 `769cfda3cc5b10234366a11dd6e24102e1bc4ab0`,收据为 `ci-validation-controller-credentials.json`。候选开发安装器包已经构建成功;新增启动完成状态检查将在这份候选上进行隔离验收。 +凭据拆分提交 `e9026f8` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34221242635),实际四份 TRX 共 4824 项通过、零失败、零跳过,30 项新凭据用例均核验身份并实际执行。合并提交 `46154b7` 与源提交 tree 同为 `769cfda3cc5b10234366a11dd6e24102e1bc4ab0`,收据为 `ci-validation-controller-credentials.json`。开发安装器制品 `10054117859` 共 317646418 字节、8 个文件,安装器版本为 `1.0.0+46154b776b2fbb501a388bee6a3f484905353f96`。整包请求三次返回存储端 `OperationTimedOut` 后,以分段请求取得完整 ZIP,其 SHA-256 与 CI 元数据一致;收据为 `installer-artifact-controller-credentials.json`。 + +实际 Windows Sandbox 验收使用上述 CI 候选和 `f5a4502` 的验收脚本,二者之间没有程序代码变更。MSIX SHA-256 为 `653c839f2a5c036e6e00622a7f6a1986ed1bcec8b52238e253702849f1a4458c`;运行 `10d6f352c4c3452791e170ff7a32f13f` 在 2026-09-08 11:56 UTC 通过全部 12 步。实际包身份与进程匹配,窗口稳定 30247 毫秒,凭据、交互主窗和最后启动步骤各成功一次,启动失败记录为零。7 项来宾清理均成功,沙箱 `3f577a24-d645-4158-a3f1-99ecb3d4f195` 已销毁,输入及主机代理保持不变;收据为 `sandbox-package-validation-controller-credentials.json`。此运行没有调用正常 WPF 安装器,不代表完整页面交互或优雅退出验收。 + +验收脚本提交 `f5a4502` 的 CI 安装器构建成功,主程序 2758 项通过、1 项失败:仓库规范检查发现两个 PowerShell 辅助函数缺少 `.DESCRIPTION`。已补齐说明,本地 16 项仓库规范测试全部通过;报告为 `1.0.0-startup-evidence-topology.trx`。原日志和制品保存在 `ci-startup-evidence-failed.log` 与 `ci-tests-startup-evidence-initial.zip`,收据为 `ci-validation-startup-evidence-initial.json`;该失败不涉及程序运行行为。实际 CI 的两版 PowerShell 也各通过 111 项报告及 16 项 SQLite 断言。远端绿色载荷复验的 SSH 在握手阶段关闭,未上传此候选,原有远端 main 仍为 `e3f597c`。 持久中断测试使用真实临时仓库、切点注入及新对象重开,运行时参与者为受控模拟。Windows 旧设置适配器已编译,未在开发机读取实际 LocalSettings。实际打包应用的迁移、进程崩溃、完整页面和安装器兼容验收将在生产切换后执行。开发机代理摘要保持 `95e97918ff6de70655b412568cd18dc81c5d6584c607bb9a71ddc72e22460447`。 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index 41a7656..2a093d5 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -25,6 +25,7 @@ - 运行时适配提交 `37f4c51` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34217135381),实际四份 TRX 共 4795 项通过、零失败、零跳过;收据为 `ci-validation-settings-generation-runtime.json`。 - 控制端凭据已从偏好及核心配置设置端口中移出,生产启动、控制端请求、配置生成和全数据清理改用独立凭据服务。写入、删除均独立重读确认;存储不可用阻止启动,普通偏好重置保留凭据,终态清理使用专门维护许可。30 项新用例替换一个旧设置类职责测试后净增 29 项,完整主程序 2759 项通过,构建零警告、零错误,format 检查 1488 个文件、0 处变更;收据为 `local-validation-controller-credentials.json`。Windows 存储边界使用隔离属性集测试,新的实际打包候选启动验收待 CI 包产出。 - 凭据拆分提交 `e9026f8` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34221242635),实际四份 TRX 共 4824 项通过,30 项新凭据用例全部实际执行;收据为 `ci-validation-controller-credentials.json`。已补充 Sandbox 启动完成状态检查,避免把持续显示的启动错误页算作成功;PowerShell 两版本各通过 111 项报告断言及 16 项隔离 SQLite 断言。新候选的实际运行证据仍待取得。 +- 上述 CI 候选已通过新的实际 Windows 启动验收:12 步、窗口稳定 30247 毫秒,凭据、主窗口和最后启动步骤各成功一次且没有启动错误;7 项清理完成,沙箱销毁,主机代理不变。收据为 `sandbox-package-validation-controller-credentials.json`,绑定源 `e9026f8`、构建 `46154b7` 和脚本 `f5a4502`。脚本 CI 暴露的两个 `.DESCRIPTION` 缺失已补齐;远端 SSH 握手关闭使此候选的绿色载荷复验暂未执行。正常 WPF 安装器和完整页面交互仍未据此判定通过。 - 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。完整接入及验收继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 - main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 From 9a331baacc3322b5d615644b5cde3381ba265a44 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 21:02:57 +0800 Subject: [PATCH 07/22] feat: apply trigger settings through the owned scheduler --- .../Settings/GenerationSettingsAuthority.cs | 31 +- .../Settings/ISettingsAuthority.cs | 3 + .../TriggersSettingsParticipantTests.cs | 664 ++++++++++++++++++ .../Settings/SettingsParticipantBinding.cs | 1 + .../AppHost/Settings/TriggerSettingsState.cs | 32 + .../Settings/TriggersSettingsParticipant.cs | 108 +++ .../2026-09-08-settings-generation-cutover.md | 20 +- docs/reviews/1.0.0-execution-ledger.md | 4 +- 8 files changed, 851 insertions(+), 12 deletions(-) create mode 100644 ClashSharp/ClashSharp.Tests/Integration/TriggersSettingsParticipantTests.cs create mode 100644 ClashSharp/ClashSharp/AppHost/Settings/TriggerSettingsState.cs create mode 100644 ClashSharp/ClashSharp/AppHost/Settings/TriggersSettingsParticipant.cs diff --git a/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs b/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs index 35d3c35..a66a6f0 100644 --- a/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs +++ b/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs @@ -34,7 +34,8 @@ public Task ApplyChangesAsync( { ArgumentNullException.ThrowIfNull(changes); SettingValueChange[] snapshot = changes.ToArray(); - return ExecuteOrdinaryAsync((context, lease, token) => ChangeAndApplyAsync(context, snapshot, transactionId, lease, token), cancellationToken); + return ExecuteConsumerAsync((context, lease, token) => ChangeAndApplyAsync(context, snapshot, transactionId, lease, token), + RequiresProducerDrain(snapshot.Select(change => change.Key)), cancellationToken); } /// @@ -43,6 +44,7 @@ public Task ApplyChangesAdmittedAsync( { ArgumentNullException.ThrowIfNull(changes); SettingValueChange[] snapshot = changes.ToArray(); + if (RequiresProducerDrain(snapshot.Select(change => change.Key))) { _admission.EnsureActiveExclusiveLease(admissionLease); } return ExecuteAdmittedAsync((context, lease, token) => ChangeAndApplyAsync(context, snapshot, transactionId, lease, token), admissionLease, cancellationToken); } @@ -52,22 +54,22 @@ public Task RevertAsync(IEnumerable keys, G { ArgumentNullException.ThrowIfNull(keys); SettingKey[] snapshot = keys.ToArray(); - return ExecuteOrdinaryAsync(async (context, lease, token) => + return ExecuteConsumerAsync(async (context, lease, token) => { SettingsAuthorityResult reverted = await context.Session.RevertAdmittedAsync(snapshot, transactionId, lease, token).ConfigureAwait(false); return await ApplyAffectedAsync(context, reverted, snapshot.ToHashSet(), SettingsApplicationPhase.Live, lease).ConfigureAwait(false); - }, cancellationToken); + }, RequiresProducerDrain(snapshot), cancellationToken); } /// public Task RetryAsync(Guid batchId, Guid expectedAttemptId, Guid newAttemptId, CancellationToken cancellationToken) => - ExecuteOrdinaryAsync(async (context, lease, token) => + ExecuteConsumerAsync(async (context, lease, token) => { SettingsAuthorityResult retry = await context.Session.RetryAdmittedAsync(batchId, expectedAttemptId, newAttemptId, lease, token).ConfigureAwait(false); if (!retry.IsSucceeded) { return retry; } SettingsApplicationBatch batch = retry.Envelope!.PendingApplications.Single(item => item.BatchId == batchId); return await ApplyOneAsync(context, retry.Envelope, batch, SettingsApplicationPhase.Live, lease).ConfigureAwait(false); - }, cancellationToken); + }, drainProducers: true, cancellationToken); /// public Task ReconcileStartupAdmittedAsync( @@ -123,11 +125,24 @@ private static Task ApplyOneAsync( return context.Session.ContinueCommittedBatchAdmittedAsync(batch.BatchId, batch.AttemptId, participant, phase, lease); } - private async Task ExecuteOrdinaryAsync( + private static bool RequiresProducerDrain(IEnumerable keys) => keys.Any(key => + key == SettingsRegistry.Keys.TriggersEnabled || key == SettingsRegistry.Keys.TriggerNotificationsEnabled); + + private Task ExecuteOrdinaryAsync( + Func> command, + CancellationToken cancellationToken) => ExecuteConsumerAsync(command, drainProducers: false, cancellationToken); + + private async Task ExecuteConsumerAsync( Func> command, - CancellationToken cancellationToken) + bool drainProducers, CancellationToken cancellationToken) { - await using MutationAdmissionLease lease = await _admission.AcquireOrdinaryAsync(cancellationToken).ConfigureAwait(false); + // A trigger evaluation can itself submit a settings command. Drain/revoke its ordinary + // authority before taking the command gate, so quiescence never waits on that gate's owner. + // Retry resolves its batch inside the pinned command; exclusive admission avoids a racy + // preflight lookup and covers a retry that must quiesce the trigger scheduler. + await using MutationAdmissionLease lease = drainProducers + ? await _admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, cancellationToken).ConfigureAwait(false) + : await _admission.AcquireOrdinaryAsync(cancellationToken).ConfigureAwait(false); return await ExecuteAdmittedAsync(command, lease, cancellationToken).ConfigureAwait(false); } diff --git a/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs b/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs index c15da8f..177f1f7 100644 --- a/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs +++ b/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs @@ -14,6 +14,7 @@ public interface ISettingsAuthority Task OpenAsync(CancellationToken cancellationToken); /// Commits a complete desired change set and verifies affected application batches under one generation pin. + /// Changes that quiesce settings-producing triggers acquire exclusive admission before the command gate. /// Canonical typed desired changes copied before waiting. /// Stable identity of the requested change set. /// Cancels waiting and work before the desired publication boundary. @@ -21,6 +22,7 @@ Task ApplyChangesAsync( IEnumerable changes, Guid transactionId, CancellationToken cancellationToken); /// Performs the complete change and application using an existing caller-owned admission lease. + /// Trigger settings require an exclusive lease; an ordinary lease is rejected before desired publication. /// Canonical typed desired changes copied before waiting. /// Stable identity of the change set. /// Active lease retained by the caller until the complete command finishes. @@ -36,6 +38,7 @@ Task ApplyChangesAdmittedAsync( Task RevertAsync(IEnumerable keys, Guid transactionId, CancellationToken cancellationToken); /// Explicitly retries a failed attempt under a fresh identity and verifies its effect. + /// Acquires exclusive admission before resolving the failed batch so runtime producers cannot form a wait cycle. /// Exact failed batch. /// Identity of the failed attempt being replaced. /// Fresh nonempty retry identity. diff --git a/ClashSharp/ClashSharp.Tests/Integration/TriggersSettingsParticipantTests.cs b/ClashSharp/ClashSharp.Tests/Integration/TriggersSettingsParticipantTests.cs new file mode 100644 index 0000000..35b6f23 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/TriggersSettingsParticipantTests.cs @@ -0,0 +1,664 @@ +extern alias ClashSharpUi; + +using System.Threading.Channels; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Lifecycle; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.ApplicationModel.Triggers; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Model.Triggers; +using ClashSharp.Settings; +using ClashSharp.Tests.Unit.Settings; +using TriggerFiredNotificationAdapter = ClashSharpUi::ClashSharp.Service.TriggerFiredNotificationAdapter; +using TriggerSettingsState = ClashSharpUi::ClashSharp.Hosting.Settings.TriggerSettingsState; +using TriggersSettingsParticipant = ClashSharpUi::ClashSharp.Hosting.Settings.TriggersSettingsParticipant; + +namespace ClashSharp.Tests.Integration; + +/// Exercises generation-owned trigger enablement against the real scheduler and durable settings session. +public sealed class TriggersSettingsParticipantTests +{ + [Fact] + public async Task Facade_DrainsSettingsProducingEvaluationsBeforeTakingItsCommandGate() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + TaskCompletionSource entered = Signal(); + TaskCompletionSource continueProducer = Signal(); + TaskCompletionSource cancelledBeforePublication = Signal(); + TaskCompletionSource> nestedStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource paused = Signal(); + using CancellationTokenSource cleanupCancellation = new(); + fixture.Events.OnUnsubscribe = () => paused.TrySetResult(); + fixture.Evaluator.Handler = async () => + { + using MutationAdmissionLease producerLease = fixture.Admission.AcquireOrdinary(); + entered.TrySetResult(); + await continueProducer.Task; + try + { + Task nested = fixture.Authority.ApplyChangesAdmittedAsync( + [new(SettingsRegistry.Keys.NotificationEnabled, SettingsEnvelopeTestData.Value("NotificationEnabled", "false"))], + Guid.NewGuid(), producerLease, cleanupCancellation.Token); + nestedStarted.TrySetResult(nested); + await nested; + } + catch (OperationCanceledException) { cancelledBeforePublication.TrySetResult(); } + }; + fixture.Events.Publish(TriggerEventKind.AppEntered); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Task disabling = fixture.Authority.ApplyChangesAsync( + [new(SettingsRegistry.Keys.TriggersEnabled, SettingsEnvelopeTestData.Value("TriggersEnabled", "false"))], + Guid.NewGuid(), CancellationToken.None); + try + { + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + while (fixture.Admission.State == MutationAdmissionState.Open && !paused.Task.IsCompleted) + { + await Task.Delay(10, deadline.Token); + } + MutationAdmissionState admissionBeforeProducerSettled = fixture.Admission.State; + bool pausedBeforeProducerSettled = paused.Task.IsCompleted; + continueProducer.TrySetResult(); + Task nested = await nestedStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + if (pausedBeforeProducerSettled) + { + Assert.False(disabling.IsCompleted); + Assert.False(nested.IsCompleted); + } + Assert.Equal(MutationAdmissionState.Closing, admissionBeforeProducerSettled); + Assert.False(pausedBeforeProducerSettled); + Assert.True((await disabling.WaitAsync(TimeSpan.FromSeconds(10))).IsSucceeded); + Assert.True(cancelledBeforePublication.Task.IsCompleted); + Assert.True(fixture.Authority.CaptureSnapshot().Envelope.Desired[SettingsRegistry.Keys.NotificationEnabled].Value.Get()); + Assert.False(fixture.Authority.CaptureSnapshot().Envelope.Applied[SettingsRegistry.Keys.TriggersEnabled].Value!.Get()); + Assert.Equal(MutationAdmissionState.Open, fixture.Admission.State); + } + finally + { + // A failing assertion against the previous ordering must still break and drain its cycle. + continueProducer.TrySetResult(); + cleanupCancellation.Cancel(); + await disabling.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UninitializedScheduler_CannotClaimAnAppliedSettingOrStartStorageWork(bool target) + { + await using Fixture fixture = await Fixture.CreateAsync(target, start: false); + Assert.Equal(0, fixture.Events.Subscriptions); + Assert.Equal(0, fixture.Clock.Waits); + SettingsAuthorityResult result = await fixture.ApplyAsync(); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, result.Status); + Assert.Equal("settings.application.probe_failed", result.Code); + Assert.Equal(SettingsApplicationBatchState.Failed, Assert.Single(result.Envelope!.PendingApplications).State); + Assert.Equal(SettingAppliedStateKind.Unknown, result.Envelope.Applied[SettingsRegistry.Keys.TriggersEnabled].Kind); + Assert.False(fixture.Participant.Scheduler.IsRunning); + Assert.Equal(0, fixture.Events.Subscriptions); + Assert.Equal(0, fixture.Clock.Waits); + } + + [Fact] + public async Task AlreadyDisabledScheduler_IsObservedWithoutRestartingItsMaintenanceLoop() + { + await using Fixture fixture = await Fixture.CreateAsync(false); + SettingsAuthorityResult result = await fixture.ApplyAsync(); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal(1, fixture.Events.Subscriptions); + Assert.True(fixture.Participant.Scheduler.IsRunning); + Assert.False(result.Envelope!.Applied[SettingsRegistry.Keys.TriggersEnabled].Value!.Get()); + fixture.Events.Publish(TriggerEventKind.ProxyStarted); + await fixture.DrainAsync(); + Assert.Equal(0, fixture.Evaluator.Calls); + } + + [Fact] + public async Task DesiredChanges_ReachEvaluationOnlyAfterOwnedApplicationAndRemainRestartable() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + fixture.Events.Publish(TriggerEventKind.ProxyStarted); + await fixture.DrainAsync(); + Assert.Equal(0, fixture.Evaluator.Calls); + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + fixture.Events.Publish(TriggerEventKind.ProxyStarted); + Assert.Equal(TriggerEventKind.ProxyStarted, (await fixture.Evaluator.ReadAsync()).EventKind); + + await fixture.ChangeAsync(false); + Assert.False((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.Desired[SettingsRegistry.Keys.TriggersEnabled].Value.Get()); + fixture.Events.Publish(TriggerEventKind.AppEntered); + Assert.Equal(TriggerEventKind.AppEntered, (await fixture.Evaluator.ReadAsync()).EventKind); + SettingsAuthorityResult disabled = await fixture.ApplyAsync(); + Assert.True(disabled.IsSucceeded, disabled.Code); + Assert.False(disabled.Envelope!.Applied[SettingsRegistry.Keys.TriggersEnabled].Value!.Get()); + fixture.Events.Publish(TriggerEventKind.ProxyStarted); + await fixture.DrainAsync(); + Assert.Equal(2, fixture.Evaluator.Calls); + + await fixture.ChangeAsync(true); + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + fixture.Events.Publish(TriggerEventKind.AppEntered); + Assert.Equal(TriggerEventKind.AppEntered, (await fixture.Evaluator.ReadAsync()).EventKind); + Assert.Equal(1, fixture.Events.ActiveSubscriptions); + } + + [Fact] + public async Task Disable_DrainsInflightAndQueuedEvaluationsDespitePageCancellation() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + fixture.Evaluator.BlockFirst = true; + fixture.Events.Publish(TriggerEventKind.AppEntered); + await fixture.Evaluator.ReadAsync(); + fixture.Events.Publish(TriggerEventKind.ProxyStarted); + await fixture.ChangeAsync(false); + TaskCompletionSource paused = Signal(); + fixture.Events.OnUnsubscribe = () => paused.TrySetResult(); + using CancellationTokenSource cancellation = new(); + Task applying = fixture.ApplyAsync(cancellationToken: cancellation.Token); + await paused.Task.WaitAsync(TimeSpan.FromSeconds(10)); + cancellation.Cancel(); + Assert.False(applying.IsCompleted); + Assert.Throws(() => fixture.Admission.AcquireOrdinary()); + Assert.False(fixture.Evaluator.CancellationObserved.Task.IsCompleted); + fixture.Evaluator.Release.TrySetResult(); + Assert.True((await applying).IsSucceeded); + using MutationAdmissionLease reopened = fixture.Admission.AcquireOrdinary(); + Assert.Equal(TriggerEventKind.ProxyStarted, (await fixture.Evaluator.ReadAsync()).EventKind); + Assert.Equal(2, fixture.Evaluator.Calls); + SettingsEnvelope durable = (await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!; + Assert.Empty(durable.PendingApplications); + Assert.False(durable.Applied[SettingsRegistry.Keys.TriggersEnabled].Value!.Get()); + Assert.True(fixture.Participant.Scheduler.IsRunning); + } + + [Fact] + public async Task LostApplicationReply_IsResolvedFromTheActualInstalledSchedulerConfiguration() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + SettingsAuthorityResult result = await fixture.ApplyAsync(new ObservingParticipant(fixture.Participant) { LoseReply = true }); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal("settings.application.reply_lost_resolved", result.Code); + Assert.Equal(SettingAppliedValueSource.RuntimeProbe, result.Envelope!.Applied[SettingsRegistry.Keys.TriggersEnabled].Source); + fixture.Events.Publish(TriggerEventKind.ProxyStarted); + await fixture.Evaluator.ReadAsync(); + Assert.Equal(1, fixture.Evaluator.Calls); + } + + [Fact] + public async Task FailedResume_RetainsUnknownIntentUntilRuntimeRecoveryAndExplicitRetry() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + fixture.Events.SubscribeFailure = new IOException("Isolated event source is unavailable."); + SettingsAuthorityResult result = await fixture.ApplyAsync(); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, result.Status); + Assert.False(fixture.Participant.Scheduler.IsRunning); + Assert.Equal(SettingAppliedStateKind.Unknown, result.Envelope!.Applied[SettingsRegistry.Keys.TriggersEnabled].Kind); + SettingsApplicationBatch failed = Assert.Single(result.Envelope.PendingApplications); + Assert.Equal(SettingsApplicationBatchState.Failed, failed.State); + Assert.True(result.Envelope.Desired[SettingsRegistry.Keys.TriggersEnabled].Value.Get()); + + fixture.Events.SubscribeFailure = null; + await fixture.Participant.Scheduler.StartAsync(CancellationToken.None); + using (MutationAdmissionLease lease = fixture.Admission.AcquireOrdinary()) + { + Assert.True((await fixture.Session.RetryAdmittedAsync(failed.BatchId, failed.AttemptId, + Guid.NewGuid(), lease, CancellationToken.None)).IsSucceeded); + } + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + fixture.Events.Publish(TriggerEventKind.AppEntered); + await fixture.Evaluator.ReadAsync(); + Assert.Equal(1, fixture.Events.ActiveSubscriptions); + } + + [Fact] + public async Task FatalResumeExceptionGraph_EscapesWithItsIdentityAndLeavesTheDurableAttemptRunning() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + InvalidOperationException fatal = new("Isolated wrapper.", new AggregateException(Activator.CreateInstance())); + fixture.Events.SubscribeFailure = fatal; + Assert.Same(fatal, await Assert.ThrowsAsync(() => fixture.ApplyAsync())); + SettingsEnvelope durable = (await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!; + Assert.Equal(SettingsApplicationBatchState.Running, Assert.Single(durable.PendingApplications).State); + Assert.Equal(SettingAppliedStateKind.Unknown, durable.Applied[SettingsRegistry.Keys.TriggersEnabled].Kind); + } + + [Fact] + public async Task DisabledEvaluation_ContinuesRetryingPreviouslyReleasedLifecycleHandoffs() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + TriggerExecution execution = new(Guid.NewGuid(), "exit-task", 1, DateTimeOffset.UnixEpoch, + Guid.NewGuid(), TriggerExecutionState.HandedOff); + fixture.Evaluator.Execution = execution; + fixture.Handoff.FailuresRemaining = 3; + fixture.Events.Publish(TriggerEventKind.AppEntered); + await fixture.Handoff.ThirdAttempt.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await fixture.ChangeAsync(false); + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + await fixture.Clock.TickAsync(); + Assert.Same(execution, await fixture.Handoff.ReadAsync()); + Assert.Equal(4, fixture.Handoff.Attempts); + Assert.Equal(1, fixture.Evaluator.Calls); + } + + [Fact] + public async Task ForeignGenerationOrInactiveAdmission_IsRejectedBeforeSchedulerAccess() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + ObservingParticipant capture = new(fixture.Participant); + Assert.True((await fixture.ApplyAsync(capture)).IsSucceeded); + SettingsApplicationRequest request = Assert.IsType(capture.Request); + int subscriptions = fixture.Events.Subscriptions; + using MutationAdmissionLease foreign = new MutationAdmissionBarrier().AcquireOrdinary(); + await Assert.ThrowsAsync(() => fixture.Participant.ProbeAsync(request, foreign, CancellationToken.None)); + await Assert.ThrowsAsync(() => fixture.Participant.ApplyAsync(request, foreign, CancellationToken.None)); + MutationAdmissionLease retired = fixture.Admission.AcquireOrdinary(); + retired.Dispose(); + await Assert.ThrowsAsync(() => fixture.Participant.ApplyAsync(request, retired, CancellationToken.None)); + DataGenerationDescriptor wrongGeneration = fixture.Directory.CreateGeneration(2); + await using TriggersSettingsParticipant wrong = new(wrongGeneration, fixture.Admission, + new TriggerSettingsState(wrongGeneration), fixture.Events, fixture.Clock, fixture.Evaluator, fixture.Handoff); + using MutationAdmissionLease own = await fixture.Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, CancellationToken.None); + await Assert.ThrowsAsync(() => wrong.ProbeAsync(request, own, CancellationToken.None)); + await Assert.ThrowsAsync(() => wrong.ApplyAsync(request, own, CancellationToken.None)); + Assert.Equal(subscriptions, fixture.Events.Subscriptions); + Assert.Equal(1, fixture.Events.ActiveSubscriptions); + } + + [Fact] + public async Task CancelledApplication_DoesNotPauseTheSchedulerOrReplaceItsInstalledSetting() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + ObservingParticipant capture = new(fixture.Participant); + Assert.True((await fixture.ApplyAsync(capture)).IsSucceeded); + SettingsApplicationRequest request = Assert.IsType(capture.Request); + int subscriptions = fixture.Events.Subscriptions; + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + using MutationAdmissionLease own = await fixture.Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, CancellationToken.None); + await Assert.ThrowsAnyAsync(() => fixture.Participant.ApplyAsync(request, own, cancellation.Token)); + Assert.Equal(subscriptions, fixture.Events.Subscriptions); + Assert.True(fixture.Participant.Scheduler.IsAcceptingEvents); + Assert.True(Assert.Single((await fixture.Participant.ProbeAsync(request, own, CancellationToken.None)).Values).Value.Get()); + } + + [Fact] + public async Task Retirement_WaitsForActiveEvaluationAndRejectsLaterReadsAndWrites() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + ObservingParticipant capture = new(fixture.Participant); + Assert.True((await fixture.ApplyAsync(capture)).IsSucceeded); + fixture.Evaluator.BlockFirst = true; + fixture.Events.Publish(TriggerEventKind.AppEntered); + await fixture.Evaluator.ReadAsync(); + Task retiring = fixture.Participant.DisposeAsync().AsTask(); + await fixture.Evaluator.CancellationObserved.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.False(retiring.IsCompleted); + Assert.Equal(0, fixture.Events.ActiveSubscriptions); + fixture.Evaluator.Release.TrySetResult(); + await retiring; + Assert.False(fixture.Participant.Scheduler.IsRunning); + using MutationAdmissionLease lease = await fixture.Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, CancellationToken.None); + await Assert.ThrowsAsync(() => fixture.Participant.ProbeAsync(capture.Request!, lease, CancellationToken.None)); + await Assert.ThrowsAsync(() => fixture.Participant.ApplyAsync(capture.Request!, lease, CancellationToken.None)); + } + + private static TaskCompletionSource Signal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + + [Theory] + [InlineData("TriggersEnabled")] + [InlineData("TriggerNotificationsEnabled")] + public async Task AdmittedTriggerChange_RejectsOrdinaryAuthorityBeforePublishingDesiredIntent(string key) + { + await using Fixture fixture = await Fixture.CreateAsync(true); + ObservingParticipant capture = new(fixture.Participant); + Assert.True((await fixture.ApplyAsync(capture)).IsSucceeded); + long revision = fixture.Authority.CaptureSnapshot().Envelope.EnvelopeRevision; + int subscriptions = fixture.Events.Subscriptions; + using MutationAdmissionLease ordinary = fixture.Admission.AcquireOrdinary(); + await Assert.ThrowsAsync(() => fixture.Authority.ApplyChangesAdmittedAsync( + [new(new SettingKey(key), SettingsEnvelopeTestData.Value(key, "false"))], + Guid.NewGuid(), ordinary, CancellationToken.None)); + await Assert.ThrowsAsync(() => fixture.Participant.ProbeAsync(capture.Request!, ordinary, CancellationToken.None)); + await Assert.ThrowsAsync(() => fixture.Participant.ApplyAsync(capture.Request!, ordinary, CancellationToken.None)); + Assert.Equal(revision, (await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.EnvelopeRevision); + Assert.True(fixture.Authority.CaptureSnapshot().Envelope.Desired[new SettingKey(key)].Value.Get()); + Assert.Equal(subscriptions, fixture.Events.Subscriptions); + } + + [Fact] + public async Task FacadeRetry_AcquiresExclusiveAuthorityForTheResolvedTriggerBatch() + { + await using Fixture fixture = await Fixture.CreateAsync(true, start: false); + SettingsAuthorityResult failed = await fixture.ApplyAsync(); + SettingsApplicationBatch batch = Assert.Single(failed.Envelope!.PendingApplications); + await fixture.Participant.Scheduler.StartAsync(CancellationToken.None); + SettingsAuthorityResult retried = await fixture.Authority.RetryAsync(batch.BatchId, batch.AttemptId, + Guid.NewGuid(), CancellationToken.None); + Assert.True(retried.IsSucceeded, retried.Code); + Assert.Empty(retried.Envelope!.PendingApplications); + fixture.Events.Publish(TriggerEventKind.AppEntered); + await fixture.Evaluator.ReadAsync(); + Assert.Equal(MutationAdmissionState.Open, fixture.Admission.State); + } + + [Fact] + public async Task FacadeRevert_VerifiesTheSafeDisabledFallbackUnderExclusiveAuthority() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + SettingsAuthorityResult result = await fixture.Authority.RevertAsync([SettingsRegistry.Keys.TriggersEnabled], + Guid.NewGuid(), CancellationToken.None); + Assert.True(result.IsSucceeded, result.Code); + Assert.False(result.Envelope!.Desired[SettingsRegistry.Keys.TriggersEnabled].Value.Get()); + Assert.False(result.Envelope.Applied[SettingsRegistry.Keys.TriggersEnabled].Value!.Get()); + Assert.Empty(result.Envelope.PendingApplications); + Assert.True(fixture.Participant.Scheduler.IsRunning); + Assert.Equal(1, fixture.Events.Subscriptions); + } + + [Fact] + public async Task NotificationOnlyChange_DrainsEvaluationAndPreservesGlobalEnablement() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + int subscriptions = fixture.Events.Subscriptions; + fixture.Evaluator.BlockFirst = true; + fixture.Events.Publish(TriggerEventKind.AppEntered); + await fixture.Evaluator.ReadAsync(); + Task applying = fixture.Authority.ApplyChangesAsync( + [new(SettingsRegistry.Keys.TriggerNotificationsEnabled, SettingsEnvelopeTestData.Value("TriggerNotificationsEnabled", "false"))], + Guid.NewGuid(), CancellationToken.None); + Assert.True(fixture.Participant.Scheduler.IsRunning); + Assert.False(applying.IsCompleted); + Assert.False(fixture.Evaluator.CancellationObserved.Task.IsCompleted); + fixture.Evaluator.Release.TrySetResult(); + SettingsAuthorityResult result = await applying.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.True(result.IsSucceeded, result.Code); + Assert.False(result.Envelope!.Applied[SettingsRegistry.Keys.TriggerNotificationsEnabled].Value!.Get()); + Assert.True(result.Envelope.Applied[SettingsRegistry.Keys.TriggersEnabled].Value!.Get()); + Assert.Equal(subscriptions + 1, fixture.Events.Subscriptions); + await fixture.NotifyAsync(); + Assert.False(Assert.Single(fixture.DeliveredPolicies)); + fixture.Events.Publish(TriggerEventKind.ProxyStarted); + Assert.Equal(TriggerEventKind.ProxyStarted, (await fixture.Evaluator.ReadAsync()).EventKind); + Assert.Equal(MutationAdmissionState.Open, fixture.Admission.State); + } + + [Fact] + public async Task CombinedTriggerChange_VerifiesEveryKeyInTheRegistryBatch() + { + await using Fixture fixture = await Fixture.CreateAsync(false); + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + SettingsAuthorityResult result = await fixture.Authority.ApplyChangesAsync( + [new(SettingsRegistry.Keys.TriggersEnabled, SettingsEnvelopeTestData.Value("TriggersEnabled", "true")), + new(SettingsRegistry.Keys.TriggerNotificationsEnabled, SettingsEnvelopeTestData.Value("TriggerNotificationsEnabled", "false"))], + Guid.NewGuid(), CancellationToken.None); + Assert.True(result.IsSucceeded, result.Code); + Assert.True(result.Envelope!.Applied[SettingsRegistry.Keys.TriggersEnabled].Value!.Get()); + Assert.False(result.Envelope.Applied[SettingsRegistry.Keys.TriggerNotificationsEnabled].Value!.Get()); + Assert.Empty(result.Envelope.PendingApplications); + await fixture.NotifyAsync(); + Assert.False(Assert.Single(fixture.DeliveredPolicies)); + fixture.Events.Publish(TriggerEventKind.ProxyStarted); + Assert.Equal(TriggerEventKind.ProxyStarted, (await fixture.Evaluator.ReadAsync()).EventKind); + } + + [Fact] + public async Task NotificationIntent_ChangesDeliveryOnlyAfterApplicationAndResolvesALostReply() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + using (MutationAdmissionLease lease = fixture.Admission.AcquireOrdinary()) + { + Assert.True((await fixture.Session.ChangeAdmittedAsync( + [new(SettingsRegistry.Keys.TriggerNotificationsEnabled, SettingsEnvelopeTestData.Value("TriggerNotificationsEnabled", "false"))], + Guid.NewGuid(), lease, CancellationToken.None)).IsSucceeded); + } + await fixture.NotifyAsync(); + Assert.True(Assert.Single(fixture.DeliveredPolicies)); + SettingsAuthorityResult result = await fixture.ApplyAsync(new ObservingParticipant(fixture.Participant) { LoseReply = true }); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal("settings.application.reply_lost_resolved", result.Code); + await fixture.NotifyAsync(); + Assert.Equal([true, false], fixture.DeliveredPolicies); + Assert.True(fixture.Settings.IsEnabled); + await fixture.Participant.DisposeAsync(); + await Assert.ThrowsAsync(() => fixture.NotifyAsync()); + } + + [Fact] + public async Task ForeignConsumerConfiguration_IsRejectedBeforeSchedulingStarts() + { + await using Fixture fixture = await Fixture.CreateAsync(false, start: false); + await using DataGenerationTestDirectory foreignDirectory = new(); + var foreign = await foreignDirectory.PromoteFirstAsync(); + Assert.Throws(() => new TriggersSettingsParticipant(fixture.Session.Generation, fixture.Admission, + new TriggerSettingsState(foreign.Descriptor), fixture.Events, fixture.Clock, fixture.Evaluator, fixture.Handoff)); + Assert.Equal(0, fixture.Events.Subscriptions); + Assert.Equal(0, fixture.Clock.Waits); + } + + [Fact] + public async Task NotificationChange_PreservesAnUnrelatedPendingSchedulerIntent() + { + await using Fixture fixture = await Fixture.CreateAsync(true); + SettingsAuthorityResult result = await fixture.Authority.ApplyChangesAsync( + [new(SettingsRegistry.Keys.TriggerNotificationsEnabled, SettingsEnvelopeTestData.Value("TriggerNotificationsEnabled", "false"))], + Guid.NewGuid(), CancellationToken.None); + Assert.True(result.IsSucceeded, result.Code); + Assert.False(fixture.Settings.IsEnabled); + Assert.False(fixture.Settings.NotificationsEnabled); + Assert.True(result.Envelope!.Desired[SettingsRegistry.Keys.TriggersEnabled].Value.Get()); + Assert.Equal(SettingsRegistry.Keys.TriggersEnabled, + Assert.Single(Assert.Single(result.Envelope.PendingApplications).Entries).Key); + Assert.Equal(MutationAdmissionState.Open, fixture.Admission.State); + Assert.True((await fixture.ApplyAsync()).IsSucceeded); + Assert.True(fixture.Settings.IsEnabled); + Assert.False(fixture.Settings.NotificationsEnabled); + } + + private sealed class Fixture : IAsyncDisposable + { + private bool _generationInitialized; + public DataGenerationTestDirectory Directory { get; } = new(); + public MutationAdmissionBarrier Admission { get; } = new(); + public Events Events { get; } = new(); + public Clock Clock { get; } = new(); + public Evaluator Evaluator { get; } = new(); + public Handoff Handoff { get; } = new(); + public JsonSettingsRepository Repository { get; private set; } = null!; + public SettingsAuthoritySession Session { get; private set; } = null!; + public TriggerSettingsState Settings { get; private set; } = null!; + public TriggersSettingsParticipant Participant { get; private set; } = null!; + public TriggerFiredNotificationAdapter Notifications { get; private set; } = null!; + public List DeliveredPolicies { get; } = []; + public DataGenerationManager Generations { get; } = new(); + public GenerationSettingsAuthority Authority { get; private set; } = null!; + + public static async Task CreateAsync(bool target, bool start = true) + { + Fixture fixture = new(); + try + { + var manifest = await fixture.Directory.PromoteFirstAsync(); + fixture.Repository = new(manifest.Descriptor, SettingsRegistry.Default); + Assert.True((await fixture.Repository.SaveAsync(SettingsEnvelopeTestData.CreateMatchingEnvelope(), 0, CancellationToken.None)).IsSucceeded); + SettingsEnvelope pending = SettingsEnvelopeTestData.CreatePendingEnvelope([("TriggersEnabled", target ? "true" : "false")]); + Dictionary applied = pending.Applied.ToDictionary(); + applied[SettingsRegistry.Keys.TriggersEnabled] = SettingAppliedState.Unknown(SettingAppliedUnknownReason.NotObserved, SettingAppliedUnknownHandling.QueueApplication); + Assert.True((await fixture.Repository.SaveAsync(new(pending.SchemaVersion, pending.EnvelopeRevision, + pending.Desired, applied, pending.PendingApplications, pending.MigrationHistory), 1, CancellationToken.None)).IsSucceeded); + fixture.Session = new(fixture.Repository, SettingsRegistry.Default, fixture.Admission); + fixture.Settings = new(manifest.Descriptor); + fixture.Notifications = new(() => fixture.Settings.NotificationsEnabled, new EmptyDefinitions(), + (_, _, enabled, _) => { fixture.DeliveredPolicies.Add(enabled); return Task.CompletedTask; }, (_, _) => { }); + fixture.Participant = new(manifest.Descriptor, fixture.Admission, fixture.Settings, + fixture.Events, fixture.Clock, fixture.Evaluator, fixture.Handoff); + fixture.Generations.Initialize(manifest, new(manifest.Descriptor, new Lifetime(fixture))); + fixture._generationInitialized = true; + fixture.Authority = new(fixture.Generations, fixture.Admission); + if (start) { await fixture.Participant.Scheduler.StartAsync(CancellationToken.None); } + return fixture; + } + catch { await fixture.DisposeAsync(); throw; } + } + + public async Task ChangeAsync(bool target) + { + using MutationAdmissionLease lease = Admission.AcquireOrdinary(); + Assert.True((await Session.ChangeAdmittedAsync([new(SettingsRegistry.Keys.TriggersEnabled, SettingsEnvelopeTestData.Value("TriggersEnabled", target ? "true" : "false"))], + Guid.NewGuid(), lease, CancellationToken.None)).IsSucceeded); + } + + public Task NotifyAsync() => Notifications.NotifyAsync(new(Guid.NewGuid(), "isolated-task", 1, + DateTimeOffset.UnixEpoch, Guid.NewGuid(), TriggerExecutionState.Pending), CancellationToken.None); + + public async Task ApplyAsync(ISettingsApplicationParticipant? participant = null, CancellationToken cancellationToken = default) + { + using MutationAdmissionLease lease = await Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, cancellationToken); + SettingsApplicationBatch batch = Assert.Single((await Repository.OpenAsync(cancellationToken)).Envelope!.PendingApplications); + return await Session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, participant ?? Participant, + SettingsApplicationPhase.Live, lease, cancellationToken); + } + + public async Task DrainAsync() + { + QuiescedState prior = await Participant.Scheduler.QuiesceAsync(CancellationToken.None); + await Participant.Scheduler.ResumeAsync(prior, CancellationToken.None); + } + + public async ValueTask DisposeAsync() + { + Evaluator.Release.TrySetResult(); + if (_generationInitialized) { await Generations.DisposeAsync(); } + else + { + if (Session is not null) { await Session.DisposeAsync(); } + if (Participant is not null) { await Participant.DisposeAsync(); } + } + await Directory.DisposeAsync(); + } + + private sealed class Lifetime(Fixture fixture) : IServiceProvider, IAsyncDisposable + { + private readonly SettingsGenerationContext _context = new(fixture.Session, [fixture.Participant]); + public object? GetService(Type serviceType) => serviceType == typeof(SettingsGenerationContext) ? _context : null; + public async ValueTask DisposeAsync() + { + await fixture.Session.DisposeAsync(); + await fixture.Participant.DisposeAsync(); + } + } + } + + private sealed class EmptyDefinitions : ITriggerDefinitionStore + { + public TriggerDefinitionCatalog Current { get; } = new(0, [], []); + public Task> ReadAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> ReplaceAsync(long expectedGeneration, + IReadOnlyList definitions, CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private sealed class ObservingParticipant(TriggersSettingsParticipant inner) : ISettingsApplicationParticipant + { + public bool LoseReply { get; init; } + public SettingsApplicationRequest? Request { get; private set; } + public SettingApplicationKind ApplicationKind => inner.ApplicationKind; + public Task ProbeAsync(SettingsApplicationRequest request, MutationAdmissionLease lease, CancellationToken cancellationToken) + { Request = request; return inner.ProbeAsync(request, lease, cancellationToken); } + public async Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease lease, CancellationToken cancellationToken) + { + await inner.ApplyAsync(request, lease, cancellationToken); + if (LoseReply) { throw new IOException("Isolated lost application reply."); } + } + } + + private sealed class Events : ITriggerSchedulerEventSource + { + private EventHandler? _raised; + public int Subscriptions { get; private set; } + public int ActiveSubscriptions => _raised?.GetInvocationList().Length ?? 0; + public Exception? SubscribeFailure { get; set; } + public Action? OnUnsubscribe { get; set; } + public event EventHandler? EventRaised + { + add { if (SubscribeFailure is not null) { throw SubscribeFailure; } ++Subscriptions; _raised += value; } + remove { _raised -= value; OnUnsubscribe?.Invoke(); } + } + public void Publish(TriggerEventKind kind) => _raised?.Invoke(this, new(kind)); + } + + private sealed class Clock : ITriggerSchedulerClock + { + private readonly Channel _ticks = Channel.CreateUnbounded(); + private int _waits; + public int Waits => Volatile.Read(ref _waits); + public DateTimeOffset UtcNow => DateTimeOffset.UnixEpoch; + public async Task WaitForNextTickAsync(CancellationToken cancellationToken) + { + TaskCompletionSource tick = Signal(); + using CancellationTokenRegistration registration = cancellationToken.Register(() => tick.TrySetCanceled(cancellationToken)); + Interlocked.Increment(ref _waits); + Assert.True(_ticks.Writer.TryWrite(tick)); + await tick.Task; + } + public async Task TickAsync() + { + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + while (!(await _ticks.Reader.ReadAsync(deadline.Token)).TrySetResult()) { } + } + } + + private sealed class Evaluator : ITriggerSchedulerEvaluator + { + private readonly Channel _calls = Channel.CreateUnbounded(); + private int _count; + public int Calls => Volatile.Read(ref _count); + public bool BlockFirst { get; set; } + public Func? Handler { get; set; } + public TriggerExecution? Execution { get; set; } + public TaskCompletionSource Release { get; } = Signal(); + public TaskCompletionSource CancellationObserved { get; } = Signal(); + public async Task EvaluateAsync(TriggerSchedulerEvent value, CancellationToken cancellationToken) + { + int ordinal = Interlocked.Increment(ref _count); + using CancellationTokenRegistration registration = cancellationToken.Register(() => CancellationObserved.TrySetResult()); + Assert.True(_calls.Writer.TryWrite(value)); + if (BlockFirst && ordinal == 1) { await Release.Task; } + if (Handler is not null) { await Handler(); } + return Execution is null ? TriggerSchedulerEvaluationOutcome.Succeeded() : TriggerSchedulerEvaluationOutcome.Succeeded([Execution]); + } + public async Task ReadAsync() + { + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + return await _calls.Reader.ReadAsync(deadline.Token); + } + } + + private sealed class Handoff : ITriggerLifecycleHandoff + { + private readonly Channel _completed = Channel.CreateUnbounded(); + public int FailuresRemaining { get; set; } + public int Attempts { get; private set; } + public TaskCompletionSource ThirdAttempt { get; } = Signal(); + public Task ProbeAsync(TriggerOutboxAction action, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task HandOffAsync(TriggerOutboxAction action, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task AcknowledgeReleaseAsync(TriggerLifecycleHandoffIdentity identity, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task AcknowledgeReleasedExecutionAsync(TriggerExecution execution, CancellationToken cancellationToken) + { + if (++Attempts == 3) { ThirdAttempt.TrySetResult(); } + if (Attempts <= FailuresRemaining) { throw new IOException("Isolated acknowledgement unavailable."); } + Assert.True(_completed.Writer.TryWrite(execution)); + return Task.CompletedTask; + } + public async Task ReadAsync() + { + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + return await _completed.Reader.ReadAsync(deadline.Token); + } + } +} diff --git a/ClashSharp/ClashSharp/AppHost/Settings/SettingsParticipantBinding.cs b/ClashSharp/ClashSharp/AppHost/Settings/SettingsParticipantBinding.cs index 3fd998e..a38d9df 100644 --- a/ClashSharp/ClashSharp/AppHost/Settings/SettingsParticipantBinding.cs +++ b/ClashSharp/ClashSharp/AppHost/Settings/SettingsParticipantBinding.cs @@ -29,6 +29,7 @@ public void Validate(SettingsApplicationRequest request, MutationAdmissionLease { ArgumentNullException.ThrowIfNull(request); _admission.EnsureActiveLease(lease); + if (_kind == SettingApplicationKind.Triggers) { _admission.EnsureActiveExclusiveLease(lease); } if (!_generation.IsSameGeneration(request.Generation) || request.Batch.ApplicationKind != _kind || request.Values.Count == 0 || request.Values.Keys.Any(key => !_keys.Contains(key))) { diff --git a/ClashSharp/ClashSharp/AppHost/Settings/TriggerSettingsState.cs b/ClashSharp/ClashSharp/AppHost/Settings/TriggerSettingsState.cs new file mode 100644 index 0000000..72b8523 --- /dev/null +++ b/ClashSharp/ClashSharp/AppHost/Settings/TriggerSettingsState.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Triggers; + +namespace ClashSharp.Hosting.Settings; + +/// Shares one installed generation configuration between scheduling and fired-notification delivery. +internal sealed class TriggerSettingsState : ITriggerSchedulerSettings +{ + private Configuration? _installed = new(false, true); + + public TriggerSettingsState(DataGenerationDescriptor generation) => + Generation = generation ?? throw new ArgumentNullException(nameof(generation)); + + public DataGenerationDescriptor Generation { get; } + public bool IsEnabled => Read().Enabled; + public bool NotificationsEnabled => Read().NotificationsEnabled; + + internal Configuration Read() => Volatile.Read(ref _installed) + ?? throw new ObjectDisposedException(nameof(TriggerSettingsState)); + + internal void Install(Configuration configuration) + { + _ = Read(); + Volatile.Write(ref _installed, configuration); + } + + internal void Retire() => Volatile.Write(ref _installed, null); + + internal sealed record Configuration(bool Enabled, bool NotificationsEnabled); +} diff --git a/ClashSharp/ClashSharp/AppHost/Settings/TriggersSettingsParticipant.cs b/ClashSharp/ClashSharp/AppHost/Settings/TriggersSettingsParticipant.cs new file mode 100644 index 0000000..c7deaa1 --- /dev/null +++ b/ClashSharp/ClashSharp/AppHost/Settings/TriggersSettingsParticipant.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Lifecycle; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.ApplicationModel.Supervision; +using ClashSharp.ApplicationModel.Triggers; +using ClashSharp.Settings; + +namespace ClashSharp.Hosting.Settings; + +/// Owns a generation's scheduler and the installed enablement consumed by its evaluation loop. +/// The host initializes trigger storage before starting and admitting settings application. +internal sealed class TriggersSettingsParticipant : ISettingsApplicationParticipant, IAsyncDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly TriggerSettingsState _settings; + private readonly SettingsParticipantBinding _binding; + private bool _disposed; + + public TriggersSettingsParticipant(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, + TriggerSettingsState settings, + ITriggerSchedulerEventSource events, ITriggerSchedulerClock clock, ITriggerSchedulerEvaluator evaluator, + ITriggerLifecycleHandoff handoff, Action? healthChanged = null) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + if (!_settings.Generation.IsSameGeneration(generation)) + { + throw new ArgumentException("The trigger consumers must share the participant's generation.", nameof(settings)); + } + _binding = new(generation, admission, SettingApplicationKind.Triggers, + SettingsRegistry.Keys.TriggersEnabled, SettingsRegistry.Keys.TriggerNotificationsEnabled); + Scheduler = new(_settings, events, clock, evaluator, handoff, healthChanged); + } + + /// Gets the same scheduler that host startup initializes and runtime lifecycle operations drain. + public TriggerScheduler Scheduler { get; } + + public SettingApplicationKind ApplicationKind => SettingApplicationKind.Triggers; + + public async Task ProbeAsync(SettingsApplicationRequest request, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + _binding.Validate(request, admissionLease); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + _binding.Validate(request, admissionLease); + EnsureSchedulerReady(); + TriggerSettingsState.Configuration installed = _settings.Read(); + return _binding.Observe(request, key => key == SettingsRegistry.Keys.TriggersEnabled + ? installed.Enabled : installed.NotificationsEnabled); + } + finally { _gate.Release(); } + } + + public async Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, + CancellationToken cancellationToken) + { + _binding.Validate(request, admissionLease); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + _binding.Validate(request, admissionLease); + cancellationToken.ThrowIfCancellationRequested(); + EnsureSchedulerReady(); + // Once draining begins, this owner finishes the transition even if the page cancels. + // Quiescence drains queued evaluations. The resumed loop continues retrying release + // acknowledgements even when new trigger evaluation is disabled. + QuiescedState prior = await Scheduler.QuiesceAsync(CancellationToken.None).ConfigureAwait(false); + if (!prior.WasRunning) { throw new InvalidOperationException("The trigger scheduler could not be quiesced for settings application."); } + TriggerSettingsState.Configuration installed = _settings.Read(); + _settings.Install(new( + request.Values.TryGetValue(SettingsRegistry.Keys.TriggersEnabled, out SettingValue? enabled) + ? enabled.Get() : installed.Enabled, + request.Values.TryGetValue(SettingsRegistry.Keys.TriggerNotificationsEnabled, out SettingValue? notifications) + ? notifications.Get() : installed.NotificationsEnabled)); + await Scheduler.ResumeAsync(prior, CancellationToken.None).ConfigureAwait(false); + } + finally { _gate.Release(); } + } + + public async ValueTask DisposeAsync() + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + if (_disposed) { return; } + _disposed = true; + try { await Scheduler.StopAsync(CancellationToken.None).ConfigureAwait(false); } + finally { _settings.Retire(); } + } + finally { _gate.Release(); } + } + + private void EnsureSchedulerReady() + { + if (!Scheduler.IsRunning || !Scheduler.IsAcceptingEvents) + { + throw new InvalidOperationException("The trigger scheduler is not ready to observe or apply settings."); + } + } + +} diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index 321b552..44c7f3c 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -1,6 +1,6 @@ # Settings generation cutover -版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口,以及 StartupTask、Sampling 的实际服务适配器。控制端凭据已从偏好中拆分并接入生产启动、运行时和数据清理;生产偏好仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 +版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口,以及 StartupTask、Sampling、Triggers 的实际服务适配器。控制端凭据已从偏好中拆分并接入生产启动、运行时和数据清理;生产偏好仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 ## 已实现的存储与迁移 @@ -40,6 +40,16 @@ 另外修复了启动服务的异常分类:探测和设置入口先检查完整异常图,嵌套致命异常保持原异常传播,不能返回未知状态或包装成 `StartupLaunchUpdateException`。两项回归先复现旧行为,再验证修复;持久 Running 记录保留给后续进程重新观察。 +## Triggers 的调度与通知配置 + +`TriggersSettingsParticipant` 拥有实际 `TriggerScheduler`,调度循环和生产 `TriggerFiredNotificationAdapter` 共同读取 `TriggerSettingsState` 的同一份不可变已安装配置。它覆盖 registry 中的总开关和通知开关,应用后独立探测两者;保存 desired 不会提前改变调度或通知。单键批次保留另一个键的实际值及其独立待办,混合批次完整验证。状态对象检查所属代际,退休后拒绝继续读取。 + +构造不启动任务或访问存储。主机需要先初始化 trigger 仓库及 outbox,再启动该适配器拥有的 scheduler,最后接入设置应用。未初始化、暂停或停止的循环不能被报告为已应用;显式应用排空当前和排队的评估后安装配置,再恢复同一循环。禁用评估时仍保持维护循环,以便继续重试生命周期释放确认;调度器静默不等于所有持久确认都已完成。开始排空后,页面取消不会截断操作;释放适配器等待实际循环停止后撤销共享配置。 + +回归复现了设置入口与调度任务互相等待的路径:UI 持有完整设置命令入口并等待调度器退出,而调度任务也在等待该入口。facade 现在先取得独占许可并排空普通调用,再进入命令入口。总开关、通知开关及其复原均采用该顺序;重试在独占许可内解析当前批次,避免预读身份与执行之间的竞态。已持有普通许可的触发器设置写入会在 desired 发布前被拒绝,其他类别的已提交命令仍完成既有续行协议。 + +测试直接调用主程序程序集中的实际调度适配器和通知适配器,平台、事件、时钟和通知投递边界使用隔离模拟。覆盖排空及取消、丢失回执、恢复失败与显式重试、致命异常图、通知策略、跨代际拒绝、旧实例释放及完整批次。首次完整检查发现测试源码重复编译违反已有 trigger 架构约束,已改为程序集引用,保留原约束;补齐通知键时的两项失败及夹具修正记录也保留。这些适配器尚未注册进生产代际装配。 + ## 独立控制端凭据 `AppSettingsService` 及其 editor 不再生成、读取或删除控制端凭据,核心配置偏好端口也不再携带 secret。`IControllerCredentialProvider` 只读取启动时已验证的进程凭据;HTTP、WebSocket 和配置生成使用同一提供者,独占设置操作期间的读取不访问存储、不获取新许可。生产主机拥有 `ControllerCredentialService`,现有静态运行时工厂通过显式启动绑定访问它。 @@ -52,7 +62,7 @@ 原有 `launch-no-proxy` 验收只确认包身份、窗口存在及稳定时长;启动错误页也可能满足该条件。现补充读取候选本次启动之后的 SQLite 日志聚合,要求 controller-credential(140)、window-shell(600)和最后的 profile-subscription-updates(710)各完成一次且成功,并且没有启动错误。查询使用系统 SQLite 只读连接,不读取凭据值、不输出日志内容,结果时间范围绑定实际 launch 步骤。PowerShell 5.1 与 7 均通过 111 项报告断言和 16 项真实隔离 SQLite 断言,覆盖错误页、未完成流程、重复或过期记录、锁定及损坏数据库;日志为 `startup-evidence-powershell51.log` 和 `startup-evidence-powershell7.log`。这些检查仍不代表全部页面交互或正常安装器流程已验收。 -当前生产装配已接入这项拆分;JSON 偏好权威和数据代际整体切换仍未激活。实际打包候选的启动验收将在对应 CI 包产出后执行。 +当前生产装配已接入这项拆分;JSON 偏好权威和数据代际整体切换仍未激活。该凭据候选已通过下述实际打包启动验收。 ## 代际服务寿命 @@ -87,10 +97,14 @@ 持久中断测试使用真实临时仓库、切点注入及新对象重开,运行时参与者为受控模拟。Windows 旧设置适配器已编译,未在开发机读取实际 LocalSettings。实际打包应用的迁移、进程崩溃、完整页面和安装器兼容验收将在生产切换后执行。开发机代理摘要保持 `95e97918ff6de70655b412568cd18dc81c5d6584c607bb9a71ddc72e22460447`。 +脚本说明修复 `3dbbb4e` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34224586524),实际四份 TRX 共 4824 项通过、零失败、零跳过。合并提交 `f3ce2e6` 与源提交 tree 同为 `ecd542df0c693a7f74cc3384b7dda2b25013dbe3`,收据为 `ci-validation-startup-help.json`。该次安装器包仅核验构建及元数据,原生启动证据仍绑定上述凭据候选。 + +Triggers 适配和入口顺序修复新增 22 项回归,本分支累计净增 160 项。完整主程序 2781 项全部通过,零失败、零跳过,用时 56 秒;18 项目 Release x64 构建零警告、零错误,用时 26.04 秒,format 检查 1491 个文件、零处变更。收据为 `local-validation-trigger-settings.json`,最终报告为 `1.0.0-trigger-settings-both-keys.trx`、`build-trigger-settings-both-keys.log` 和 `format-trigger-settings-both-keys-verified.log`。死锁复现保存在 `1.0.0-trigger-settings-cycle-red.trx`;最初通知键遗漏、程序集引用问题及夹具错误报告分别保留,不将先前未完成的验证累计为通过项。 + ## 完整切换的剩余依赖 1. 将偏好写入统一为应用层异步 change set;页面、磁贴、触发器和网络提交者使用同一个接口。独立控制端凭据已接入生产调用,后续代际重置继续使用该能力。 -2. 完成 Internal、Appearance、Network、Triggers 的实际 apply/probe 适配器,并将已实现的 StartupTask、Sampling 一起装配;明确读取 desired、有效状态和待办的消费者。 +2. 完成 Internal、Appearance、Network 的实际 apply/probe 适配器,并将已实现的 StartupTask、Sampling、Triggers 一起装配;明确读取 desired、有效状态和待办的消费者。 3. 在设置驱动的启动步骤之前完成旧事务恢复、代际打开和偏好迁移。profile/log/trigger 与 settings 必须由同一代际容器解析、排空和替换。 4. 将导入、重置和回滚接入候选代际及 manifest 提交,完成生产消费者替换后,原子替换 `SettingsAuthorityArchitectureTests` 中的临时门禁。 5. 运行新候选的 CI、打包应用及隔离 Windows 验收,再将完整节点推送 main。 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index 2a093d5..cf7962b 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -26,7 +26,9 @@ - 控制端凭据已从偏好及核心配置设置端口中移出,生产启动、控制端请求、配置生成和全数据清理改用独立凭据服务。写入、删除均独立重读确认;存储不可用阻止启动,普通偏好重置保留凭据,终态清理使用专门维护许可。30 项新用例替换一个旧设置类职责测试后净增 29 项,完整主程序 2759 项通过,构建零警告、零错误,format 检查 1488 个文件、0 处变更;收据为 `local-validation-controller-credentials.json`。Windows 存储边界使用隔离属性集测试,新的实际打包候选启动验收待 CI 包产出。 - 凭据拆分提交 `e9026f8` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34221242635),实际四份 TRX 共 4824 项通过,30 项新凭据用例全部实际执行;收据为 `ci-validation-controller-credentials.json`。已补充 Sandbox 启动完成状态检查,避免把持续显示的启动错误页算作成功;PowerShell 两版本各通过 111 项报告断言及 16 项隔离 SQLite 断言。新候选的实际运行证据仍待取得。 - 上述 CI 候选已通过新的实际 Windows 启动验收:12 步、窗口稳定 30247 毫秒,凭据、主窗口和最后启动步骤各成功一次且没有启动错误;7 项清理完成,沙箱销毁,主机代理不变。收据为 `sandbox-package-validation-controller-credentials.json`,绑定源 `e9026f8`、构建 `46154b7` 和脚本 `f5a4502`。脚本 CI 暴露的两个 `.DESCRIPTION` 缺失已补齐;远端 SSH 握手关闭使此候选的绿色载荷复验暂未执行。正常 WPF 安装器和完整页面交互仍未据此判定通过。 -- 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。完整接入及验收继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 +- 脚本说明修复 `3dbbb4e` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34224586524),实际四份 TRX 共 4824 项通过、零失败、零跳过;源及合并提交 tree 一致,收据为 `ci-validation-startup-help.json`。 +- Triggers 适配器拥有真实调度循环,与通知投递共用已安装配置;总开关和通知开关均独立验证,单键应用保留其他待办。修复 UI 设置入口与调度任务相互等待的路径,先排空普通许可再进入设置入口;禁用评估后继续重试释放确认。新增 22 项回归,完整主程序 2781 项通过,18 项目构建零警告、零错误,format 检查 1491 文件、零处变更;收据为 `local-validation-trigger-settings.json`。测试直接使用主程序程序集中的实际调度及通知适配器,外部端口为隔离模拟。 +- 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。Internal、Appearance、Network 的适配及整体装配继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 - main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 ## M3m 连接采样设置统一事务(2026-09-08) From 04ded00568843e04b2cb0eac26f55b341213b845 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 21:19:04 +0800 Subject: [PATCH 08/22] feat: expose installed internal settings through a read-only contract --- .../Settings/IInternalSettingsReader.cs | 9 + .../Settings/InternalSettingsParticipant.cs | 99 ++++++ .../Settings/InternalSettingsSnapshot.cs | 27 ++ .../InternalSettingsParticipantTests.cs | 313 ++++++++++++++++++ .../2026-09-08-settings-generation-cutover.md | 16 +- docs/reviews/1.0.0-execution-ledger.md | 4 +- 6 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 ClashSharp/ClashSharp.Application/Settings/IInternalSettingsReader.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/InternalSettingsParticipant.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/InternalSettingsSnapshot.cs create mode 100644 ClashSharp/ClashSharp.Tests/Integration/InternalSettingsParticipantTests.cs diff --git a/ClashSharp/ClashSharp.Application/Settings/IInternalSettingsReader.cs b/ClashSharp/ClashSharp.Application/Settings/IInternalSettingsReader.cs new file mode 100644 index 0000000..a8549f2 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/IInternalSettingsReader.cs @@ -0,0 +1,9 @@ +namespace ClashSharp.ApplicationModel.Settings; + +/// Provides internal consumers with installed configuration without granting settings mutation or storage access. +public interface IInternalSettingsReader +{ + /// Captures one immutable, generation-bound configuration without I/O; a retired owner rejects new captures. + /// The complete internal configuration installed at this observation. + InternalSettingsSnapshot CaptureSnapshot(); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/InternalSettingsParticipant.cs b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsParticipant.cs new file mode 100644 index 0000000..c400137 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsParticipant.cs @@ -0,0 +1,99 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Owns atomic installation and independent observation of the application-internal consumer configuration. +/// Defaults initialize the actual consumer contract without claiming durable application. The containing generation owns retirement. +public sealed class InternalSettingsParticipant : ISettingsApplicationParticipant, IInternalSettingsReader, IDisposable +{ + private readonly object _gate = new(); + private readonly DataGenerationDescriptor _generation; + private readonly MutationAdmissionBarrier _admission; + private readonly IReadOnlyDictionary _definitions; + private InternalSettingsSnapshot? _installed; + + /// Creates a pure memory owner without opening storage, reading legacy preferences or starting tasks. + /// Immutable consumer lifetime. + /// The process-wide admission owner used by the settings authority. + /// Canonical definitions used by the same generation's settings session. + public InternalSettingsParticipant(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, SettingsRegistry registry) + { + _generation = generation ?? throw new ArgumentNullException(nameof(generation)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + ArgumentNullException.ThrowIfNull(registry); + _definitions = registry.Definitions.Where(definition => definition.ApplicationKind == SettingApplicationKind.Internal) + .ToDictionary(definition => definition.Key); + if (_definitions.Count == 0 || _definitions.Values.Any(definition => definition.Authority != SettingAuthority.Internal)) + { + throw new ArgumentException("Internal settings require application-owned definitions.", nameof(registry)); + } + _installed = new(_generation, _definitions.Select(pair => KeyValuePair.Create(pair.Key, pair.Value.DefaultValue))); + } + + /// + public SettingApplicationKind ApplicationKind => SettingApplicationKind.Internal; + + /// Reads one immutable installed snapshot; subsequent changes cannot modify an already captured value. + /// The complete current consumer configuration from this generation. + public InternalSettingsSnapshot CaptureSnapshot() => Volatile.Read(ref _installed) + ?? throw new ObjectDisposedException(nameof(InternalSettingsParticipant)); + + /// + public Task ProbeAsync(SettingsApplicationRequest request, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + lock (_gate) + { + Validate(request, admissionLease); + cancellationToken.ThrowIfCancellationRequested(); + InternalSettingsSnapshot snapshot = CaptureSnapshot(); + return Task.FromResult(new SettingsApplicationObservation(_generation, request.Batch.BatchId, request.Batch.AttemptId, + request.Values.Keys.Select(key => new SettingValueChange(key, snapshot.Values[key])))); + } + } + + /// + public Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + lock (_gate) + { + Validate(request, admissionLease); + cancellationToken.ThrowIfCancellationRequested(); + Dictionary installed = CaptureSnapshot().Values.ToDictionary(); + foreach ((SettingKey key, SettingValue value) in request.Values) { installed[key] = value; } + InternalSettingsSnapshot next = new(_generation, installed); + cancellationToken.ThrowIfCancellationRequested(); + Volatile.Write(ref _installed, next); + return Task.CompletedTask; + } + } + + /// Rejects future calls without changing historical snapshots or durable preferences. + public void Dispose() + { + lock (_gate) { Volatile.Write(ref _installed, null); } + } + + private void Validate(SettingsApplicationRequest request, MutationAdmissionLease lease) + { + ArgumentNullException.ThrowIfNull(request); + _admission.EnsureActiveLease(lease); + _ = CaptureSnapshot(); + if (request.Phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(lease); } + if (!_generation.IsSameGeneration(request.Generation) || request.Batch.ApplicationKind != ApplicationKind || request.Values.Count == 0) + { + throw new InvalidOperationException("The internal settings attempt belongs to another participant or generation."); + } + foreach ((SettingKey key, SettingValue value) in request.Values) + { + if (!_definitions.TryGetValue(key, out SettingDefinition? definition) + || definition.ApplicationTiming == SettingApplicationTiming.Restart && request.Phase != SettingsApplicationPhase.Startup + || !value.Equals(definition.Normalize(value.CanonicalText).Value)) + { + throw new InvalidOperationException("The internal settings attempt contains an unsupported key or value."); + } + } + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/InternalSettingsSnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsSnapshot.cs new file mode 100644 index 0000000..1d9c4b8 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsSnapshot.cs @@ -0,0 +1,27 @@ +using System.Collections.ObjectModel; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Captures the complete immutable configuration actually installed for internal consumers in one generation. +public sealed class InternalSettingsSnapshot +{ + internal InternalSettingsSnapshot(DataGenerationDescriptor generation, IEnumerable> values) + { + Generation = generation; + Values = new ReadOnlyDictionary(values.ToDictionary()); + } + + /// Gets the exact lifetime that published this historical snapshot. + public DataGenerationDescriptor Generation { get; } + + /// Gets installed values only, excluding pending desired intent and other participants' settings. + public IReadOnlyDictionary Values { get; } + + /// Reads an exact typed internal value without storage I/O. + /// The registry-declared immutable value type. + /// An internal consumer key. + /// The value installed when this snapshot was captured. + public T Get(SettingKey key) where T : notnull => Values[key].Get(); +} diff --git a/ClashSharp/ClashSharp.Tests/Integration/InternalSettingsParticipantTests.cs b/ClashSharp/ClashSharp.Tests/Integration/InternalSettingsParticipantTests.cs new file mode 100644 index 0000000..51f3f0d --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/InternalSettingsParticipantTests.cs @@ -0,0 +1,313 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Settings; +using ClashSharp.Tests.Unit.Settings; + +namespace ClashSharp.Tests.Integration; + +/// Exercises actual internal consumer snapshots with durable settings and generation lifetimes. +public sealed class InternalSettingsParticipantTests +{ + [Fact] + public async Task Defaults_ExposeOnlyInternalConsumerKeysWithoutOpeningStorage() + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationDescriptor generation = directory.CreateGeneration(1); + string[] entriesBefore = Directory.GetFileSystemEntries(directory.RootPath, "*", SearchOption.AllDirectories); + using InternalSettingsParticipant participant = new(generation, new(), SettingsRegistry.Default); + InternalSettingsSnapshot snapshot = participant.CaptureSnapshot(); + Assert.Same(generation, snapshot.Generation); + Assert.Equal(SettingsRegistry.Default.Definitions.Where(definition => definition.ApplicationKind == SettingApplicationKind.Internal) + .Select(definition => definition.Key).OrderBy(key => key.Value), snapshot.Values.Keys.OrderBy(key => key.Value)); + Assert.True(snapshot.Get(SettingsRegistry.Keys.NotificationEnabled)); + Assert.Throws(() => snapshot.Get(SettingsRegistry.Keys.TriggersEnabled)); + Assert.Throws(() => snapshot.Get(SettingsRegistry.Keys.NotificationEnabled)); + Assert.Throws(() => ((IDictionary)snapshot.Values).Clear()); + Assert.Equal(entriesBefore, Directory.GetFileSystemEntries(directory.RootPath, "*", SearchOption.AllDirectories)); + } + + [Fact] + public async Task DurableDesired_DoesNotChangeConsumersUntilTheCompleteBatchIsApplied() + { + await using Fixture fixture = await Fixture.CreateAsync(); + InternalSettingsSnapshot original = fixture.Current(); + SettingsAuthorityResult changed = await fixture.ChangeAsync( + Change("NotificationEnabled", "false"), Change("ConnectionTestProxyUrl1", "https://one.example/"), + Change("ConnectionTestProxyUrl2", "https://two.example/"), Change("ConnectionTestDirectUrl", "https://three.example/")); + Assert.True(changed.IsSucceeded, changed.Code); + Assert.Same(original, fixture.Current()); + CapturingParticipant wrapper = new(fixture.Participant) + { + BeforeApply = async () => + { + Assert.Same(original, fixture.Current()); + Assert.Equal(SettingsApplicationBatchState.Running, + Assert.Single((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.PendingApplications).State); + }, + }; + Assert.True((await fixture.ApplyAsync(SettingsRegistry.Keys.NotificationEnabled, wrapper)).IsSucceeded); + InternalSettingsSnapshot installed = fixture.Current(); + Assert.NotSame(original, installed); + Assert.False(installed.Get(SettingsRegistry.Keys.NotificationEnabled)); + Assert.Equal("https://one.example", installed.Get(SettingsRegistry.Keys.ConnectionTestProxyUrl1)); + Assert.Equal("https://two.example", installed.Get(SettingsRegistry.Keys.ConnectionTestProxyUrl2)); + Assert.Equal("https://three.example", installed.Get(SettingsRegistry.Keys.ConnectionTestDirectUrl)); + Assert.True(original.Get(SettingsRegistry.Keys.NotificationEnabled)); + Assert.Equal(SettingsRegistry.Default.Get("ConnectionTestProxyUrl1").DefaultValue, + original.Values[SettingsRegistry.Keys.ConnectionTestProxyUrl1]); + Assert.Equal(1, wrapper.Applies); + } + + [Fact] + public async Task SingleKeyApplication_PreservesOtherPendingInternalIntent() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("ConnectionTestProxyUrl1", "https://pending.example/"))).IsSucceeded); + SettingsAuthorityResult result = await fixture.Authority.ApplyChangesAsync([Change("NotificationEnabled", "false")], + Guid.NewGuid(), CancellationToken.None); + Assert.True(result.IsSucceeded, result.Code); + Assert.False(fixture.Current().Get(SettingsRegistry.Keys.NotificationEnabled)); + Assert.Equal(SettingsRegistry.Default.Get("ConnectionTestProxyUrl1").DefaultValue, + fixture.Current().Values[SettingsRegistry.Keys.ConnectionTestProxyUrl1]); + Assert.Equal(SettingsRegistry.Keys.ConnectionTestProxyUrl1, Assert.Single(Assert.Single(result.Envelope!.PendingApplications).Entries).Key); + Assert.True((await fixture.ApplyAsync(SettingsRegistry.Keys.ConnectionTestProxyUrl1)).IsSucceeded); + Assert.Equal("https://pending.example", fixture.Current().Get(SettingsRegistry.Keys.ConnectionTestProxyUrl1)); + Assert.False(fixture.Current().Get(SettingsRegistry.Keys.NotificationEnabled)); + } + + [Fact] + public async Task LostApplyReply_IsResolvedFromTheActualConsumerSnapshot() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("NotificationEnabled", "false"))).IsSucceeded); + CapturingParticipant wrapper = new(fixture.Participant) { LoseReply = true }; + SettingsAuthorityResult result = await fixture.ApplyAsync(SettingsRegistry.Keys.NotificationEnabled, wrapper); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal("settings.application.reply_lost_resolved", result.Code); + Assert.False(fixture.Current().Get(SettingsRegistry.Keys.NotificationEnabled)); + Assert.Equal(1, wrapper.Applies); + Assert.Empty(result.Envelope!.PendingApplications); + } + + [Fact] + public async Task ForeignGenerationAndInactiveAdmission_CannotChangeInstalledValues() + { + await using Fixture fixture = await Fixture.CreateAsync(); + CapturingParticipant capture = await fixture.CaptureAttemptAsync(); + using InternalSettingsParticipant other = new(fixture.Directory.CreateGeneration(2), fixture.Admission, SettingsRegistry.Default); + InternalSettingsSnapshot original = other.CaptureSnapshot(); + using MutationAdmissionLease own = fixture.Admission.AcquireOrdinary(); + await Assert.ThrowsAsync(() => other.ApplyAsync(capture.Request!, own, CancellationToken.None)); + await Assert.ThrowsAsync(() => other.ProbeAsync(capture.Request!, own, CancellationToken.None)); + using MutationAdmissionLease foreign = new MutationAdmissionBarrier().AcquireOrdinary(); + await Assert.ThrowsAsync(() => fixture.Participant.ApplyAsync(capture.Request!, foreign, CancellationToken.None)); + MutationAdmissionLease retired = fixture.Admission.AcquireOrdinary(); + retired.Dispose(); + await Assert.ThrowsAsync(() => fixture.Participant.ProbeAsync(capture.Request!, retired, CancellationToken.None)); + Assert.Same(original, other.CaptureSnapshot()); + } + + [Fact] + public async Task AnotherApplicationKind_IsRejectedBeforePublication() + { + await using Fixture fixture = await Fixture.CreateAsync(); + InternalSettingsSnapshot original = fixture.Current(); + Assert.True((await fixture.ChangeAsync(Change("LaunchAtStartupEnabled", "true"))).IsSucceeded); + CapturingParticipant wrongKind = new(fixture.Participant) { ApplicationKind = SettingApplicationKind.StartupTask }; + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, + (await fixture.ApplyAsync(SettingsRegistry.Keys.LaunchAtStartupEnabled, wrongKind)).Status); + using MutationAdmissionLease lease = fixture.Admission.AcquireOrdinary(); + await Assert.ThrowsAsync(() => fixture.Participant.ApplyAsync(wrongKind.Request!, lease, CancellationToken.None)); + Assert.Same(original, fixture.Current()); + } + + [Fact] + public async Task CancelledDirectCalls_LeaveTheInstalledSnapshotUntouched() + { + await using Fixture fixture = await Fixture.CreateAsync(); + CapturingParticipant capture = await fixture.CaptureAttemptAsync(); + InternalSettingsSnapshot original = fixture.Current(); + using MutationAdmissionLease lease = fixture.Admission.AcquireOrdinary(); + using CancellationTokenSource cancelled = new(); + cancelled.Cancel(); + await Assert.ThrowsAnyAsync(() => fixture.Participant.ApplyAsync(capture.Request!, lease, cancelled.Token)); + await Assert.ThrowsAnyAsync(() => fixture.Participant.ProbeAsync(capture.Request!, lease, cancelled.Token)); + Assert.Same(original, fixture.Current()); + } + + [Fact] + public async Task Retirement_RejectsLiveAccessAndPreservesHistoricalSnapshotsAndStorage() + { + await using Fixture fixture = await Fixture.CreateAsync(); + CapturingParticipant capture = await fixture.CaptureAttemptAsync(); + InternalSettingsSnapshot historical = fixture.Current(); + SettingsEnvelope before = (await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!; + fixture.Participant.Dispose(); + fixture.Participant.Dispose(); + Assert.Throws(() => fixture.Current()); + using MutationAdmissionLease lease = fixture.Admission.AcquireOrdinary(); + await Assert.ThrowsAsync(() => fixture.Participant.ProbeAsync(capture.Request!, lease, CancellationToken.None)); + await Assert.ThrowsAsync(() => fixture.Participant.ApplyAsync(capture.Request!, lease, CancellationToken.None)); + Assert.True(historical.Get(SettingsRegistry.Keys.NotificationEnabled)); + Assert.Equal(SettingsEnvelopeCodec.Encode(before, SettingsRegistry.Default).ContentHash, + SettingsEnvelopeCodec.Encode((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!, SettingsRegistry.Default).ContentHash); + } + + [Fact] + public async Task Startup_ReobservesANewConsumerOwnerBeforeReinstallingDurableIntent() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.Authority.ApplyChangesAsync([Change("NotificationEnabled", "false")], + Guid.NewGuid(), CancellationToken.None)).IsSucceeded); + fixture.Participant.Dispose(); + using InternalSettingsParticipant reopened = new(fixture.Session.Generation, fixture.Admission, SettingsRegistry.Default); + Assert.True(reopened.CaptureSnapshot().Get(SettingsRegistry.Keys.NotificationEnabled)); + await using MutationAdmissionLease startup = await fixture.Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, CancellationToken.None); + SettingsAuthorityResult prepared = await fixture.Session.PrepareStartupAdmittedAsync(Guid.NewGuid(), startup, CancellationToken.None); + SettingsApplicationBatch batch = Assert.Single(prepared.Envelope!.PendingApplications, item => item.ApplicationKind == SettingApplicationKind.Internal); + CapturingParticipant wrapper = new(reopened); + SettingsAuthorityResult applied = await fixture.Session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, + wrapper, SettingsApplicationPhase.Startup, startup, CancellationToken.None); + Assert.True(applied.IsSucceeded, applied.Code); + Assert.False(reopened.CaptureSnapshot().Get(SettingsRegistry.Keys.NotificationEnabled)); + Assert.Equal(SettingAppliedValueSource.StartupReconciliation, applied.Envelope!.Applied[SettingsRegistry.Keys.NotificationEnabled].Source); + await startup.DisposeAsync(); + using MutationAdmissionLease ordinary = fixture.Admission.AcquireOrdinary(); + await Assert.ThrowsAsync(() => reopened.ApplyAsync(wrapper.Request!, ordinary, CancellationToken.None)); + } + + [Fact] + public async Task GenerationSwap_ResolvesOnlyTheNewInstalledOwnerAndRetiresThePreviousOne() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.Authority.ApplyChangesAsync([Change("NotificationEnabled", "false")], + Guid.NewGuid(), CancellationToken.None)).IsSucceeded); + InternalSettingsSnapshot previous = fixture.Current(); + DataGenerationTransition transition = await fixture.Generations.BeginDrainAsync(fixture.Generations.CurrentManifest.ContentHash, CancellationToken.None); + DataGenerationDescriptor candidate = fixture.Directory.CreateGeneration(2); + Lifetime next = await Lifetime.CreateAsync(candidate, fixture.Admission); + transition.Stage(new(candidate, next)); + await transition.PromoteManifestAsync(fixture.Directory.Store, CancellationToken.None); + transition.SwapToPromoted(); + await transition.CommitAsync(); + Assert.Throws(() => fixture.Participant.CaptureSnapshot()); + Assert.Equal(candidate.GenerationId, fixture.Current().Generation.GenerationId); + Assert.True(fixture.Current().Get(SettingsRegistry.Keys.NotificationEnabled)); + Assert.False(previous.Get(SettingsRegistry.Keys.NotificationEnabled)); + Assert.True((await fixture.Authority.ApplyChangesAsync([Change("CheckStaleProxyOnStartup", "false")], + Guid.NewGuid(), CancellationToken.None)).IsSucceeded); + Assert.False(fixture.Current().Get(SettingsRegistry.Keys.CheckStaleProxyOnStartup)); + Assert.False((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.Desired[SettingsRegistry.Keys.NotificationEnabled].Value.Get()); + } + + private static SettingValueChange Change(string key, string value) => new(new(key), SettingsEnvelopeTestData.Value(key, value)); + + private sealed class Fixture : IAsyncDisposable + { + public DataGenerationTestDirectory Directory { get; } = new(); + public MutationAdmissionBarrier Admission { get; } = new(); + public DataGenerationManager Generations { get; } = new(); + public Lifetime Lifetime { get; private set; } = null!; + public InternalSettingsParticipant Participant => Lifetime.Participant; + public SettingsAuthoritySession Session => Lifetime.Session; + public JsonSettingsRepository Repository => Lifetime.Repository; + public GenerationSettingsAuthority Authority { get; private set; } = null!; + + public static async Task CreateAsync() + { + Fixture fixture = new(); + try + { + DataGenerationManifestSnapshot manifest = await fixture.Directory.PromoteFirstAsync(); + fixture.Lifetime = await InternalSettingsParticipantTests.Lifetime.CreateAsync(manifest.Descriptor, fixture.Admission); + fixture.Generations.Initialize(manifest, new(manifest.Descriptor, fixture.Lifetime)); + fixture.Authority = new(fixture.Generations, fixture.Admission); + return fixture; + } + catch { await fixture.DisposeAsync(); throw; } + } + + public InternalSettingsSnapshot Current() => Generations.ReadSnapshot( + (participant, generation) => { Assert.True(participant.CaptureSnapshot().Generation.IsSameGeneration(generation)); return participant.CaptureSnapshot(); }); + + public async Task ChangeAsync(params SettingValueChange[] changes) + { + using MutationAdmissionLease lease = Admission.AcquireOrdinary(); + return await Session.ChangeAdmittedAsync(changes, Guid.NewGuid(), lease, CancellationToken.None); + } + + public async Task ApplyAsync(SettingKey key, ISettingsApplicationParticipant? participant = null) + { + using MutationAdmissionLease lease = Admission.AcquireOrdinary(); + SettingsApplicationBatch batch = Assert.Single((await Repository.OpenAsync(CancellationToken.None)).Envelope!.PendingApplications, + item => item.Entries.Any(entry => entry.Key == key)); + return await Session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, participant ?? Participant, + SettingsApplicationPhase.Live, lease, CancellationToken.None); + } + + public async Task CaptureAttemptAsync() + { + Assert.True((await ChangeAsync(Change("NotificationEnabled", "false"))).IsSucceeded); + CapturingParticipant capture = new(Participant) { BeforeApply = () => throw new IOException("Isolated interruption before publication.") }; + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, (await ApplyAsync(SettingsRegistry.Keys.NotificationEnabled, capture)).Status); + Assert.NotNull(capture.Request); + return capture; + } + + public async ValueTask DisposeAsync() + { + await Generations.DisposeAsync(); + if (Lifetime is not null) { await Lifetime.DisposeAsync(); } + await Directory.DisposeAsync(); + } + } + + private sealed class Lifetime : IServiceProvider, IAsyncDisposable + { + private readonly SettingsGenerationContext _context; + private Lifetime(DataGenerationDescriptor generation, MutationAdmissionBarrier admission) + { + Repository = new(generation, SettingsRegistry.Default); + Session = new(Repository, SettingsRegistry.Default, admission); + Participant = new(generation, admission, SettingsRegistry.Default); + _context = new(Session, [Participant]); + } + public JsonSettingsRepository Repository { get; } + public SettingsAuthoritySession Session { get; } + public InternalSettingsParticipant Participant { get; } + + public static async Task CreateAsync(DataGenerationDescriptor generation, MutationAdmissionBarrier admission) + { + Lifetime lifetime = new(generation, admission); + try + { + Assert.True((await lifetime.Repository.SaveAsync(SettingsEnvelopeTestData.CreateMatchingEnvelope(), 0, CancellationToken.None)).IsSucceeded); + return lifetime; + } + catch { await lifetime.DisposeAsync(); throw; } + } + public object? GetService(Type serviceType) => serviceType == typeof(IInternalSettingsReader) ? Participant + : serviceType == typeof(SettingsGenerationContext) ? _context : null; + public async ValueTask DisposeAsync() { await Session.DisposeAsync(); Participant.Dispose(); } + } + + private sealed class CapturingParticipant(InternalSettingsParticipant inner) : ISettingsApplicationParticipant + { + public SettingApplicationKind ApplicationKind { get; init; } = SettingApplicationKind.Internal; + public SettingsApplicationRequest? Request { get; private set; } + public Func? BeforeApply { get; init; } + public bool LoseReply { get; init; } + public int Applies { get; private set; } + public Task ProbeAsync(SettingsApplicationRequest request, MutationAdmissionLease lease, CancellationToken cancellationToken) + { Request = request; return inner.ProbeAsync(request, lease, cancellationToken); } + public async Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease lease, CancellationToken cancellationToken) + { + ++Applies; + if (BeforeApply is not null) { await BeforeApply(); } + await inner.ApplyAsync(request, lease, cancellationToken); + if (LoseReply) { throw new IOException("Isolated lost publication reply."); } + } + } +} diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index 44c7f3c..db72b5f 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -1,6 +1,6 @@ # Settings generation cutover -版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口,以及 StartupTask、Sampling、Triggers 的实际服务适配器。控制端凭据已从偏好中拆分并接入生产启动、运行时和数据清理;生产偏好仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 +版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口、内部设置运行快照,以及 StartupTask、Sampling、Triggers 的实际服务适配器。控制端凭据已从偏好中拆分并接入生产启动、运行时和数据清理;生产偏好仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 ## 已实现的存储与迁移 @@ -30,6 +30,14 @@ 新增回归复现了完整命令的一个衔接缺口:desired 已经提交后,退出开始排空并撤销等待许可,原 session 的普通入口会取消随后的运行时应用。现在 facade 使用内部的已提交命令续行路径,继续验证原许可的有效性,持有原代际,完成全部参与者和保存;后续排队命令仍受撤销控制。直接调用 session 的普通批次入口继续遵守原有 Running 提交前取消规则。 +## 内部设置的只读消费者接口 + +`InternalSettingsParticipant` 只负责 registry 标记为 Internal 的应用内配置。它用规范默认值创建真实内存配置,不读取存储或迁移偏好,也不将构造视为持久 applied 证据。`IInternalSettingsReader` 仅向消费者提供不可变 `InternalSettingsSnapshot`,没有设置写入和仓库访问能力;完整快照包含所属代际,不含其他参与者的设置。 + +desired 发布与配置安装分开进行。会话持久保存 Running 后,参与者原子替换本批次涉及的值,保留其他已安装值和独立待办;probe 从安装后的快照读取,不复述请求。旧快照的整组地址或策略值不会随下一次应用改变。取消发生在发布前时保留原快照,发布后没有可遗弃的后台操作或 I/O;丢失回执由实际快照重新验证。 + +退休阻止新的快照捕获、probe 和 apply,已捕获的历史值及持久数据保持。启动重新观察会识别新消费者的实际默认配置,再安装 durable desired;启动请求要求独占许可。集成测试使用真实 JSON 会话、facade、只读接口和代际管理器,验证代际切换后只解析新实例,原实例已退休,旧磁盘值没有被新实例覆盖。生产消费者的读取端口仍需在整体装配时接入该接口。 + ## StartupTask 与 Sampling 的实际服务适配 `StartupTaskSettingsParticipant` 和 `SamplingSettingsParticipant` 在访问运行时之前检查完整 generation descriptor、应用类别、允许的键和原许可的有效性。两者都不写偏好、不重新申请普通许可。StartupTask 通过生产 `StartupLaunchService` 读取 Windows 注册状态;已满足目标时不重复注册,拒绝或未知状态保留待办。应用回执丢失由后续独立平台探测判断。 @@ -101,10 +109,14 @@ Triggers 适配和入口顺序修复新增 22 项回归,本分支累计净增 160 项。完整主程序 2781 项全部通过,零失败、零跳过,用时 56 秒;18 项目 Release x64 构建零警告、零错误,用时 26.04 秒,format 检查 1491 个文件、零处变更。收据为 `local-validation-trigger-settings.json`,最终报告为 `1.0.0-trigger-settings-both-keys.trx`、`build-trigger-settings-both-keys.log` 和 `format-trigger-settings-both-keys-verified.log`。死锁复现保存在 `1.0.0-trigger-settings-cycle-red.trx`;最初通知键遗漏、程序集引用问题及夹具错误报告分别保留,不将先前未完成的验证累计为通过项。 +触发器提交 `9a331ba` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34229757710),实际四份 TRX 共 4846 项通过、零失败、零跳过,22 项新增用例逐一匹配本地身份。合并提交 `ad5a3b1` 与源提交 tree 同为 `9de5e93966d08e2174e15ec8260a103e1e2ec661`,收据为 `ci-validation-trigger-settings.json`。开发安装器归档 `10057617117` 共 317653478 字节,SHA-256 为 `234b68e19d297c45b0470ccaece0bbe89fb549139407ee2a8124cc4c15fa3033`;该候选只核验构建与元数据,尚未对这组未装配的适配器追加原生运行验收。 + +内部设置只读接口与实际配置所有者追加 10 项回归,本分支累计净增 170 项。完整主程序 2791 项全部通过,零失败、零跳过,用时 58 秒;18 项目 Release x64 构建零警告、零错误,用时 26.06 秒,format 检查 1495 个文件、零处变更。收据为 `local-validation-internal-settings.json`,最终报告为 `1.0.0-internal-settings-main.trx`、`build-internal-settings-complete.log` 和 `format-internal-settings-verified.log`。首次编译修正两处测试断言分析器用法;初轮测试的三个失败来自夹具对预建目录及 URL 规范化的错误预期,报告保留于 `1.0.0-internal-settings-components-final.trx`,不计作产品缺陷复现。 + ## 完整切换的剩余依赖 1. 将偏好写入统一为应用层异步 change set;页面、磁贴、触发器和网络提交者使用同一个接口。独立控制端凭据已接入生产调用,后续代际重置继续使用该能力。 -2. 完成 Internal、Appearance、Network 的实际 apply/probe 适配器,并将已实现的 StartupTask、Sampling、Triggers 一起装配;明确读取 desired、有效状态和待办的消费者。 +2. 完成 Appearance、Network 的实际 apply/probe 适配器,并将已实现的 Internal、StartupTask、Sampling、Triggers 一起装配;明确读取 desired、有效状态和待办的消费者。 3. 在设置驱动的启动步骤之前完成旧事务恢复、代际打开和偏好迁移。profile/log/trigger 与 settings 必须由同一代际容器解析、排空和替换。 4. 将导入、重置和回滚接入候选代际及 manifest 提交,完成生产消费者替换后,原子替换 `SettingsAuthorityArchitectureTests` 中的临时门禁。 5. 运行新候选的 CI、打包应用及隔离 Windows 验收,再将完整节点推送 main。 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index cf7962b..62dba4d 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -28,7 +28,9 @@ - 上述 CI 候选已通过新的实际 Windows 启动验收:12 步、窗口稳定 30247 毫秒,凭据、主窗口和最后启动步骤各成功一次且没有启动错误;7 项清理完成,沙箱销毁,主机代理不变。收据为 `sandbox-package-validation-controller-credentials.json`,绑定源 `e9026f8`、构建 `46154b7` 和脚本 `f5a4502`。脚本 CI 暴露的两个 `.DESCRIPTION` 缺失已补齐;远端 SSH 握手关闭使此候选的绿色载荷复验暂未执行。正常 WPF 安装器和完整页面交互仍未据此判定通过。 - 脚本说明修复 `3dbbb4e` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34224586524),实际四份 TRX 共 4824 项通过、零失败、零跳过;源及合并提交 tree 一致,收据为 `ci-validation-startup-help.json`。 - Triggers 适配器拥有真实调度循环,与通知投递共用已安装配置;总开关和通知开关均独立验证,单键应用保留其他待办。修复 UI 设置入口与调度任务相互等待的路径,先排空普通许可再进入设置入口;禁用评估后继续重试释放确认。新增 22 项回归,完整主程序 2781 项通过,18 项目构建零警告、零错误,format 检查 1491 文件、零处变更;收据为 `local-validation-trigger-settings.json`。测试直接使用主程序程序集中的实际调度及通知适配器,外部端口为隔离模拟。 -- 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。Internal、Appearance、Network 的适配及整体装配继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 +- 触发器提交 `9a331ba` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34229757710),实际四份 TRX 共 4846 项通过,22 项新增用例全部核对;源与合并提交 tree 一致,收据为 `ci-validation-trigger-settings.json`。安装器开发包构建成功,本次只核验制品元数据。 +- 内部设置新增只读消费者接口和不可变运行快照,批次原子安装后独立观察,保留其他待办和历史快照。真实 JSON、facade 与代际管理器验证新实例读取及旧实例退休,追加 10 项回归;完整主程序 2791 项通过,18 项目构建零警告、零错误,format 检查 1495 文件、零处变更。本分支累计净增 170 项,收据为 `local-validation-internal-settings.json`。 +- 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。Appearance、Network 的适配及整体装配继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 - main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 ## M3m 连接采样设置统一事务(2026-09-08) From a4f6368f89aedea56e34bbe1c30199969d6527f5 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 22:05:14 +0800 Subject: [PATCH 09/22] fix: verify applied WinUI accent resources before reporting success --- .../Settings/AccentColorConfiguration.cs | 31 +++ .../Settings/AccentColorRuntime.cs | 91 +++++++ .../Settings/IAccentResourceStore.cs | 19 ++ .../Resources/AppResourcePackagingTests.cs | 4 +- .../Unit/Services/AppThemePaletteTests.cs | 87 +++++++ .../AppThemeUnavailableResourceTests.cs | 22 ++ .../Unit/Settings/AccentColorRuntimeTests.cs | 235 ++++++++++++++++++ .../Composition/MainWindowComposition.cs | 5 + .../ClashSharp/Service/AppThemeService.cs | 112 ++++----- .../Service/WinUiAccentResourceStore.cs | 53 ++++ .../2026-09-08-settings-generation-cutover.md | 14 ++ docs/reviews/1.0.0-execution-ledger.md | 2 + 12 files changed, 617 insertions(+), 58 deletions(-) create mode 100644 ClashSharp/ClashSharp.Application/Settings/AccentColorConfiguration.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/AccentColorRuntime.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/IAccentResourceStore.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Services/AppThemePaletteTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Services/AppThemeUnavailableResourceTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Settings/AccentColorRuntimeTests.cs create mode 100644 ClashSharp/ClashSharp/Service/WinUiAccentResourceStore.cs diff --git a/ClashSharp/ClashSharp.Application/Settings/AccentColorConfiguration.cs b/ClashSharp/ClashSharp.Application/Settings/AccentColorConfiguration.cs new file mode 100644 index 0000000..66f2947 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/AccentColorConfiguration.cs @@ -0,0 +1,31 @@ +using ClashSharp.Model; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Contains the configured accent policy and canonical custom color, including the retained color while following Windows. +public sealed record AccentColorConfiguration +{ + /// Normalizes one complete accent selection without touching platform resources. + /// A declared accent policy. + /// A valid custom ARGB color. + public AccentColorConfiguration(AppAccentColorMode mode, string colorValue) + { + if (!Enum.IsDefined(mode)) { throw new ArgumentOutOfRangeException(nameof(mode)); } + SettingNormalizationResult normalized = SettingsRegistry.Default.Get(SettingsRegistry.Keys.AppAccentColorValue.Value).Normalize(colorValue); + if (!normalized.IsSuccess) { throw new ArgumentException("The accent color is invalid.", nameof(colorValue)); } + Mode = mode; + ColorValue = normalized.Value!.Get(); + } + + /// Gets the installed accent policy. + public AppAccentColorMode Mode { get; } + + /// Gets the normalized configured custom color. + public string ColorValue { get; } +} + +/// Describes one color or fully opaque solid-brush resource independently of WinUI objects. +/// The exact ARGB channels; color alpha is independent of brush opacity. +/// Whether the resource must be a solid brush instead of a color value. +public readonly record struct AccentResourceValue(uint Argb, bool IsBrush); diff --git a/ClashSharp/ClashSharp.Application/Settings/AccentColorRuntime.cs b/ClashSharp/ClashSharp.Application/Settings/AccentColorRuntime.cs new file mode 100644 index 0000000..a41aaf2 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/AccentColorRuntime.cs @@ -0,0 +1,91 @@ +using System.Collections.ObjectModel; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.Model; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Records accent application only after independently observing the complete resource palette. +/// All calls are made on the resource dictionary's owning UI thread. Construction performs no platform access. +public sealed class AccentColorRuntime +{ + private readonly IAccentResourceStore _resources; + private readonly IReadOnlyCollection _ownedKeys; + private readonly Func> _buildPalette; + private Palette _verified; + private Palette? _attempted; + + /// Creates an owner with a declared default policy that still requires a real resource observation. + /// The actual application dictionary boundary. + /// Every primary resource key this owner can change. + /// Pure complete-palette construction; follow-system selections produce no overrides. + public AccentColorRuntime(IAccentResourceStore resources, IEnumerable ownedKeys, + Func> buildPalette) + { + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + ArgumentNullException.ThrowIfNull(ownedKeys); + string[] keys = ownedKeys.ToArray(); + if (keys.Length == 0 || keys.Any(string.IsNullOrWhiteSpace) || keys.Distinct(StringComparer.Ordinal).Count() != keys.Length) + { + throw new ArgumentException("Accent resource keys must be nonempty and unique.", nameof(ownedKeys)); + } + _ownedKeys = Array.AsReadOnly(keys); + _buildPalette = buildPalette ?? throw new ArgumentNullException(nameof(buildPalette)); + _verified = Build(new(AppAccentColorMode.FollowSystem, "#FF0078D4")); + } + + /// Reads configured state only when the actual complete resource set supports that state. + /// The independently verified current selection. + public AccentColorConfiguration CaptureConfiguration() + { + IReadOnlyDictionary actual = _resources.CaptureLocalOverrides(_ownedKeys); + if (_attempted is not null && Matches(_attempted, actual)) { _verified = _attempted; _attempted = null; } + if (!Matches(_verified, actual)) { throw new InvalidOperationException("The application accent resources are not verified."); } + return _verified.Configuration; + } + + /// Writes a complete palette, then reads every owned override before acknowledging installation. + /// The complete canonical selection. + public void Apply(AccentColorConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + Palette target = Build(configuration); + // Confirm availability before the first effect. An attempted palette is only a candidate + // for later independent observation, never evidence that a write succeeded. + _ = _resources.CaptureLocalOverrides(_ownedKeys); + _attempted = target; + Exception? writeFailure = null; + try + { + foreach (string key in _ownedKeys) + { + if (target.Values.TryGetValue(key, out AccentResourceValue value)) { _resources.WriteOverride(key, value); } + else { _resources.RemoveOverride(key); } + } + } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { writeFailure = exception; } + + if (!Matches(target, _resources.CaptureLocalOverrides(_ownedKeys))) + { + if (writeFailure is not null) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(writeFailure).Throw(); } + throw new InvalidOperationException("The complete application accent palette could not be verified."); + } + _verified = target; + _attempted = null; + } + + private Palette Build(AccentColorConfiguration configuration) + { + Dictionary values = _buildPalette(configuration).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + if (values.Keys.Any(key => !_ownedKeys.Contains(key, StringComparer.Ordinal)) + || values.Count != (configuration.Mode == AppAccentColorMode.FollowSystem ? 0 : _ownedKeys.Count)) + { + throw new InvalidOperationException("The accent palette does not cover the declared resource contract."); + } + return new(configuration, new ReadOnlyDictionary(values)); + } + + private static bool Matches(Palette target, IReadOnlyDictionary actual) => + actual.Count == target.Values.Count && target.Values.All(pair => actual.TryGetValue(pair.Key, out AccentResourceValue? observed) && observed == pair.Value); + + private sealed record Palette(AccentColorConfiguration Configuration, IReadOnlyDictionary Values); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/IAccentResourceStore.cs b/ClashSharp/ClashSharp.Application/Settings/IAccentResourceStore.cs new file mode 100644 index 0000000..42727ff --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/IAccentResourceStore.cs @@ -0,0 +1,19 @@ +namespace ClashSharp.ApplicationModel.Settings; + +/// Accesses the primary application dictionary's accent overrides on its owning UI thread. +public interface IAccentResourceStore +{ + /// Captures locally defined owned keys; absent keys are omitted and incompatible resource values are represented by null. + /// The complete set of keys owned by accent application. + /// A stable snapshot that excludes inherited and merged resources. + IReadOnlyDictionary CaptureLocalOverrides(IReadOnlyCollection ownedKeys); + + /// Installs one exact owned color or solid-brush resource. + /// The exact owned resource key. + /// The requested color and resource kind. + void WriteOverride(string key, AccentResourceValue value); + + /// Removes one primary-dictionary override while preserving merged system resources. + /// The exact owned resource key. + void RemoveOverride(string key); +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Resources/AppResourcePackagingTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Resources/AppResourcePackagingTests.cs index 9c34bee..a7d57ce 100644 --- a/ClashSharp/ClashSharp.Tests/Unit/Resources/AppResourcePackagingTests.cs +++ b/ClashSharp/ClashSharp.Tests/Unit/Resources/AppResourcePackagingTests.cs @@ -691,7 +691,9 @@ public void AppThemeService_OverridesAccentBrushResources() Assert.Contains(resourceKey, serviceCode, StringComparison.Ordinal); } - Assert.Contains("new SolidColorBrush", serviceCode, StringComparison.Ordinal); + string resourceAdapter = File.ReadAllText(FindSourceFile("ClashSharp", "ClashSharp", "Service", "WinUiAccentResourceStore.cs")); + Assert.Contains("AccentRuntime.Apply", serviceCode, StringComparison.Ordinal); + Assert.Contains("new SolidColorBrush", resourceAdapter, StringComparison.Ordinal); } /// Verifies region display names are resolved through localization keys. diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/AppThemePaletteTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/AppThemePaletteTests.cs new file mode 100644 index 0000000..b5588da --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/AppThemePaletteTests.cs @@ -0,0 +1,87 @@ +extern alias ClashSharpUi; + +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Model; +using AppThemeService = ClashSharpUi::ClashSharp.Service.AppThemeService; + +namespace ClashSharp.Tests.Unit.Services; + +/// Exercises the main assembly's real palette composition through its resource dictionary boundary. +public sealed class AppThemePaletteTests +{ + [Fact] + public void CustomAccent_InstallsTheCompleteProductionPaletteWithIndependentArgbExpectations() + { + ResourceStore resources = new(); + AccentColorRuntime runtime = AppThemeService.CreateAccentRuntime(resources); + Assert.Equal(0, resources.ReadCount); + Assert.Equal(0, resources.WriteCount); + AccentColorConfiguration custom = new(AppAccentColorMode.Custom, "#804477AA"); + + runtime.Apply(custom); + + Assert.Equal(custom, runtime.CaptureConfiguration()); + Assert.Equal(48, resources.Local.Count); + Assert.Equal(48, resources.WriteCount); + Assert.Equal(7, resources.Local.Count(pair => !pair.Value.IsBrush)); + Assert.Equal(41, resources.Local.Count(pair => pair.Value.IsBrush)); + Assert.Equal(new(0x804477AA, false), resources.Local["SystemAccentColor"]); + Assert.Equal(new(0x807CA0C4, false), resources.Local["SystemAccentColorLight1"]); + Assert.Equal(new(0x80A2BBD4, false), resources.Local["SystemAccentColorLight2"]); + Assert.Equal(new(0x80C7D6E6, false), resources.Local["SystemAccentColorLight3"]); + Assert.Equal(new(0x80335980, false), resources.Local["SystemAccentColorDark1"]); + Assert.Equal(new(0x8025415E, false), resources.Local["SystemAccentColorDark2"]); + Assert.Equal(new(0x80182A3C, false), resources.Local["SystemAccentColorDark3"]); + Assert.Equal(new(0x804477AA, true), resources.Local["AccentButtonBackground"]); + Assert.Equal(new(0x807CA0C4, true), resources.Local["AccentButtonBackgroundPointerOver"]); + Assert.Equal(new(0x80335980, true), resources.Local["AccentButtonBackgroundPressed"]); + Assert.Equal(new(0x5C4477AA, true), resources.Local["AccentButtonBackgroundDisabled"]); + Assert.Equal(new(0x00FFFFFF, true), resources.Local["AccentButtonBorderBrush"]); + Assert.Equal(new(0xFFFFFFFF, true), resources.Local["AccentButtonForeground"]); + Assert.Equal(new(0x5CFFFFFF, true), resources.Local["AccentButtonForegroundDisabled"]); + Assert.Equal(new(0xCCFFFFFF, true), resources.Local["AccentTextFillColorTertiaryBrush"]); + Assert.Equal(new(0x80A2BBD4, true), resources.Local["ToggleSwitchFillOnPressed"]); + Assert.Equal(new(0xFFFFFFFF, true), resources.Local["TextOnAccentFillColorPrimaryBrush"]); + } + + [Fact] + public void FollowSystem_RemovesEveryProductionOverrideAndRetainsTheConfiguredCustomColor() + { + ResourceStore resources = new(); + AccentResourceValue unrelated = new(0xFF123456, true); + AccentResourceValue systemAccent = new(0xFF00CC88, false); + resources.Local.Add("UnrelatedResource", unrelated); + resources.Merged.Add("SystemAccentColor", systemAccent); + AccentColorRuntime runtime = AppThemeService.CreateAccentRuntime(resources); + runtime.Apply(new(AppAccentColorMode.Custom, "#804477AA")); + AccentColorConfiguration followSystem = new(AppAccentColorMode.FollowSystem, "#804477AA"); + + runtime.Apply(followSystem); + + Assert.Equal(followSystem, runtime.CaptureConfiguration()); + Assert.Equal(new KeyValuePair("UnrelatedResource", unrelated), Assert.Single(resources.Local)); + Assert.Equal(new KeyValuePair("SystemAccentColor", systemAccent), Assert.Single(resources.Merged)); + Assert.Equal(48, resources.Removed.Count); + Assert.Equal(48, resources.Removed.Distinct(StringComparer.Ordinal).Count()); + Assert.DoesNotContain("UnrelatedResource", resources.Removed); + } + + private sealed class ResourceStore : IAccentResourceStore + { + public Dictionary Local { get; } = new(StringComparer.Ordinal); + public Dictionary Merged { get; } = new(StringComparer.Ordinal); + public List Removed { get; } = []; + public int ReadCount { get; private set; } + public int WriteCount { get; private set; } + + public IReadOnlyDictionary CaptureLocalOverrides(IReadOnlyCollection ownedKeys) + { + ReadCount++; + return Local.Where(pair => ownedKeys.Contains(pair.Key, StringComparer.Ordinal)) + .ToDictionary(pair => pair.Key, pair => (AccentResourceValue?)pair.Value, StringComparer.Ordinal); + } + + public void WriteOverride(string key, AccentResourceValue value) { WriteCount++; Local[key] = value; } + public void RemoveOverride(string key) { Removed.Add(key); Local.Remove(key); } + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/AppThemeUnavailableResourceTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/AppThemeUnavailableResourceTests.cs new file mode 100644 index 0000000..6c30fc7 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/AppThemeUnavailableResourceTests.cs @@ -0,0 +1,22 @@ +extern alias ClashSharpUi; + +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.Model; +using AppThemeService = ClashSharpUi::ClashSharp.Service.AppThemeService; + +namespace ClashSharp.Tests.Unit.Services; + +/// Uses the actual main-assembly entry point in a test process that has no WinUI application. +public sealed class AppThemeUnavailableResourceTests +{ + [Theory] + [InlineData(AppAccentColorMode.FollowSystem, "#FF0078D4")] + [InlineData(AppAccentColorMode.Custom, "#804477AA")] + public void MissingApplicationResources_CannotClaimTheRequestedAccentIsApplied(AppAccentColorMode mode, string color) + { + Assert.Null(ClashSharpUi::ClashSharp.App.MainWindow); + Exception? failure = Record.Exception(() => AppThemeService.ApplyAccentColor(mode, color)); + if (failure is not null) { Assert.False(ExceptionGraphClassifier.IsProcessFatal(failure)); } + Assert.True(AppThemeService.IsAccentColorRestartPending(mode, color)); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Settings/AccentColorRuntimeTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Settings/AccentColorRuntimeTests.cs new file mode 100644 index 0000000..1c94748 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Settings/AccentColorRuntimeTests.cs @@ -0,0 +1,235 @@ +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Model; + +namespace ClashSharp.Tests.Unit.Settings; + +/// Verifies complete resource observation, unavailable resources and interrupted accent writes. +public sealed class AccentColorRuntimeTests +{ + private static readonly AccentColorConfiguration Blue = new(AppAccentColorMode.Custom, "#FF0078D4"); + private static readonly AccentColorConfiguration Purple = new(AppAccentColorMode.Custom, "#804477AA"); + private static readonly AccentColorConfiguration System = new(AppAccentColorMode.FollowSystem, "#FF0078D4"); + + [Fact] + public void Construction_DoesNotOpenResourcesOrWriteDefaults() + { + Store store = new() { BeforeRead = () => throw new InvalidOperationException("No application.") }; + AccentColorRuntime runtime = Create(store); + Assert.Equal(0, store.Reads); + Assert.Equal(0, store.Writes); + Assert.Throws(() => runtime.CaptureConfiguration()); + Assert.Equal(1, store.Reads); + } + + [Fact] + public void UnavailableResources_RejectTheRequestBeforeAnyWriteOrFalseAcknowledgement() + { + Store store = new() { BeforeRead = () => throw new InvalidOperationException("No application.") }; + AccentColorRuntime runtime = Create(store); + Assert.Throws(() => runtime.Apply(Purple)); + Assert.Equal(0, store.Writes); + store.BeforeRead = null; + Assert.Equal(System, runtime.CaptureConfiguration()); + } + + [Fact] + public void CompletePalette_IsIndependentlyObservedIncludingResourceKindAndColorAlpha() + { + Store store = new(); + AccentColorRuntime runtime = Create(store); + runtime.Apply(Purple); + Assert.Equal(Purple, runtime.CaptureConfiguration()); + Assert.Equal(new AccentResourceValue(0x804477AA, false), store.Values["color"]); + Assert.Equal(new AccentResourceValue(0x804477AA, true), store.Values["brush"]); + Assert.Equal(2, store.Writes); + Assert.Equal(3, store.Reads); + store.Values["brush"] = new(0x804477AA, false); + Assert.Throws(() => runtime.CaptureConfiguration()); + } + + [Theory] + [InlineData(1, true)] + [InlineData(2, false)] + public void InterruptedWrite_ObservesTheOldPaletteOrUnknownInsteadOfClaimingSuccess(int failAtWrite, bool oldStillIntact) + { + Store store = new(); + AccentColorRuntime runtime = Create(store); + runtime.Apply(Blue); + int priorWrites = store.Writes; + IOException failure = new("Isolated resource write failure."); + store.BeforeWrite = () => { if (store.Writes == priorWrites + failAtWrite) { throw failure; } }; + Assert.Same(failure, Assert.Throws(() => runtime.Apply(Purple))); + if (oldStillIntact) { Assert.Equal(Blue, runtime.CaptureConfiguration()); } + else { Assert.Throws(() => runtime.CaptureConfiguration()); } + store.BeforeWrite = null; + runtime.Apply(Purple); + Assert.Equal(Purple, runtime.CaptureConfiguration()); + } + + [Fact] + public void LostLastWriteReply_IsResolvedFromTheCompleteActualPalette() + { + Store store = new(); + AccentColorRuntime runtime = Create(store); + store.AfterWrite = () => { if (store.Writes == 2) { throw new IOException("Isolated lost reply."); } }; + runtime.Apply(Purple); + Assert.Equal(Purple, runtime.CaptureConfiguration()); + Assert.Equal(2, store.Writes); + } + + [Fact] + public void FailedFinalObservation_RetainsOnlyAnAttemptUntilResourcesCanBeReadAgain() + { + Store store = new(); + AccentColorRuntime runtime = Create(store); + store.BeforeRead = () => { if (store.Reads == 2) { throw new IOException("Isolated observation failure."); } }; + Assert.Throws(() => runtime.Apply(Purple)); + Assert.Equal(2, store.Writes); + store.BeforeRead = null; + Assert.Equal(Purple, runtime.CaptureConfiguration()); + Assert.Equal(2, store.Writes); + } + + [Fact] + public void AcknowledgedButMissingWrites_AreRejectedByIndependentObservation() + { + Store store = new() { IgnoreWrites = true }; + AccentColorRuntime runtime = Create(store); + Assert.Throws(() => runtime.Apply(Purple)); + Assert.Equal(System, runtime.CaptureConfiguration()); + } + + [Fact] + public void InvalidResourceValue_CannotMasqueradeAsAbsenceOrAsASolidBrush() + { + Store store = new(); + AccentColorRuntime runtime = Create(store); + store.Values["brush"] = null; + Assert.Throws(() => runtime.CaptureConfiguration()); + runtime.Apply(Purple); + store.Values["brush"] = null; + Assert.Throws(() => runtime.CaptureConfiguration()); + } + + [Fact] + public void FollowSystem_RemovesOnlyOwnedOverridesAndRetainsTheConfiguredCustomColor() + { + Store store = new(); + store.Values["unrelated"] = new(0x01234567, false); + store.Merged["brush"] = new(0xFF112233, true); + AccentColorRuntime runtime = Create(store); + runtime.Apply(Purple); + AccentColorConfiguration following = new(AppAccentColorMode.FollowSystem, Purple.ColorValue); + runtime.Apply(following); + Assert.Equal(following, runtime.CaptureConfiguration()); + Assert.Equal("unrelated", Assert.Single(store.Values).Key); + Assert.Equal(new AccentResourceValue(0xFF112233, true), store.Merged["brush"]); + Assert.Equal(["color", "brush"], store.RemovedKeys); + } + + [Fact] + public void IncompleteRemoval_CannotClaimTheSystemPaletteIsActive() + { + Store store = new(); + AccentColorRuntime runtime = Create(store); + runtime.Apply(Purple); + store.BeforeWrite = () => { if (store.Writes == 4) { throw new IOException("Isolated remove failure."); } }; + Assert.Throws(() => runtime.Apply(System)); + Assert.Throws(() => runtime.CaptureConfiguration()); + store.BeforeWrite = null; + runtime.Apply(System); + Assert.Equal(System, runtime.CaptureConfiguration()); + } + + [Theory] + [InlineData("preflight")] + [InlineData("write")] + [InlineData("verification")] + public void FatalExceptionGraphs_KeepTheirIdentity(string stage) + { + Store store = new(); + AccentColorRuntime runtime = Create(store); + InvalidOperationException fatal = new("Isolated wrapper.", new AggregateException(Activator.CreateInstance())); + if (stage == "write") { store.BeforeWrite = () => throw fatal; } + else { store.BeforeRead = () => { if (store.Reads == (stage == "preflight" ? 1 : 2)) { throw fatal; } }; } + Assert.Same(fatal, Assert.Throws(() => runtime.Apply(Purple))); + } + + [Theory] + [InlineData("partial")] + [InlineData("foreign")] + [InlineData("system-override")] + public void InvalidPaletteContracts_AreRejectedBeforePlatformAccess(string kind) + { + Store store = new(); + bool invalid = false; + AccentColorRuntime runtime = new(store, ["color", "brush"], configuration => + { + if (!invalid) { return Build(configuration); } + return kind == "foreign" ? new Dictionary { ["other"] = new(0, false) } + : new Dictionary { ["color"] = new(0, false) }; + }); + invalid = true; + Assert.Throws(() => runtime.Apply(kind == "system-override" ? System : Purple)); + Assert.Equal(0, store.Reads); + Assert.Equal(0, store.Writes); + } + + [Fact] + public void PaletteInputs_AreCopiedBeforeEffectsCanChangeTheirSource() + { + Store store = new(); + string[] keys = ["color", "brush"]; + Dictionary source = Build(Purple); + AccentColorRuntime runtime = new(store, keys, configuration => configuration.Mode == AppAccentColorMode.FollowSystem + ? new Dictionary() : source); + keys[0] = "unrelated"; + store.BeforeWrite = () => source.Clear(); + runtime.Apply(Purple); + Assert.Equal(Purple, runtime.CaptureConfiguration()); + Assert.Equal(2, store.Values.Count); + Assert.False(store.Values.ContainsKey("unrelated")); + } + + private static AccentColorRuntime Create(Store store) => new(store, ["color", "brush"], Build); + private static Dictionary Build(AccentColorConfiguration configuration) + { + if (configuration.Mode == AppAccentColorMode.FollowSystem) { return []; } + uint argb = Convert.ToUInt32(configuration.ColorValue[1..], 16); + return new(StringComparer.Ordinal) { ["color"] = new(argb, false), ["brush"] = new(argb, true) }; + } + + private sealed class Store : IAccentResourceStore + { + public Dictionary Values { get; } = new(StringComparer.Ordinal); + public Dictionary Merged { get; } = new(StringComparer.Ordinal); + public List RemovedKeys { get; } = []; + public int Reads { get; private set; } + public int Writes { get; private set; } + public bool IgnoreWrites { get; init; } + public Action? BeforeRead { get; set; } + public Action? BeforeWrite { get; set; } + public Action? AfterWrite { get; set; } + public IReadOnlyDictionary CaptureLocalOverrides(IReadOnlyCollection ownedKeys) + { + ++Reads; + BeforeRead?.Invoke(); + return Values.Where(pair => ownedKeys.Contains(pair.Key, StringComparer.Ordinal)).ToDictionary(); + } + public void WriteOverride(string key, AccentResourceValue value) + { + ++Writes; + BeforeWrite?.Invoke(); + if (!IgnoreWrites) { Values[key] = value; } + AfterWrite?.Invoke(); + } + public void RemoveOverride(string key) + { + ++Writes; + BeforeWrite?.Invoke(); + Values.Remove(key); + RemovedKeys.Add(key); + AfterWrite?.Invoke(); + } + } +} diff --git a/ClashSharp/ClashSharp/Presentation/Composition/MainWindowComposition.cs b/ClashSharp/ClashSharp/Presentation/Composition/MainWindowComposition.cs index 32dc488..efd2d30 100644 --- a/ClashSharp/ClashSharp/Presentation/Composition/MainWindowComposition.cs +++ b/ClashSharp/ClashSharp/Presentation/Composition/MainWindowComposition.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using ClashSharp.ApplicationModel.Network; using ClashSharp.ApplicationModel.Presentation; +using ClashSharp.ApplicationModel.Settings; using ClashSharp.Model; using ClashSharp.Presentation.Adapters; using ClashSharp.Presentation.Dialogs; @@ -158,6 +159,10 @@ public void ApplyTheme(FrameworkElement root) { ArgumentNullException.ThrowIfNull(root); AppThemeService.Apply(root, _settings.AppThemeMode); + if (AppThemeService.ReadAccentConfiguration() != new AccentColorConfiguration(_settings.AppAccentColorMode, _settings.AppAccentColorValue)) + { + throw new InvalidOperationException("The startup accent resources do not match the selected configuration."); + } } /// Gets a localized string for shell-owned UI. diff --git a/ClashSharp/ClashSharp/Service/AppThemeService.cs b/ClashSharp/ClashSharp/Service/AppThemeService.cs index b92ef8d..c189880 100644 --- a/ClashSharp/ClashSharp/Service/AppThemeService.cs +++ b/ClashSharp/ClashSharp/Service/AppThemeService.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; +using System.Linq; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.ApplicationModel.Settings; using ClashSharp.Model; -using Microsoft.UI; using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Media; using Windows.UI; namespace ClashSharp.Service; @@ -10,12 +12,6 @@ namespace ClashSharp.Service; /// Applies the configured app display style to the active window. internal static class AppThemeService { - private const string DefaultAccentColorValue = "#FF0078D4"; - - private static AppAccentColorMode _appliedAccentColorMode = AppAccentColorMode.FollowSystem; - - private static string _appliedAccentColorValue = DefaultAccentColorValue; - /// Application resource keys overridden for custom accent colors. private static readonly string[] AccentColorResourceKeys = [ @@ -74,6 +70,12 @@ internal static class AppThemeService "TextOnAccentFillColorPrimaryBrush", ]; + private static readonly AccentColorRuntime AccentRuntime = CreateAccentRuntime(new WinUiAccentResourceStore()); + + /// Composes the complete application palette with the resource dictionary boundary. + internal static AccentColorRuntime CreateAccentRuntime(IAccentResourceStore resources) => + new(resources, AccentColorResourceKeys.Concat(AccentBrushResourceKeys), CreateAccentPalette); + /// Applies to the main window root when available. public static void Apply(AppThemeMode mode) { @@ -101,51 +103,42 @@ public static void Apply(FrameworkElement root, AppThemeMode mode) /// Custom accent color in #AARRGGBB format. public static void ApplyAccentColor(AppAccentColorMode mode, string colorValue) { - _appliedAccentColorMode = mode; - _appliedAccentColorValue = NormalizeAccentColorValue(colorValue); - - if (Application.Current is null) - { - return; - } - - ResourceDictionary resources = Application.Current.Resources; - if (mode == AppAccentColorMode.FollowSystem) - { - foreach (string key in AccentColorResourceKeys) - { - resources.Remove(key); - } - - foreach (string key in AccentBrushResourceKeys) - { - resources.Remove(key); - } + AccentRuntime.Apply(new(mode, NormalizeAccentColorValue(colorValue))); + } - return; - } + /// Reads accent configuration after verifying the complete primary-dictionary resource set. + public static AccentColorConfiguration ReadAccentConfiguration() => AccentRuntime.CaptureConfiguration(); - Color accentColor = ParseAccentColorOrDefault(_appliedAccentColorValue); - Color light1 = Blend(accentColor, Colors.White, 0.30); - Color light2 = Blend(accentColor, Colors.White, 0.50); - Color light3 = Blend(accentColor, Colors.White, 0.70); - Color dark1 = Blend(accentColor, Colors.Black, 0.25); - Color dark2 = Blend(accentColor, Colors.Black, 0.45); - Color dark3 = Blend(accentColor, Colors.Black, 0.65); - SolidColorBrush accentBrush = new(accentColor); - SolidColorBrush light1Brush = new(light1); - SolidColorBrush light2Brush = new(light2); - SolidColorBrush disabledAccentBrush = new(Color.FromArgb(0x5C, accentColor.R, accentColor.G, accentColor.B)); - SolidColorBrush whiteBrush = new(Colors.White); - SolidColorBrush transparentBrush = new(Colors.Transparent); + /// Builds a complete palette before any application resource is changed. + private static IReadOnlyDictionary CreateAccentPalette(AccentColorConfiguration configuration) + { + Dictionary resources = new(StringComparer.Ordinal); + if (configuration.Mode == AppAccentColorMode.FollowSystem) { return resources; } + static AccentResourceValue Brush(Color color) => new(WinUiAccentResourceStore.ToArgb(color), true); + static AccentResourceValue ColorValue(Color color) => new(WinUiAccentResourceStore.ToArgb(color), false); + Color accentColor = ParseAccentColorOrDefault(configuration.ColorValue); + Color white = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF); + Color black = Color.FromArgb(0xFF, 0, 0, 0); + Color light1 = Blend(accentColor, white, 0.30); + Color light2 = Blend(accentColor, white, 0.50); + Color light3 = Blend(accentColor, white, 0.70); + Color dark1 = Blend(accentColor, black, 0.25); + Color dark2 = Blend(accentColor, black, 0.45); + Color dark3 = Blend(accentColor, black, 0.65); + AccentResourceValue accentBrush = Brush(accentColor); + AccentResourceValue light1Brush = Brush(light1); + AccentResourceValue light2Brush = Brush(light2); + AccentResourceValue disabledAccentBrush = Brush(Color.FromArgb(0x5C, accentColor.R, accentColor.G, accentColor.B)); + AccentResourceValue whiteBrush = Brush(white); + AccentResourceValue transparentBrush = Brush(Color.FromArgb(0, 0xFF, 0xFF, 0xFF)); - resources["SystemAccentColor"] = accentColor; - resources["SystemAccentColorLight1"] = light1; - resources["SystemAccentColorLight2"] = light2; - resources["SystemAccentColorLight3"] = light3; - resources["SystemAccentColorDark1"] = dark1; - resources["SystemAccentColorDark2"] = dark2; - resources["SystemAccentColorDark3"] = dark3; + resources["SystemAccentColor"] = ColorValue(accentColor); + resources["SystemAccentColorLight1"] = ColorValue(light1); + resources["SystemAccentColorLight2"] = ColorValue(light2); + resources["SystemAccentColorLight3"] = ColorValue(light3); + resources["SystemAccentColorDark1"] = ColorValue(dark1); + resources["SystemAccentColorDark2"] = ColorValue(dark2); + resources["SystemAccentColorDark3"] = ColorValue(dark3); resources["AccentFillColorDefaultBrush"] = accentBrush; resources["AccentFillColorSecondaryBrush"] = light1Brush; @@ -153,11 +146,11 @@ public static void ApplyAccentColor(AppAccentColorMode mode, string colorValue) resources["AccentFillColorDisabledBrush"] = disabledAccentBrush; resources["AccentTextFillColorPrimaryBrush"] = whiteBrush; resources["AccentTextFillColorSecondaryBrush"] = whiteBrush; - resources["AccentTextFillColorTertiaryBrush"] = new SolidColorBrush(Color.FromArgb(0xCC, 0xFF, 0xFF, 0xFF)); - resources["AccentTextFillColorDisabledBrush"] = new SolidColorBrush(Color.FromArgb(0x5C, 0xFF, 0xFF, 0xFF)); + resources["AccentTextFillColorTertiaryBrush"] = Brush(Color.FromArgb(0xCC, 0xFF, 0xFF, 0xFF)); + resources["AccentTextFillColorDisabledBrush"] = Brush(Color.FromArgb(0x5C, 0xFF, 0xFF, 0xFF)); resources["AccentButtonBackground"] = accentBrush; resources["AccentButtonBackgroundPointerOver"] = light1Brush; - resources["AccentButtonBackgroundPressed"] = new SolidColorBrush(dark1); + resources["AccentButtonBackgroundPressed"] = Brush(dark1); resources["AccentButtonBackgroundDisabled"] = disabledAccentBrush; resources["AccentButtonBorderBrush"] = transparentBrush; resources["AccentButtonBorderBrushPointerOver"] = transparentBrush; @@ -166,7 +159,7 @@ public static void ApplyAccentColor(AppAccentColorMode mode, string colorValue) resources["AccentButtonForeground"] = whiteBrush; resources["AccentButtonForegroundPointerOver"] = whiteBrush; resources["AccentButtonForegroundPressed"] = whiteBrush; - resources["AccentButtonForegroundDisabled"] = new SolidColorBrush(Color.FromArgb(0x5C, 0xFF, 0xFF, 0xFF)); + resources["AccentButtonForegroundDisabled"] = Brush(Color.FromArgb(0x5C, 0xFF, 0xFF, 0xFF)); resources["SystemControlBackgroundAccentBrush"] = accentBrush; resources["SystemControlDisabledAccentBrush"] = disabledAccentBrush; resources["SystemControlForegroundAccentBrush"] = accentBrush; @@ -188,14 +181,19 @@ public static void ApplyAccentColor(AppAccentColorMode mode, string colorValue) resources["ToggleButtonForegroundCheckedPointerOver"] = whiteBrush; resources["ToggleButtonForegroundCheckedPressed"] = whiteBrush; resources["TextOnAccentFillColorPrimaryBrush"] = whiteBrush; + return resources; } /// Returns whether requested accent settings differ from the currently applied app resources. public static bool IsAccentColorRestartPending(AppAccentColorMode mode, string colorValue) { - return mode != _appliedAccentColorMode - || (mode == AppAccentColorMode.Custom - && !StringComparer.OrdinalIgnoreCase.Equals(NormalizeAccentColorValue(colorValue), _appliedAccentColorValue)); + try + { + AccentColorConfiguration installed = ReadAccentConfiguration(); + return mode != installed.Mode || (mode == AppAccentColorMode.Custom + && !StringComparer.OrdinalIgnoreCase.Equals(NormalizeAccentColorValue(colorValue), installed.ColorValue)); + } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { return true; } } /// Parses an accent color value, returning Windows blue when parsing fails. diff --git a/ClashSharp/ClashSharp/Service/WinUiAccentResourceStore.cs b/ClashSharp/ClashSharp/Service/WinUiAccentResourceStore.cs new file mode 100644 index 0000000..31b8943 --- /dev/null +++ b/ClashSharp/ClashSharp/Service/WinUiAccentResourceStore.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClashSharp.ApplicationModel.Settings; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace ClashSharp.Service; + +/// Reads and changes primary application accent overrides without confusing merged Windows resources with owned values. +internal sealed class WinUiAccentResourceStore : IAccentResourceStore +{ + public IReadOnlyDictionary CaptureLocalOverrides(IReadOnlyCollection ownedKeys) + { + ResourceDictionary resources = GetResources(); + HashSet keys = ownedKeys.ToHashSet(StringComparer.Ordinal); + Dictionary snapshot = new(StringComparer.Ordinal); + // Enumeration identifies local entries. Indexed lookup and ContainsKey may resolve merged + // resources, which must remain available when the application follows the system accent. + foreach (KeyValuePair entry in resources) + { + if (entry.Key is string key && keys.Contains(key)) + { + snapshot.Add(key, entry.Value switch + { + Color color => new(ToArgb(color), false), + SolidColorBrush brush when brush.Opacity == 1d => new(ToArgb(brush.Color), true), + _ => null, + }); + } + } + return snapshot; + } + + public void WriteOverride(string key, AccentResourceValue value) + { + ResourceDictionary resources = GetResources(); + Color color = Color.FromArgb((byte)(value.Argb >> 24), (byte)(value.Argb >> 16), (byte)(value.Argb >> 8), (byte)value.Argb); + resources[key] = value.IsBrush ? new SolidColorBrush(color) : color; + } + + public void RemoveOverride(string key) => GetResources().Remove(key); + + internal static uint ToArgb(Color color) => ((uint)color.A << 24) | ((uint)color.R << 16) | ((uint)color.G << 8) | color.B; + + private static ResourceDictionary GetResources() + { + ResourceDictionary resources = Application.Current?.Resources ?? throw new InvalidOperationException("The WinUI application resources are unavailable."); + if (!resources.DispatcherQueue.HasThreadAccess) { throw new InvalidOperationException("Accent resources require the owning UI thread."); } + return resources; + } +} diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index db72b5f..212612b 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -38,6 +38,16 @@ desired 发布与配置安装分开进行。会话持久保存 Running 后,参 退休阻止新的快照捕获、probe 和 apply,已捕获的历史值及持久数据保持。启动重新观察会识别新消费者的实际默认配置,再安装 durable desired;启动请求要求独占许可。集成测试使用真实 JSON 会话、facade、只读接口和代际管理器,验证代际切换后只解析新实例,原实例已退休,旧磁盘值没有被新实例覆盖。生产消费者的读取端口仍需在整体装配时接入该接口。 +## 生产强调色资源的独立验证 + +原强调色入口在访问 WinUI 资源之前就记录已应用配置,资源不可用或中途写入失败时仍可能向设置页报告成功。两项回归直接调用原主程序程序集,在没有 WinUI Application 的测试进程中复现了跟随系统、自定义颜色都被误报为已应用的问题。 + +`AccentColorRuntime` 先构造完整不可变资源表,再通过 `IAccentResourceStore` 写入并独立读取所有资源。只有键集合、颜色 ARGB 和资源类型全部匹配,才发布已验证配置。部分写入或无效资源不能证明成功;最后一次写入的回执丢失可由实际完整资源匹配解决。写入后探测暂时失败时,后续读取仍能独立验证已尝试的目标;致命异常图继续传播。 + +主程序 `AppThemeService` 已使用该路径,配置构造和调色计算没有平台访问。实际生产调色表通过程序集引用测试,覆盖 7 个颜色、41 个画刷、固定 ARGB 混色结果和透明度。透明画刷保持原有 `#00FFFFFF`,与 [Microsoft.UI.Colors.Transparent](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.colors.transparent?view=windows-app-sdk-1.8) 一致。 + +`WinUiAccentResourceStore` 限制在资源字典所属 UI 线程访问,枚举主字典的局部项,避免将合并字典中的系统资源误当成应用覆盖;跟随系统只移除本服务拥有的覆盖。画刷除颜色外还验证其自身不透明度为 1。资源字典的集合接口及线程关联见 [ResourceDictionary](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.resourcedictionary?view=windows-app-sdk-1.8)。设置页读取实际资源来判断是否尚待应用;主窗口启动步骤也验证目标强调色后才完成。这项修复已接入现有生产设置入口,完整 Appearance 代际参与者和其他外观消费者仍待整体装配。 + ## StartupTask 与 Sampling 的实际服务适配 `StartupTaskSettingsParticipant` 和 `SamplingSettingsParticipant` 在访问运行时之前检查完整 generation descriptor、应用类别、允许的键和原许可的有效性。两者都不写偏好、不重新申请普通许可。StartupTask 通过生产 `StartupLaunchService` 读取 Windows 注册状态;已满足目标时不重复注册,拒绝或未知状态保留待办。应用回执丢失由后续独立平台探测判断。 @@ -113,6 +123,10 @@ Triggers 适配和入口顺序修复新增 22 项回归,本分支累计净增 内部设置只读接口与实际配置所有者追加 10 项回归,本分支累计净增 170 项。完整主程序 2791 项全部通过,零失败、零跳过,用时 58 秒;18 项目 Release x64 构建零警告、零错误,用时 26.06 秒,format 检查 1495 个文件、零处变更。收据为 `local-validation-internal-settings.json`,最终报告为 `1.0.0-internal-settings-main.trx`、`build-internal-settings-complete.log` 和 `format-internal-settings-verified.log`。首次编译修正两处测试断言分析器用法;初轮测试的三个失败来自夹具对预建目录及 URL 规范化的错误预期,报告保留于 `1.0.0-internal-settings-components-final.trx`,不计作产品缺陷复现。 +内部设置提交 `04ded00` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34231353788),实际四份 TRX 共 4856 项通过、零失败、零跳过,10 项新增用例全部匹配本地身份。合并提交 `0edd44c` 与源提交 tree 同为 `9c766820a60a2aaf974b4dd03ffa4716344b2b8c`,收据为 `ci-validation-internal-settings.json`。开发安装器包构建成功,本次只核验制品元数据。 + +强调色验证修复追加 22 项回归,本分支累计净增 192 项。完整主程序 2813 项全部通过、零失败、零跳过,用时 57 秒;18 项目 Release x64 构建零警告、零错误,用时 29.06 秒,format 检查 1502 文件、零处变更。收据为 `local-validation-accent-runtime.json`,最终报告为 `1.0.0-accent-production-main.trx`、`build-accent-production-complete.log` 和 `format-accent-production-verified.log`。旧实现的两项失败保存在 `1.0.0-accent-unavailable-red.trx`,编译后已恢复并核对工作文件摘要;初次编译的命名及异常构造分析器诊断也保留原日志。资源存储的成功路径和故障注入使用隔离边界,真实 WinUI 字典及新启动检查需要同一 CI 候选的原生验收。 + ## 完整切换的剩余依赖 1. 将偏好写入统一为应用层异步 change set;页面、磁贴、触发器和网络提交者使用同一个接口。独立控制端凭据已接入生产调用,后续代际重置继续使用该能力。 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index 62dba4d..cae79a0 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -30,6 +30,8 @@ - Triggers 适配器拥有真实调度循环,与通知投递共用已安装配置;总开关和通知开关均独立验证,单键应用保留其他待办。修复 UI 设置入口与调度任务相互等待的路径,先排空普通许可再进入设置入口;禁用评估后继续重试释放确认。新增 22 项回归,完整主程序 2781 项通过,18 项目构建零警告、零错误,format 检查 1491 文件、零处变更;收据为 `local-validation-trigger-settings.json`。测试直接使用主程序程序集中的实际调度及通知适配器,外部端口为隔离模拟。 - 触发器提交 `9a331ba` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34229757710),实际四份 TRX 共 4846 项通过,22 项新增用例全部核对;源与合并提交 tree 一致,收据为 `ci-validation-trigger-settings.json`。安装器开发包构建成功,本次只核验制品元数据。 - 内部设置新增只读消费者接口和不可变运行快照,批次原子安装后独立观察,保留其他待办和历史快照。真实 JSON、facade 与代际管理器验证新实例读取及旧实例退休,追加 10 项回归;完整主程序 2791 项通过,18 项目构建零警告、零错误,format 检查 1495 文件、零处变更。本分支累计净增 170 项,收据为 `local-validation-internal-settings.json`。 +- 内部设置提交 `04ded00` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34231353788),实际四份 TRX 共 4856 项通过,10 项新增用例全部核对;源与合并提交 tree 一致,收据为 `ci-validation-internal-settings.json`。安装器开发包构建成功,本次只核验制品元数据。 +- 修复生产强调色在资源不可用或写入失败时仍报告已应用的问题,两项回归先在原主程序上复现。现在独立验证完整 48 项资源后才报告配置生效,主窗口启动也核对实际资源。追加 22 项回归,完整主程序 2813 项通过,18 项目构建零警告、零错误;本分支累计净增 192 项,收据为 `local-validation-accent-runtime.json`。完整 Appearance 代际参与者和新候选原生验收继续推进。 - 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。Appearance、Network 的适配及整体装配继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 - main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 From ff0e0659392c67734caa56f47a2077c2d948d28b Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 22:21:18 +0800 Subject: [PATCH 10/22] docs: record accent CI and packaged startup acceptance --- docs/design/2026-09-08-settings-generation-cutover.md | 6 +++++- docs/reviews/1.0.0-execution-ledger.md | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index 212612b..0e06a06 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -125,7 +125,11 @@ Triggers 适配和入口顺序修复新增 22 项回归,本分支累计净增 内部设置提交 `04ded00` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34231353788),实际四份 TRX 共 4856 项通过、零失败、零跳过,10 项新增用例全部匹配本地身份。合并提交 `0edd44c` 与源提交 tree 同为 `9c766820a60a2aaf974b4dd03ffa4716344b2b8c`,收据为 `ci-validation-internal-settings.json`。开发安装器包构建成功,本次只核验制品元数据。 -强调色验证修复追加 22 项回归,本分支累计净增 192 项。完整主程序 2813 项全部通过、零失败、零跳过,用时 57 秒;18 项目 Release x64 构建零警告、零错误,用时 29.06 秒,format 检查 1502 文件、零处变更。收据为 `local-validation-accent-runtime.json`,最终报告为 `1.0.0-accent-production-main.trx`、`build-accent-production-complete.log` 和 `format-accent-production-verified.log`。旧实现的两项失败保存在 `1.0.0-accent-unavailable-red.trx`,编译后已恢复并核对工作文件摘要;初次编译的命名及异常构造分析器诊断也保留原日志。资源存储的成功路径和故障注入使用隔离边界,真实 WinUI 字典及新启动检查需要同一 CI 候选的原生验收。 +强调色验证修复追加 22 项回归,本分支累计净增 192 项。完整主程序 2813 项全部通过、零失败、零跳过,用时 57 秒;18 项目 Release x64 构建零警告、零错误,用时 29.06 秒,format 检查 1502 文件、零处变更。收据为 `local-validation-accent-runtime.json`,最终报告为 `1.0.0-accent-production-main.trx`、`build-accent-production-complete.log` 和 `format-accent-production-verified.log`。旧实现的两项失败保存在 `1.0.0-accent-unavailable-red.trx`,编译后已恢复并核对工作文件摘要;初次编译的命名及异常构造分析器诊断也保留原日志。完整自定义资源表及故障注入使用隔离资源存储边界,默认跟随系统与新启动检查的原生证据如下。 + +强调色提交 `a4f6368` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34236028856),实际四份 TRX 共 4878 项通过、零失败、零跳过,22 项新增用例全部核对。合并提交 `6b0d9e3` 与源提交 tree 同为 `1115ea1ac545ee6197a6343cddf23867170418d3`,收据为 `ci-validation-accent-runtime.json`。已下载并验证安装器制品 `10060250945` 的全部 8 个文件;归档共 317688931 字节,SHA-256 为 `b816bde0ef20921ab50e1f89c73936eaab733a407a02375d5a70ae88af9fb193`,安装器版本为 `1.0.0+6b0d9e313394a363e52878bfc4832b17023c613f`。收据为 `installer-artifact-accent-runtime.json`。 + +同一源提交和验收脚本在 2026-09-08 14:18 UTC 完成实际 Windows Sandbox 运行 `9328bd3fefce42b7a3ed7b70dba422b8`。MSIX SHA-256 为 `20da82391439e06ce0877a5febdeb56c4e1af5be9f0d671afb725810e21bfb22`;12 步全部通过,实际包进程与 EXE 摘要匹配,主窗口稳定 30366 毫秒,凭据、主窗口及最后启动步骤各成功一次,启动失败记录为零。7 项来宾清理成功,沙箱 `4f30c13b-672d-4ac5-b113-fc825f429f49` 已销毁,输入和主机代理不变;收据为 `sandbox-package-validation-accent-runtime.json`。这验证了新资源检查下的默认启动,未执行自定义配色的实际页面操作、正常 WPF 安装器流程或优雅退出。 ## 完整切换的剩余依赖 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index cae79a0..0b8610c 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -31,7 +31,8 @@ - 触发器提交 `9a331ba` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34229757710),实际四份 TRX 共 4846 项通过,22 项新增用例全部核对;源与合并提交 tree 一致,收据为 `ci-validation-trigger-settings.json`。安装器开发包构建成功,本次只核验制品元数据。 - 内部设置新增只读消费者接口和不可变运行快照,批次原子安装后独立观察,保留其他待办和历史快照。真实 JSON、facade 与代际管理器验证新实例读取及旧实例退休,追加 10 项回归;完整主程序 2791 项通过,18 项目构建零警告、零错误,format 检查 1495 文件、零处变更。本分支累计净增 170 项,收据为 `local-validation-internal-settings.json`。 - 内部设置提交 `04ded00` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34231353788),实际四份 TRX 共 4856 项通过,10 项新增用例全部核对;源与合并提交 tree 一致,收据为 `ci-validation-internal-settings.json`。安装器开发包构建成功,本次只核验制品元数据。 -- 修复生产强调色在资源不可用或写入失败时仍报告已应用的问题,两项回归先在原主程序上复现。现在独立验证完整 48 项资源后才报告配置生效,主窗口启动也核对实际资源。追加 22 项回归,完整主程序 2813 项通过,18 项目构建零警告、零错误;本分支累计净增 192 项,收据为 `local-validation-accent-runtime.json`。完整 Appearance 代际参与者和新候选原生验收继续推进。 +- 修复生产强调色在资源不可用或写入失败时仍报告已应用的问题,两项回归先在原主程序上复现。现在独立验证完整 48 项资源后才报告配置生效,主窗口启动也核对实际资源。追加 22 项回归,完整主程序 2813 项通过,18 项目构建零警告、零错误,format 检查 1502 文件、零处变更;本分支累计净增 192 项,收据为 `local-validation-accent-runtime.json`。 +- 强调色提交 `a4f6368` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34236028856),实际四份 TRX 共 4878 项通过,22 项新增用例全部核对;源与合并提交 tree 一致。完整开发安装包的 8 个文件均验证,原生启动使用同一源提交及验收脚本,12 步通过、窗口稳定 30366 毫秒、启动失败为零。7 项来宾清理成功,沙箱已销毁,输入和主机代理保持;收据为 `ci-validation-accent-runtime.json`、`installer-artifact-accent-runtime.json` 和 `sandbox-package-validation-accent-runtime.json`。默认启动已验证,自定义配色实际页面交互、正常 WPF 安装器及完整代际切换仍分别验收。 - 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。Appearance、Network 的适配及整体装配继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 - main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 From ea940a140775636ac78952a01ef6d7d866c391b0 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 8 Sep 2026 22:51:50 +0800 Subject: [PATCH 11/22] feat: apply appearance settings through owned UI operations --- .../Presentation/OwnedUiDispatcher.cs | 125 +++++ .../Settings/AppearanceSettingsParticipant.cs | 175 ++++++ .../Settings/AppearanceSettingsSnapshot.cs | 34 ++ .../Settings/IAppearanceNativeSettings.cs | 46 ++ .../AppearanceSettingsParticipantTests.cs | 498 ++++++++++++++++++ .../Unit/Services/OwnedUiDispatcherTests.cs | 126 +++++ .../WinUiAppearanceUnavailableWindowTests.cs | 27 + .../Settings/WinUiAppearanceSettings.cs | 52 ++ .../2026-09-08-settings-generation-cutover.md | 18 +- docs/reviews/1.0.0-execution-ledger.md | 4 +- 10 files changed, 1102 insertions(+), 3 deletions(-) create mode 100644 ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsParticipant.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsSnapshot.cs create mode 100644 ClashSharp/ClashSharp.Application/Settings/IAppearanceNativeSettings.cs create mode 100644 ClashSharp/ClashSharp.Tests/Integration/AppearanceSettingsParticipantTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Services/OwnedUiDispatcherTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Services/WinUiAppearanceUnavailableWindowTests.cs create mode 100644 ClashSharp/ClashSharp/AppHost/Settings/WinUiAppearanceSettings.cs diff --git a/ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs b/ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs new file mode 100644 index 0000000..2010c12 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs @@ -0,0 +1,125 @@ +using ClashSharp.ApplicationModel.Diagnostics; + +namespace ClashSharp.ApplicationModel.Presentation; + +/// Owns queued synchronous UI operations until they finish or are revoked before execution. +/// The containing scope retires this owner before releasing its window and generation. +public sealed class OwnedUiDispatcher : IAsyncDisposable +{ + private readonly object _gate = new(); + private readonly Func _hasThreadAccess; + private readonly Func _tryEnqueue; + private readonly HashSet _operations = []; + private readonly CancellationTokenRegistration _windowLifetime; + private bool _closed; + private Task? _retirement; + + /// Creates a dispatcher boundary without accessing the platform or scheduling work. + /// Reports access to the owning UI thread. + /// Queues one callback; rejection never permits a fallback on another thread. + /// Revoked when the window can no longer execute queued work. + public OwnedUiDispatcher(Func hasThreadAccess, Func tryEnqueue, CancellationToken windowLifetime) + { + _hasThreadAccess = hasThreadAccess ?? throw new ArgumentNullException(nameof(hasThreadAccess)); + _tryEnqueue = tryEnqueue ?? throw new ArgumentNullException(nameof(tryEnqueue)); + _windowLifetime = windowLifetime.Register(static state => ((OwnedUiDispatcher)state!).Close(), this); + } + + /// Waits for the actual callback, including after cancellation or a lost enqueue reply. + /// The immutable result captured on the UI thread. + /// Synchronous work that must not launch unowned asynchronous operations. + /// Cancels before the callback starts effects. + /// The callback's result or original failure. + public Task InvokeAsync(Func action, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(action); + cancellationToken.ThrowIfCancellationRequested(); + QueuedOperation operation = new(this, action, cancellationToken); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_closed, this); + _operations.Add(operation); + } + try + { + if (_hasThreadAccess()) { operation.Run(); } + else if (!_tryEnqueue(operation.Run)) { operation.Reject(new InvalidOperationException("The UI dispatcher rejected the operation.")); } + } + catch (Exception exception) + { + // A throwing enqueue may already have handed the callback to the queue. Revoke + // only unstarted work; a callback already executing retains its completion owner. + operation.Reject(exception); + if (ExceptionGraphClassifier.IsProcessFatal(exception)) { throw; } + } + return operation.Result; + } + + /// Rejects new and unstarted work, then drains every callback that has begun execution. + public ValueTask DisposeAsync() + { + lock (_gate) { return new(_retirement ??= RetireAsync()); } + } + + private async Task RetireAsync() + { + Task[] pending = Close(); + await _windowLifetime.DisposeAsync().ConfigureAwait(false); + foreach (Task completion in pending) + { + try { await completion.ConfigureAwait(false); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { } + } + } + + private Task[] Close() + { + Operation[] operations; + lock (_gate) { _closed = true; operations = _operations.ToArray(); } + foreach (Operation operation in operations) { operation.Reject(new ObjectDisposedException(nameof(OwnedUiDispatcher))); } + return operations.Select(operation => operation.Completion).ToArray(); + } + + private void Complete(Operation operation) { lock (_gate) { _operations.Remove(operation); } } + + private abstract class Operation + { + public abstract Task Completion { get; } + public abstract void Reject(Exception exception); + } + + private sealed class QueuedOperation(OwnedUiDispatcher owner, Func action, CancellationToken cancellationToken) : Operation + { + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _state; + public Task Result => _completion.Task; + public override Task Completion => _completion.Task; + + public void Run() + { + if (Interlocked.CompareExchange(ref _state, 1, 0) != 0) { return; } + try + { + cancellationToken.ThrowIfCancellationRequested(); + _completion.TrySetResult(action()); + } + catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested && exception.CancellationToken == cancellationToken) + { + _completion.TrySetCanceled(cancellationToken); + } + catch (Exception exception) + { + _completion.TrySetException(exception); + if (ExceptionGraphClassifier.IsProcessFatal(exception)) { throw; } + } + finally { Volatile.Write(ref _state, 2); owner.Complete(this); } + } + + public override void Reject(Exception exception) + { + if (Interlocked.CompareExchange(ref _state, 2, 0) != 0) { return; } + _completion.TrySetException(exception); + owner.Complete(this); + } + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsParticipant.cs b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsParticipant.cs new file mode 100644 index 0000000..1539c07 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsParticipant.cs @@ -0,0 +1,175 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Presentation; +using ClashSharp.Model; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Applies complete appearance batches on the owning UI thread and independently observes their results. +public sealed class AppearanceSettingsParticipant : ISettingsApplicationParticipant, IAppearanceSettingsReader, IAsyncDisposable +{ + private static readonly HashSet NativeKeys = + [SettingsRegistry.Keys.DisplayLanguage, SettingsRegistry.Keys.AppThemeMode, + SettingsRegistry.Keys.AppAccentColorMode, SettingsRegistry.Keys.AppAccentColorValue]; + private readonly object _lifetimeGate = new(); + private readonly DataGenerationDescriptor _generation; + private readonly MutationAdmissionBarrier _admission; + private readonly OwnedUiDispatcher _dispatcher; + private readonly IAppearanceNativeSettings _native; + private readonly IReadOnlyDictionary _definitions; + private AppearanceSettingsSnapshot? _installed; + private int _retiring; + private Task? _retirement; + + /// Creates a pure configuration owner; the caller transfers this generation's dispatcher lifetime. + /// The exact generation that owns the installed policies. + /// Process-wide admission retained by the settings authority. + /// The same canonical registry used by the settings session. + /// A dedicated operation owner bound to the live window. + /// The actual UI configuration boundary. + public AppearanceSettingsParticipant(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, + SettingsRegistry registry, OwnedUiDispatcher dispatcher, IAppearanceNativeSettings native) + { + _generation = generation ?? throw new ArgumentNullException(nameof(generation)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _native = native ?? throw new ArgumentNullException(nameof(native)); + ArgumentNullException.ThrowIfNull(registry); + _definitions = registry.Definitions.Where(definition => definition.ApplicationKind == SettingApplicationKind.Appearance) + .ToDictionary(definition => definition.Key); + if (_definitions.Count <= NativeKeys.Count || NativeKeys.Any(key => !_definitions.TryGetValue(key, out SettingDefinition? definition) + || definition.ValueType != SettingsRegistry.Default.Get(key.Value).ValueType) + || _definitions.Values.Any(definition => definition.Authority != SettingAuthority.Internal)) + { + throw new ArgumentException("Appearance requires canonical UI definitions and application-owned consumer policies.", nameof(registry)); + } + _installed = new(_generation, _definitions.Where(pair => !NativeKeys.Contains(pair.Key)) + .Select(pair => KeyValuePair.Create(pair.Key, pair.Value.DefaultValue))); + } + + /// + public SettingApplicationKind ApplicationKind => SettingApplicationKind.Appearance; + /// + public AppearanceSettingsSnapshot CaptureSnapshot() => Volatile.Read(ref _installed) + ?? throw new ObjectDisposedException(nameof(AppearanceSettingsParticipant)); + + /// + public Task ProbeAsync(SettingsApplicationRequest request, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + Validate(request, admissionLease); + return _dispatcher.InvokeAsync(() => + { + Validate(request, admissionLease); + AppearanceNativeConfiguration native = _native.CaptureConfiguration(); + AppearanceSettingsSnapshot installed = CaptureSnapshot(); + return new SettingsApplicationObservation(_generation, request.Batch.BatchId, request.Batch.AttemptId, + request.Values.Keys.Select(key => new SettingValueChange(key, Read(key, native, installed)))); + }, cancellationToken); + } + + /// + public Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + Validate(request, admissionLease); + return _dispatcher.InvokeAsync(() => + { + Validate(request, admissionLease); + AppearanceNativeConfiguration before = _native.CaptureConfiguration(); + AppearanceSettingsSnapshot installed = CaptureSnapshot(); + T Target(SettingKey key) where T : notnull => request.Values.TryGetValue(key, out SettingValue? value) + ? value.Get() : Read(key, before, installed).Get(); + AppearanceNativeConfiguration target = new(Target(SettingsRegistry.Keys.DisplayLanguage), + Target(SettingsRegistry.Keys.AppThemeMode), + new(Target(SettingsRegistry.Keys.AppAccentColorMode), Target(SettingsRegistry.Keys.AppAccentColorValue))); + Dictionary policies = installed.Values.ToDictionary(); + foreach ((SettingKey key, SettingValue value) in request.Values.Where(pair => !NativeKeys.Contains(pair.Key))) { policies[key] = value; } + AppearanceSettingsSnapshot next = new(_generation, policies); + ApplyNative(before, target); + Volatile.Write(ref _installed, next); + return true; + }, cancellationToken); + } + + /// Retires queued work and drains started UI callbacks before withdrawing the consumer snapshot. + public ValueTask DisposeAsync() + { + lock (_lifetimeGate) { return new(_retirement ??= RetireAsync()); } + } + + private async Task RetireAsync() + { + Volatile.Write(ref _retiring, 1); + try { await _dispatcher.DisposeAsync().ConfigureAwait(false); } + finally { Volatile.Write(ref _installed, null); } + } + + private void ApplyNative(AppearanceNativeConfiguration before, AppearanceNativeConfiguration target) + { + try + { + ApplyNativeDifference(before, target); + if (_native.CaptureConfiguration() != target) { throw new InvalidOperationException("The requested appearance could not be verified."); } + } + catch (Exception failure) when (!ExceptionGraphClassifier.IsProcessFatal(failure)) + { + if (TryObserve(target)) { return; } + if (TryObserve(before)) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw(); } + List failures = [failure]; + // These effects only replace this application's UI configuration. Recover the + // observed baseline so an explicit retry can start from independently known state. + Restore(() => { if (before.Language != target.Language) { _native.ApplyLanguage(before.Language); } }, failures); + Restore(() => { if (before.Theme != target.Theme) { _native.ApplyTheme(before.Theme); } }, failures); + Restore(() => { if (before.Accent != target.Accent) { _native.ApplyAccent(before.Accent); } }, failures); + if (TryObserve(before)) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw(); } + failures.Add(new InvalidOperationException("The previous appearance could not be independently restored.")); + throw new AggregateException("Appearance application and recovery did not reach a verified configuration.", failures); + } + } + + private void ApplyNativeDifference(AppearanceNativeConfiguration before, AppearanceNativeConfiguration target) + { + if (before.Accent != target.Accent) { _native.ApplyAccent(target.Accent); } + if (before.Theme != target.Theme) { _native.ApplyTheme(target.Theme); } + if (before.Language != target.Language) { _native.ApplyLanguage(target.Language); } + } + + private bool TryObserve(AppearanceNativeConfiguration expected) + { + try { return _native.CaptureConfiguration() == expected; } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { return false; } + } + + private static void Restore(Action action, ICollection failures) + { + try { action(); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { failures.Add(exception); } + } + + private SettingValue Read(SettingKey key, AppearanceNativeConfiguration native, AppearanceSettingsSnapshot installed) + { + if (!NativeKeys.Contains(key)) { return installed.Values[key]; } + object value = key == SettingsRegistry.Keys.DisplayLanguage ? native.Language + : key == SettingsRegistry.Keys.AppThemeMode ? native.Theme + : key == SettingsRegistry.Keys.AppAccentColorMode ? native.Accent.Mode : native.Accent.ColorValue; + SettingNormalizationResult normalized = _definitions[key].NormalizeValue(value); + return normalized.IsSuccess ? normalized.Value! + : throw new InvalidOperationException("The UI returned a noncanonical appearance value."); + } + + private void Validate(SettingsApplicationRequest request, MutationAdmissionLease lease) + { + ArgumentNullException.ThrowIfNull(request); + _admission.EnsureActiveLease(lease); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _retiring) != 0, this); + if (request.Phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(lease); } + if (!_generation.IsSameGeneration(request.Generation) || request.Batch.ApplicationKind != ApplicationKind || request.Values.Count == 0 + || request.Values.Any(pair => !_definitions.TryGetValue(pair.Key, out SettingDefinition? definition) + || !pair.Value.Equals(definition.Normalize(pair.Value.CanonicalText).Value))) + { + throw new InvalidOperationException("The appearance attempt does not match its generation, participant or canonical values."); + } + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsSnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsSnapshot.cs new file mode 100644 index 0000000..933e84d --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsSnapshot.cs @@ -0,0 +1,34 @@ +using System.Collections.ObjectModel; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Reads the installed tray, regional and tile configuration without exposing preference mutation or platform access. +public interface IAppearanceSettingsReader +{ + /// Captures one immutable configuration from the owning generation. + AppearanceSettingsSnapshot CaptureSnapshot(); +} + +/// Contains the installed policies consumed by tray, regional display and tile layout services. +/// Native language, theme and accent observations belong to the UI adapter and are not cached here. +public sealed class AppearanceSettingsSnapshot +{ + internal AppearanceSettingsSnapshot(DataGenerationDescriptor generation, IEnumerable> values) + { + Generation = generation; + Values = new ReadOnlyDictionary(values.ToDictionary()); + } + + /// Gets the lifetime that owns these installed policies. + public DataGenerationDescriptor Generation { get; } + /// Gets the complete installed policy set, excluding desired intent and native UI values. + public IReadOnlyDictionary Values { get; } + + /// Reads a registry-typed immutable policy value. + /// The exact registry type. + /// A tray, regional or tile policy key. + /// The value installed when the snapshot was captured. + public T Get(SettingKey key) where T : notnull => Values[key].Get(); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/IAppearanceNativeSettings.cs b/ClashSharp/ClashSharp.Application/Settings/IAppearanceNativeSettings.cs new file mode 100644 index 0000000..92e7041 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/IAppearanceNativeSettings.cs @@ -0,0 +1,46 @@ +using ClashSharp.Model; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Reads and changes actual UI configuration on the owning window thread, without preference storage. +public interface IAppearanceNativeSettings +{ + /// Reads the actual selected language, window theme and independently verified accent resources. + AppearanceNativeConfiguration CaptureConfiguration(); + + /// Installs the requested language in the resource resolver. + /// Canonical selected language. + void ApplyLanguage(AppLanguage language); + + /// Installs the requested theme on the actual window root. + /// Canonical selected theme. + void ApplyTheme(AppThemeMode theme); + + /// Installs and verifies the complete application accent resource set. + /// The configured mode and retained custom color. + void ApplyAccent(AccentColorConfiguration accent); +} + +/// Contains UI configuration observed in one synchronous operation on the window thread. +public sealed record AppearanceNativeConfiguration +{ + /// Creates an immutable and canonical UI configuration. + /// Selected resource language. + /// Requested window theme. + /// Independently verified accent configuration. + public AppearanceNativeConfiguration(AppLanguage language, AppThemeMode theme, AccentColorConfiguration accent) + { + if (!Enum.IsDefined(language)) { throw new ArgumentOutOfRangeException(nameof(language)); } + if (!Enum.IsDefined(theme)) { throw new ArgumentOutOfRangeException(nameof(theme)); } + Language = language; + Theme = theme; + Accent = accent ?? throw new ArgumentNullException(nameof(accent)); + } + + /// Gets the selected resource language. + public AppLanguage Language { get; } + /// Gets the requested window theme. + public AppThemeMode Theme { get; } + /// Gets the configured accent whose resources were verified. + public AccentColorConfiguration Accent { get; } +} diff --git a/ClashSharp/ClashSharp.Tests/Integration/AppearanceSettingsParticipantTests.cs b/ClashSharp/ClashSharp.Tests/Integration/AppearanceSettingsParticipantTests.cs new file mode 100644 index 0000000..3293d15 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/AppearanceSettingsParticipantTests.cs @@ -0,0 +1,498 @@ +extern alias ClashSharpUi; + +using System.Collections.Concurrent; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Presentation; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Model; +using ClashSharp.Settings; +using ClashSharp.Tests.Unit.Settings; +using AppThemeService = ClashSharpUi::ClashSharp.Service.AppThemeService; + +namespace ClashSharp.Tests.Integration; + +/// Exercises real generation authority, installed appearance policies and the production accent palette with isolated UI ports. +public sealed class AppearanceSettingsParticipantTests +{ + [Fact] + public async Task Construction_ExposesImmutablePolicyDefaultsWithoutReadingTheWindowOrStorage() + { + await using DataGenerationTestDirectory directory = new(); + DataGenerationDescriptor generation = directory.CreateGeneration(1); + using NativeSurface native = new(); + string[] before = Directory.GetFileSystemEntries(directory.RootPath, "*", SearchOption.AllDirectories); + await using AppearanceSettingsParticipant participant = new(generation, new(), SettingsRegistry.Default, native.CreateDispatcher(), native); + IAppearanceSettingsReader reader = participant; + AppearanceSettingsSnapshot snapshot = reader.CaptureSnapshot(); + Assert.Same(generation, snapshot.Generation); + Assert.Equal(6, snapshot.Values.Count); + Assert.False(snapshot.Get(SettingsRegistry.Keys.TrayUseMonochromeInactiveIcon)); + Assert.Throws(() => snapshot.Get(SettingsRegistry.Keys.AppThemeMode)); + Assert.Throws(() => ((IDictionary)snapshot.Values).Clear()); + Assert.Equal(0, native.Reads); + Assert.Equal(0, native.Writes); + Assert.Equal(0, native.ResourceReads); + Assert.Equal(before, Directory.GetFileSystemEntries(directory.RootPath, "*", SearchOption.AllDirectories)); + } + + [Fact] + public async Task CompleteBatch_InstallsAllTenKeysAfterRunningIsDurableAndPreservesHistoricalPolicies() + { + await using Fixture fixture = await Fixture.CreateAsync(); + AppearanceSettingsSnapshot before = fixture.Current(); + SettingValueChange[] changes = + [Change("DisplayLanguage", "English"), Change("AppThemeMode", "Dark"), Change("AppAccentColorMode", "Custom"), + Change("AppAccentColorValue", "#804477AA"), Change("TrayUseMonochromeInactiveIcon", "true"), + Change("TrayVisibleFeatureIds", "settings,safe-exit"), Change("MainlandChinaFeatureMode", "Disabled"), + Change("MainlandChinaUrlBlockingEnabled", "true"), Change("MasterHeroStatusLayout", ReverseDefault("MasterHeroStatusLayout")), + Change("MasterInfoTileLayout", ReverseDefault("MasterInfoTileLayout"))]; + Assert.Equal(SettingsRegistry.Default.Definitions.Where(definition => definition.ApplicationKind == SettingApplicationKind.Appearance) + .Select(definition => definition.Key).OrderBy(key => key.Value), changes.Select(change => change.Key).OrderBy(key => key.Value)); + Assert.True((await fixture.ChangeAsync(changes)).IsSucceeded); + Assert.Same(before, fixture.Current()); + Assert.Equal(0, fixture.Native.Writes); + CapturingParticipant wrapper = new(fixture.Participant) + { + BeforeApply = async () => + { + Assert.Same(before, fixture.Current()); + Assert.Equal(SettingsApplicationBatchState.Running, + Assert.Single((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.PendingApplications).State); + }, + }; + SettingsAuthorityResult applied = await fixture.ApplyAsync(SettingsRegistry.Keys.AppThemeMode, wrapper); + Assert.True(applied.IsSucceeded, applied.Code); + Assert.Empty(applied.Envelope!.PendingApplications); + foreach (SettingValueChange change in changes) { Assert.Equal(change.Value, applied.Envelope.Applied[change.Key].Value); } + AppearanceNativeConfiguration actual = await fixture.ReadNativeAsync(); + Assert.Equal(AppLanguage.English, actual.Language); + Assert.Equal(AppThemeMode.Dark, actual.Theme); + Assert.Equal(new(AppAccentColorMode.Custom, "#804477AA"), actual.Accent); + Assert.Equal(48, fixture.Native.Resources.Values.Count); + Assert.Equal(0x804477AAu, fixture.Native.Resources.Values["SystemAccentColor"].Argb); + Assert.True(fixture.Current().Get(SettingsRegistry.Keys.TrayUseMonochromeInactiveIcon)); + Assert.False(before.Get(SettingsRegistry.Keys.TrayUseMonochromeInactiveIcon)); + Assert.NotSame(before, fixture.Current()); + } + + [Fact] + public async Task SingleAccentKey_UsesTheInstalledCompanionAndLeavesOtherDesiredIntentPending() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("AppAccentColorValue", "#804477AA"))).IsSucceeded); + SettingsAuthorityResult applied = await fixture.Authority.ApplyChangesAsync([Change("AppAccentColorMode", "Custom")], Guid.NewGuid(), CancellationToken.None); + Assert.True(applied.IsSucceeded, applied.Code); + Assert.Equal(new(AppAccentColorMode.Custom, "#FF0078D4"), (await fixture.ReadNativeAsync()).Accent); + Assert.Equal(SettingsRegistry.Keys.AppAccentColorValue, Assert.Single(Assert.Single(applied.Envelope!.PendingApplications).Entries).Key); + Assert.True((await fixture.ApplyAsync(SettingsRegistry.Keys.AppAccentColorValue)).IsSucceeded); + Assert.Equal(new(AppAccentColorMode.Custom, "#804477AA"), (await fixture.ReadNativeAsync()).Accent); + } + + [Fact] + public async Task PolicyOnlyBatch_PreservesUnappliedNativeIntentAndDoesNotWriteTheWindow() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("AppThemeMode", "Dark"))).IsSucceeded); + SettingsAuthorityResult applied = await fixture.Authority.ApplyChangesAsync([Change("MasterInfoTileLayout", ReverseDefault("MasterInfoTileLayout"))], Guid.NewGuid(), CancellationToken.None); + Assert.True(applied.IsSucceeded, applied.Code); + Assert.Equal(0, fixture.Native.Writes); + Assert.Equal(AppThemeMode.FollowSystem, (await fixture.ReadNativeAsync()).Theme); + Assert.Equal(ReverseDefault("MasterInfoTileLayout"), fixture.Current().Get(SettingsRegistry.Keys.MasterInfoTileLayout)); + Assert.Equal(SettingsRegistry.Keys.AppThemeMode, Assert.Single(Assert.Single(applied.Envelope!.PendingApplications).Entries).Key); + } + + [Fact] + public async Task MissingWindow_DoesNotPublishPoliciesAndCanBeRetriedWhenTheWindowReturns() + { + await using Fixture fixture = await Fixture.CreateAsync(); + AppearanceSettingsSnapshot before = fixture.Current(); + fixture.Native.Unavailable = true; + SettingsAuthorityResult failed = await fixture.Authority.ApplyChangesAsync([Change("TrayVisibleFeatureIds", "safe-exit")], Guid.NewGuid(), CancellationToken.None); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, failed.Status); + Assert.Same(before, fixture.Current()); + Assert.Equal(0, fixture.Native.Writes); + Assert.Equal(SettingAppliedStateKind.Unknown, failed.Envelope!.Applied[SettingsRegistry.Keys.TrayVisibleFeatureIds].Kind); + fixture.Native.Unavailable = false; + Assert.True((await fixture.RetryAsync(failed)).IsSucceeded); + Assert.Equal("safe-exit", fixture.Current().Get(SettingsRegistry.Keys.TrayVisibleFeatureIds)); + } + + [Theory] + [InlineData("partial-accent")] + [InlineData("theme-before-write")] + public async Task InterruptedNativeApplication_RestoresTheObservedBaselineBeforeExplicitRetry(string fault) + { + await using Fixture fixture = await Fixture.CreateAsync(); + AppearanceNativeConfiguration before = await fixture.ReadNativeAsync(); + AppearanceSettingsSnapshot policies = fixture.Current(); + if (fault == "partial-accent") { fixture.Native.Resources.FailAfterWrite = 2; } + else { fixture.Native.FailNextTheme = true; } + SettingsAuthorityResult failed = await fixture.Authority.ApplyChangesAsync( + [Change("AppAccentColorMode", "Custom"), Change("AppAccentColorValue", "#804477AA"), + Change("AppThemeMode", "Dark"), Change("TrayUseMonochromeInactiveIcon", "true")], Guid.NewGuid(), CancellationToken.None); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, failed.Status); + Assert.Equal(before, await fixture.ReadNativeAsync()); + Assert.Empty(fixture.Native.Resources.Values); + Assert.Same(policies, fixture.Current()); + Assert.True((await fixture.RetryAsync(failed)).IsSucceeded); + Assert.Equal(AppThemeMode.Dark, (await fixture.ReadNativeAsync()).Theme); + Assert.True(fixture.Current().Get(SettingsRegistry.Keys.TrayUseMonochromeInactiveIcon)); + } + + [Fact] + public async Task LostFinalNativeReply_IsAcceptedOnlyAfterIndependentTargetObservation() + { + await using Fixture fixture = await Fixture.CreateAsync(); + fixture.Native.LoseNextThemeReply = true; + SettingsAuthorityResult result = await fixture.Authority.ApplyChangesAsync( + [Change("AppThemeMode", "Dark"), Change("TrayUseMonochromeInactiveIcon", "true")], Guid.NewGuid(), CancellationToken.None); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal(AppThemeMode.Dark, (await fixture.ReadNativeAsync()).Theme); + Assert.True(fixture.Current().Get(SettingsRegistry.Keys.TrayUseMonochromeInactiveIcon)); + Assert.Equal(1, fixture.Native.Writes); + } + + [Fact] + public async Task LostParticipantReply_IsResolvedByTheAuthorityFromActualNativeAndPolicyState() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("AppThemeMode", "Dark"), Change("TrayUseMonochromeInactiveIcon", "true"))).IsSucceeded); + SettingsAuthorityResult result = await fixture.ApplyAsync(SettingsRegistry.Keys.AppThemeMode, new(fixture.Participant) { LoseReply = true }); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal("settings.application.reply_lost_resolved", result.Code); + Assert.True(fixture.Current().Get(SettingsRegistry.Keys.TrayUseMonochromeInactiveIcon)); + } + + [Fact] + public async Task IgnoredNativeWrites_CannotPublishPolicyChangesOrClearThePendingBatch() + { + await using Fixture fixture = await Fixture.CreateAsync(); + AppearanceSettingsSnapshot before = fixture.Current(); + fixture.Native.IgnoreThemeWrites = true; + SettingsAuthorityResult failed = await fixture.Authority.ApplyChangesAsync( + [Change("AppThemeMode", "Dark"), Change("TrayUseMonochromeInactiveIcon", "true")], Guid.NewGuid(), CancellationToken.None); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, failed.Status); + Assert.Same(before, fixture.Current()); + Assert.Equal(SettingsApplicationBatchState.Failed, Assert.Single(failed.Envelope!.PendingApplications).State); + } + + [Fact] + public async Task NestedFatalFailure_EscapesWithoutCompensationOrPolicyPublication() + { + await using Fixture fixture = await Fixture.CreateAsync(); + AppearanceSettingsSnapshot before = fixture.Current(); + Exception fatal = new AggregateException(new InvalidOperationException("Isolated nested fatal.", Activator.CreateInstance())); + fixture.Native.ThemeFailure = fatal; + Exception? observed = await Record.ExceptionAsync(() => fixture.Authority.ApplyChangesAsync( + [Change("AppAccentColorMode", "Custom"), Change("AppThemeMode", "Dark"), Change("TrayVisibleFeatureIds", "safe-exit")], Guid.NewGuid(), CancellationToken.None)); + Assert.Same(fatal, observed); + Assert.Same(before, fixture.Current()); + Assert.Equal(2, fixture.Native.Writes); + Assert.Equal(48, fixture.Native.Resources.Values.Count); + Assert.Equal(SettingsApplicationBatchState.Running, Assert.Single((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.PendingApplications).State); + } + + [Fact] + public async Task ForeignGenerationAndAdmission_AreRejectedBeforePlatformAccess() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("AppThemeMode", "Dark"))).IsSucceeded); + CapturingParticipant capture = new(fixture.Participant); + Assert.True((await fixture.ApplyAsync(SettingsRegistry.Keys.AppThemeMode, capture)).IsSucceeded); + await using AppearanceSettingsParticipant other = new(fixture.Directory.CreateGeneration(2), fixture.Admission, + SettingsRegistry.Default, fixture.Native.CreateDispatcher(), fixture.Native); + int reads = fixture.Native.Reads; + int writes = fixture.Native.Writes; + using MutationAdmissionLease own = fixture.Admission.AcquireOrdinary(); + await Assert.ThrowsAsync(() => other.ApplyAsync(capture.Request!, own, CancellationToken.None)); + using MutationAdmissionLease foreign = new MutationAdmissionBarrier().AcquireOrdinary(); + await Assert.ThrowsAsync(() => fixture.Participant.ProbeAsync(capture.Request!, foreign, CancellationToken.None)); + Assert.Equal(reads, fixture.Native.Reads); + Assert.Equal(writes, fixture.Native.Writes); + } + + [Fact] + public async Task GenerationReplacement_RetiresTheOldReaderAndUsesANewPolicyOwnerWithTheSameWindow() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.Authority.ApplyChangesAsync([Change("AppThemeMode", "Dark"), Change("TrayUseMonochromeInactiveIcon", "true")], Guid.NewGuid(), CancellationToken.None)).IsSucceeded); + AppearanceSettingsSnapshot historical = fixture.Current(); + DataGenerationTransition transition = await fixture.Generations.BeginDrainAsync(fixture.Generations.CurrentManifest.ContentHash, CancellationToken.None); + DataGenerationDescriptor generation = fixture.Directory.CreateGeneration(2); + Lifetime next = await Lifetime.CreateAsync(generation, fixture.Admission, fixture.Native); + transition.Stage(new(generation, next)); + await transition.PromoteManifestAsync(fixture.Directory.Store, CancellationToken.None); + transition.SwapToPromoted(); + await transition.CommitAsync(); + Assert.Throws(() => fixture.Participant.CaptureSnapshot()); + Assert.True(historical.Get(SettingsRegistry.Keys.TrayUseMonochromeInactiveIcon)); + Assert.False(fixture.Current().Get(SettingsRegistry.Keys.TrayUseMonochromeInactiveIcon)); + Assert.Equal(generation.GenerationId, fixture.Current().Generation.GenerationId); + Assert.Equal(AppThemeMode.Dark, (await next.Dispatcher.InvokeAsync(fixture.Native.CaptureConfiguration, CancellationToken.None)).Theme); + Assert.True((await fixture.Authority.ApplyChangesAsync([Change("TrayVisibleFeatureIds", "safe-exit")], Guid.NewGuid(), CancellationToken.None)).IsSucceeded); + Assert.Equal("safe-exit", fixture.Current().Get(SettingsRegistry.Keys.TrayVisibleFeatureIds)); + Assert.True((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.Desired[SettingsRegistry.Keys.TrayUseMonochromeInactiveIcon].Value.Get()); + } + + [Fact] + public async Task GenerationDrain_WaitsForTheQueuedUiCallbackAndFinalDurableApplicationDespitePageCancellation() + { + await using Fixture fixture = await Fixture.CreateAsync(); + using CancellationTokenSource page = new(); + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(15)); + fixture.Native.HoldQueue = true; + Task command = fixture.Authority.ApplyChangesAsync( + [Change("AppThemeMode", "Dark"), Change("TrayVisibleFeatureIds", "safe-exit")], Guid.NewGuid(), page.Token); + Task? drain = null; + try + { + await fixture.Native.Queued.Task.WaitAsync(deadline.Token); + Assert.Equal(SettingsApplicationBatchState.Running, Assert.Single((await fixture.Repository.OpenAsync(deadline.Token)).Envelope!.PendingApplications).State); + drain = fixture.Generations.BeginDrainAsync(fixture.Generations.CurrentManifest.ContentHash, deadline.Token).AsTask(); + page.Cancel(); + Assert.False(command.IsCompleted); + Assert.False(drain.IsCompleted); + } + finally + { + fixture.Native.HoldQueue = false; + fixture.Native.DrainQueue(); + await command.WaitAsync(deadline.Token); + } + Assert.True((await command).IsSucceeded); + DataGenerationTransition transition = await drain!; + Assert.Empty((await fixture.Repository.OpenAsync(deadline.Token)).Envelope!.PendingApplications); + Assert.Equal(AppThemeMode.Dark, (await fixture.ReadNativeAsync()).Theme); + await transition.AbortAsync(); + Assert.Equal("safe-exit", fixture.Current().Get(SettingsRegistry.Keys.TrayVisibleFeatureIds)); + } + + [Fact] + public async Task Startup_ReobservesTheNewWindowAndPolicyOwnerUnderExclusiveAdmission() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.Authority.ApplyChangesAsync( + [Change("AppThemeMode", "Dark"), Change("AppAccentColorMode", "Custom"), Change("TrayVisibleFeatureIds", "safe-exit")], Guid.NewGuid(), CancellationToken.None)).IsSucceeded); + await fixture.Participant.DisposeAsync(); + using NativeSurface newWindow = new(); + await using AppearanceSettingsParticipant reopened = new(fixture.Lifetime.Session.Generation, fixture.Admission, + SettingsRegistry.Default, newWindow.CreateDispatcher(), newWindow); + await using MutationAdmissionLease startup = await fixture.Admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, CancellationToken.None); + SettingsAuthorityResult prepared = await fixture.Lifetime.Session.PrepareStartupAdmittedAsync(Guid.NewGuid(), startup, CancellationToken.None); + SettingsApplicationBatch batch = Assert.Single(prepared.Envelope!.PendingApplications, item => item.ApplicationKind == SettingApplicationKind.Appearance); + CapturingParticipant capture = new(reopened); + SettingsAuthorityResult result = await fixture.Lifetime.Session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, capture, + SettingsApplicationPhase.Startup, startup, CancellationToken.None); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal("safe-exit", reopened.CaptureSnapshot().Get(SettingsRegistry.Keys.TrayVisibleFeatureIds)); + Assert.Equal(48, newWindow.Resources.Values.Count); + Assert.Equal(SettingAppliedValueSource.StartupReconciliation, result.Envelope!.Applied[SettingsRegistry.Keys.AppThemeMode].Source); + await startup.DisposeAsync(); + using MutationAdmissionLease ordinary = fixture.Admission.AcquireOrdinary(); + int reads = newWindow.Reads; + await Assert.ThrowsAsync(() => reopened.ApplyAsync(capture.Request!, ordinary, CancellationToken.None)); + Assert.Equal(reads, newWindow.Reads); + } + + [Fact] + public async Task UnverifiableCompensation_RemainsUnknownAndCannotAuthorizeBlindRetry() + { + await using Fixture fixture = await Fixture.CreateAsync(); + AppearanceSettingsSnapshot before = fixture.Current(); + fixture.Native.Resources.FailAfterWrite = 2; + fixture.Native.Resources.FailRemovals = true; + SettingsAuthorityResult failed = await fixture.Authority.ApplyChangesAsync( + [Change("AppAccentColorMode", "Custom"), Change("TrayUseMonochromeInactiveIcon", "true")], Guid.NewGuid(), CancellationToken.None); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, failed.Status); + Assert.Equal(SettingAppliedStateKind.Unknown, failed.Envelope!.Applied[SettingsRegistry.Keys.AppAccentColorMode].Kind); + Assert.Same(before, fixture.Current()); + int writes = fixture.Native.Writes; + SettingsAuthorityResult retry = await fixture.RetryAsync(failed); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, retry.Status); + Assert.Equal("settings.application.probe_failed", retry.Code); + Assert.Equal(writes, fixture.Native.Writes); + Assert.Same(before, fixture.Current()); + } + + private static SettingValueChange Change(string key, string value) => new(new(key), SettingsEnvelopeTestData.Value(key, value)); + private static string ReverseDefault(string key) => string.Join(',', SettingsRegistry.Default.Get(key).DefaultValue.Get().Split(',').Reverse()); + + private sealed class Fixture : IAsyncDisposable + { + public DataGenerationTestDirectory Directory { get; } = new(); + public MutationAdmissionBarrier Admission { get; } = new(); + public DataGenerationManager Generations { get; } = new(); + public NativeSurface Native { get; } = new(); + public Lifetime Lifetime { get; private set; } = null!; + public AppearanceSettingsParticipant Participant => Lifetime.Participant; + public JsonSettingsRepository Repository => Lifetime.Repository; + public GenerationSettingsAuthority Authority { get; private set; } = null!; + public static async Task CreateAsync() + { + Fixture fixture = new(); + try + { + DataGenerationManifestSnapshot manifest = await fixture.Directory.PromoteFirstAsync(); + fixture.Lifetime = await AppearanceSettingsParticipantTests.Lifetime.CreateAsync(manifest.Descriptor, fixture.Admission, fixture.Native); + fixture.Generations.Initialize(manifest, new(manifest.Descriptor, fixture.Lifetime)); + fixture.Authority = new(fixture.Generations, fixture.Admission); + return fixture; + } + catch { await fixture.DisposeAsync(); throw; } + } + public AppearanceSettingsSnapshot Current() => Generations.ReadSnapshot((reader, _) => reader.CaptureSnapshot()); + public Task ReadNativeAsync() => Lifetime.Dispatcher.InvokeAsync(Native.CaptureConfiguration, CancellationToken.None); + public async Task ChangeAsync(params SettingValueChange[] changes) + { + using MutationAdmissionLease lease = Admission.AcquireOrdinary(); + return await Lifetime.Session.ChangeAdmittedAsync(changes, Guid.NewGuid(), lease, CancellationToken.None); + } + public async Task ApplyAsync(SettingKey key, CapturingParticipant? wrapper = null) + { + using MutationAdmissionLease lease = Admission.AcquireOrdinary(); + SettingsApplicationBatch batch = Assert.Single((await Repository.OpenAsync(CancellationToken.None)).Envelope!.PendingApplications, + item => item.Entries.Any(entry => entry.Key == key)); + return await Lifetime.Session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, (ISettingsApplicationParticipant?)wrapper ?? Participant, + SettingsApplicationPhase.Live, lease, CancellationToken.None); + } + public Task RetryAsync(SettingsAuthorityResult failed) + { + SettingsApplicationBatch batch = Assert.Single(failed.Envelope!.PendingApplications); + return Authority.RetryAsync(batch.BatchId, batch.AttemptId, Guid.NewGuid(), CancellationToken.None); + } + public async ValueTask DisposeAsync() + { + await Generations.DisposeAsync(); + if (Lifetime is not null) { await Lifetime.DisposeAsync(); } + Native.Dispose(); + await Directory.DisposeAsync(); + } + } + + private sealed class Lifetime : IServiceProvider, IAsyncDisposable + { + private readonly SettingsGenerationContext _context; + private Lifetime(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, NativeSurface native) + { + Repository = new(generation, SettingsRegistry.Default); + Session = new(Repository, SettingsRegistry.Default, admission); + Dispatcher = native.CreateDispatcher(); + Participant = new(generation, admission, SettingsRegistry.Default, Dispatcher, native); + _context = new(Session, [Participant]); + } + public JsonSettingsRepository Repository { get; } + public SettingsAuthoritySession Session { get; } + public OwnedUiDispatcher Dispatcher { get; } + public AppearanceSettingsParticipant Participant { get; } + public static async Task CreateAsync(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, NativeSurface native) + { + Lifetime lifetime = new(generation, admission, native); + try + { + Assert.True((await lifetime.Repository.SaveAsync(SettingsEnvelopeTestData.CreateMatchingEnvelope(), 0, CancellationToken.None)).IsSucceeded); + return lifetime; + } + catch { await lifetime.DisposeAsync(); throw; } + } + public object? GetService(Type serviceType) => serviceType == typeof(IAppearanceSettingsReader) ? Participant + : serviceType == typeof(SettingsGenerationContext) ? _context : null; + public async ValueTask DisposeAsync() { await Session.DisposeAsync(); await Participant.DisposeAsync(); } + } + + private sealed class CapturingParticipant(AppearanceSettingsParticipant inner) : ISettingsApplicationParticipant + { + public SettingApplicationKind ApplicationKind => SettingApplicationKind.Appearance; + public SettingsApplicationRequest? Request { get; private set; } + public Func? BeforeApply { get; init; } + public bool LoseReply { get; init; } + public Task ProbeAsync(SettingsApplicationRequest request, MutationAdmissionLease lease, CancellationToken cancellationToken) + { Request = request; return inner.ProbeAsync(request, lease, cancellationToken); } + public async Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease lease, CancellationToken cancellationToken) + { + if (BeforeApply is not null) { await BeforeApply(); } + await inner.ApplyAsync(request, lease, cancellationToken); + if (LoseReply) { throw new IOException("Isolated lost participant reply."); } + } + } + + private sealed class NativeSurface : IAppearanceNativeSettings, IDisposable + { + private readonly AsyncLocal _insideUi = new(); + private readonly CancellationTokenSource _window = new(); + private readonly ConcurrentQueue _queued = new(); + private readonly AccentColorRuntime _accent; + private AppLanguage _language = AppLanguage.AutoDetect; + private AppThemeMode _theme = AppThemeMode.FollowSystem; + public NativeSurface() { Resources = new(() => Assert.True(_insideUi.Value)); _accent = AppThemeService.CreateAccentRuntime(Resources); } + public Resources Resources { get; } + public int Reads { get; private set; } + public int Writes { get; private set; } + public int ResourceReads => Resources.Reads; + public bool Unavailable { get; set; } + public bool FailNextTheme { get; set; } + public bool LoseNextThemeReply { get; set; } + public bool IgnoreThemeWrites { get; set; } + public Exception? ThemeFailure { get; set; } + public bool HoldQueue { get; set; } + public TaskCompletionSource Queued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public OwnedUiDispatcher CreateDispatcher() => new(() => _insideUi.Value, action => + { + if (HoldQueue) { _queued.Enqueue(action); Queued.TrySetResult(); return true; } + RunOnUi(action); + return true; + }, _window.Token); + public void DrainQueue() { while (_queued.TryDequeue(out Action? action)) { RunOnUi(action); } } + private void RunOnUi(Action action) + { + bool previous = _insideUi.Value; + _insideUi.Value = true; + try { action(); } + finally { _insideUi.Value = previous; } + } + public AppearanceNativeConfiguration CaptureConfiguration() + { + Assert.True(_insideUi.Value); + ++Reads; + if (Unavailable) { throw new IOException("Isolated unavailable window."); } + return new(_language, _theme, _accent.CaptureConfiguration()); + } + public void ApplyLanguage(AppLanguage language) { Assert.True(_insideUi.Value); ++Writes; _language = language; } + public void ApplyAccent(AccentColorConfiguration accent) { Assert.True(_insideUi.Value); ++Writes; _accent.Apply(accent); } + public void ApplyTheme(AppThemeMode theme) + { + Assert.True(_insideUi.Value); + ++Writes; + if (ThemeFailure is not null) { throw ThemeFailure; } + if (FailNextTheme) { FailNextTheme = false; throw new IOException("Isolated theme write failure."); } + if (!IgnoreThemeWrites) { _theme = theme; } + if (LoseNextThemeReply) { LoseNextThemeReply = false; throw new IOException("Isolated lost native reply."); } + } + public void Dispose() => _window.Dispose(); + } + + private sealed class Resources(Action assertUi) : IAccentResourceStore + { + public Dictionary Values { get; } = new(StringComparer.Ordinal); + public int Reads { get; private set; } + public int FailAfterWrite { get; set; } + public bool FailRemovals { get; set; } + public IReadOnlyDictionary CaptureLocalOverrides(IReadOnlyCollection ownedKeys) + { + assertUi(); ++Reads; + return Values.Where(pair => ownedKeys.Contains(pair.Key, StringComparer.Ordinal)).ToDictionary(pair => pair.Key, pair => (AccentResourceValue?)pair.Value, StringComparer.Ordinal); + } + public void WriteOverride(string key, AccentResourceValue value) + { + assertUi(); Values[key] = value; + if (FailAfterWrite > 0 && --FailAfterWrite == 0) { throw new IOException("Isolated partial palette write."); } + } + public void RemoveOverride(string key) + { + assertUi(); + if (FailRemovals) { throw new IOException("Isolated unavailable palette removal."); } + Values.Remove(key); + } + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/OwnedUiDispatcherTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/OwnedUiDispatcherTests.cs new file mode 100644 index 0000000..62b6e2e --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/OwnedUiDispatcherTests.cs @@ -0,0 +1,126 @@ +using ClashSharp.ApplicationModel.Presentation; + +namespace ClashSharp.Tests.Unit.Services; + +public sealed class OwnedUiDispatcherTests +{ + [Fact] + public async Task ConstructionIsPure_AndOwningThreadRunsWithoutEnqueue() + { + int threadReads = 0; + await using OwnedUiDispatcher dispatcher = new(() => { ++threadReads; return true; }, _ => throw new IOException(), CancellationToken.None); + Assert.Equal(0, threadReads); + int thread = Environment.CurrentManagedThreadId; + Assert.Equal(thread, await dispatcher.InvokeAsync(() => Environment.CurrentManagedThreadId, CancellationToken.None)); + Assert.Equal(1, threadReads); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RejectedOrThrowingEnqueue_CannotRunAnUnstartedCallbackLater(bool throws) + { + Action? queued = null; + IOException failure = new("Isolated queue reply failure."); + await using OwnedUiDispatcher dispatcher = new(() => false, action => + { + queued = action; + return throws ? throw failure : false; + }, CancellationToken.None); + int effects = 0; + Exception? observed = await Record.ExceptionAsync(() => dispatcher.InvokeAsync(() => ++effects, CancellationToken.None)); + if (throws) { Assert.Same(failure, observed); } + else { Assert.IsType(observed); } + Assert.NotNull(queued); + queued(); + Assert.Equal(0, effects); + } + + [Fact] + public async Task ExecutedCallback_ResolvesAnEnqueueReplyLostAfterCompletion() + { + await using OwnedUiDispatcher dispatcher = new(() => false, action => { action(); throw new IOException("Lost queue reply."); }, CancellationToken.None); + int effects = 0; + Assert.Equal(1, await dispatcher.InvokeAsync(() => ++effects, CancellationToken.None)); + Assert.Equal(1, effects); + } + + [Fact] + public async Task CancelledQueuedOperation_IsObservedAtTheCallbackWithoutStartingEffects() + { + using CancellationTokenSource cancellation = new(); + Action? queued = null; + await using OwnedUiDispatcher dispatcher = new(() => false, action => { queued = action; return true; }, CancellationToken.None); + int effects = 0; + Task operation = dispatcher.InvokeAsync(() => ++effects, cancellation.Token); + cancellation.Cancel(); + Assert.False(operation.IsCompleted); + Assert.NotNull(queued); + queued(); + await Assert.ThrowsAnyAsync(() => operation); + Assert.Equal(0, effects); + } + + [Fact] + public async Task WindowShutdown_RevokesUnstartedCallbacksAndRejectsFutureOperations() + { + using CancellationTokenSource window = new(); + Action? queued = null; + await using OwnedUiDispatcher dispatcher = new(() => false, action => { queued = action; return true; }, window.Token); + int effects = 0; + Task operation = dispatcher.InvokeAsync(() => ++effects, CancellationToken.None); + window.Cancel(); + await Assert.ThrowsAsync(() => operation); + await Assert.ThrowsAsync(() => dispatcher.InvokeAsync(() => ++effects, CancellationToken.None)); + Assert.NotNull(queued); + queued(); + Assert.Equal(0, effects); + } + + [Fact] + public async Task StartedCallback_RetainsCompletionAcrossCancellationAndRetirement() + { + using CancellationTokenSource cancellation = new(); + using ManualResetEventSlim started = new(); + using ManualResetEventSlim release = new(); + Action? queued = null; + await using OwnedUiDispatcher dispatcher = new(() => false, action => { queued = action; return true; }, CancellationToken.None); + Task operation = dispatcher.InvokeAsync(() => + { + started.Set(); + if (!release.Wait(TimeSpan.FromSeconds(5))) { throw new TimeoutException("Test callback release was not observed."); } + return 42; + }, cancellation.Token); + Assert.NotNull(queued); + Task callback = Task.Run(queued); + try + { + Assert.True(started.Wait(TimeSpan.FromSeconds(5))); + cancellation.Cancel(); + Task retirement = dispatcher.DisposeAsync().AsTask(); + Assert.False(operation.IsCompleted); + Assert.False(retirement.IsCompleted); + release.Set(); + Assert.Equal(42, await operation.WaitAsync(TimeSpan.FromSeconds(5))); + await retirement.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally { release.Set(); await callback.WaitAsync(TimeSpan.FromSeconds(5)); } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CallbackFailure_PreservesOriginalExceptionAndFatalDispatcherPropagation(bool fatal) + { + Action? queued = null; + await using OwnedUiDispatcher dispatcher = new(() => false, action => { queued = action; return true; }, CancellationToken.None); + Exception failure = fatal ? new AggregateException(new InvalidOperationException("Nested fatal.", Activator.CreateInstance())) + : new IOException("Isolated callback failure."); + Task operation = dispatcher.InvokeAsync(() => throw failure, CancellationToken.None); + Assert.NotNull(queued); + Exception? dispatchFailure = Record.Exception(queued); + if (fatal) { Assert.Same(failure, dispatchFailure); } + else { Assert.Null(dispatchFailure); } + Assert.Same(failure, await Record.ExceptionAsync(() => operation)); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/WinUiAppearanceUnavailableWindowTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/WinUiAppearanceUnavailableWindowTests.cs new file mode 100644 index 0000000..7c3c830 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/WinUiAppearanceUnavailableWindowTests.cs @@ -0,0 +1,27 @@ +extern alias ClashSharpUi; + +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Model; +using LocalizationService = ClashSharpUi::ClashSharp.Service.LocalizationService; +using WinUiAppearanceSettings = ClashSharpUi::ClashSharp.Hosting.Settings.WinUiAppearanceSettings; + +namespace ClashSharp.Tests.Unit.Services; + +public sealed class WinUiAppearanceUnavailableWindowTests +{ + [Fact] + public void MissingWindow_RejectsEveryActualNativeOperationBeforeAnyUiSettingChanges() + { + int rootReads = 0; + LocalizationService localization = LocalizationService.Instance; + AppLanguage before = localization.CurrentLanguage; + IAppearanceNativeSettings native = new WinUiAppearanceSettings(() => { ++rootReads; return null; }, localization); + Assert.Equal(0, rootReads); + Assert.Throws(() => native.CaptureConfiguration()); + Assert.Throws(() => native.ApplyLanguage(AppLanguage.English)); + Assert.Throws(() => native.ApplyTheme(AppThemeMode.Dark)); + Assert.Throws(() => native.ApplyAccent(new(AppAccentColorMode.Custom, "#804477AA"))); + Assert.Equal(before, localization.CurrentLanguage); + Assert.Equal(4, rootReads); + } +} diff --git a/ClashSharp/ClashSharp/AppHost/Settings/WinUiAppearanceSettings.cs b/ClashSharp/ClashSharp/AppHost/Settings/WinUiAppearanceSettings.cs new file mode 100644 index 0000000..6bb5cae --- /dev/null +++ b/ClashSharp/ClashSharp/AppHost/Settings/WinUiAppearanceSettings.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; +using ClashSharp.ApplicationModel.Presentation; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Model; +using ClashSharp.Service; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; + +namespace ClashSharp.Hosting.Settings; + +/// Adapts the actual window, localization resolver and accent resources without preference access. +internal sealed class WinUiAppearanceSettings(Func getRoot, LocalizationService localization) : IAppearanceNativeSettings +{ + private readonly Func _getRoot = getRoot ?? throw new ArgumentNullException(nameof(getRoot)); + private readonly LocalizationService _localization = localization ?? throw new ArgumentNullException(nameof(localization)); + + /// Creates a generation-owned operation boundary whose lifetime ends before the window's queue closes. + internal static OwnedUiDispatcher CreateDispatcher(DispatcherQueue queue, CancellationToken windowLifetime) + { + ArgumentNullException.ThrowIfNull(queue); + return new(() => queue.HasThreadAccess, operation => queue.TryEnqueue(() => operation()), windowLifetime); + } + + public AppearanceNativeConfiguration CaptureConfiguration() + { + FrameworkElement root = RequireRoot(); + AppThemeMode theme = root.RequestedTheme switch + { + ElementTheme.Default => AppThemeMode.FollowSystem, + ElementTheme.Light => AppThemeMode.Light, + ElementTheme.Dark => AppThemeMode.Dark, + _ => throw new InvalidOperationException("The window requested an unsupported theme."), + }; + return new(_localization.CurrentLanguage, theme, AppThemeService.ReadAccentConfiguration()); + } + + public void ApplyLanguage(AppLanguage language) { _ = RequireRoot(); _localization.CurrentLanguage = language; } + public void ApplyTheme(AppThemeMode theme) => AppThemeService.Apply(RequireRoot(), theme); + public void ApplyAccent(AccentColorConfiguration accent) + { + _ = RequireRoot(); + AppThemeService.ApplyAccentColor(accent.Mode, accent.ColorValue); + } + + private FrameworkElement RequireRoot() + { + FrameworkElement root = _getRoot() ?? throw new InvalidOperationException("The appearance window is unavailable."); + if (!root.DispatcherQueue.HasThreadAccess) { throw new InvalidOperationException("Appearance requires the owning window thread."); } + return root; + } +} diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index 0e06a06..77f0310 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -1,6 +1,6 @@ # Settings generation cutover -版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口、内部设置运行快照,以及 StartupTask、Sampling、Triggers 的实际服务适配器。控制端凭据已从偏好中拆分并接入生产启动、运行时和数据清理;生产偏好仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 +版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口、内部设置运行快照,以及 Appearance、StartupTask、Sampling、Triggers 的服务适配器。控制端凭据已从偏好中拆分并接入生产启动、运行时和数据清理;生产偏好仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 ## 已实现的存储与迁移 @@ -48,6 +48,16 @@ desired 发布与配置安装分开进行。会话持久保存 Running 后,参 `WinUiAccentResourceStore` 限制在资源字典所属 UI 线程访问,枚举主字典的局部项,避免将合并字典中的系统资源误当成应用覆盖;跟随系统只移除本服务拥有的覆盖。画刷除颜色外还验证其自身不透明度为 1。资源字典的集合接口及线程关联见 [ResourceDictionary](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.resourcedictionary?view=windows-app-sdk-1.8)。设置页读取实际资源来判断是否尚待应用;主窗口启动步骤也验证目标强调色后才完成。这项修复已接入现有生产设置入口,完整 Appearance 代际参与者和其他外观消费者仍待整体装配。 +## Appearance 的完整批次与 UI 操作寿命 + +`AppearanceSettingsParticipant` 覆盖 registry 中全部十项外观配置。语言、窗口主题和强调色的模式/颜色从 `IAppearanceNativeSettings` 独立观察;托盘、地区显示和磁贴的六项配置由同一代际拥有,通过 `IAppearanceSettingsReader` 暴露不可变已安装快照。构造不访问窗口、资源或偏好存储,快照也不缓存原生 UI 状态。保存 desired 不会改变已安装配置;单键应用从实际已安装值取得配套值,保留其他键的独立待办。 + +`WinUiAppearanceSettings` 使用注入的实际窗口根元素、语言资源解析器及生产强调色服务,拒绝窗口缺席和错误线程访问。`OwnedUiDispatcher` 将整个同步 UI 回调纳入可等待的操作寿命;排队拒绝不授权其他线程执行,丢失 enqueue 回执时仅撤销未开始的回调。开始后的回调继续完成;窗口寿命结束或代际退休会撤销未开始的工作,并等待已开始的回调结束。主机装配必须将每代独立的操作所有者绑定窗口寿命,并在窗口队列关闭前退休。 + +原生效果执行后再次读取完整 UI 配置,再原子发布六项消费者策略。最后回执丢失但实际目标完整匹配时可完成发布;部分失败则尝试恢复之前独立观察到的应用内外观,恢复确认后保留失败待办,允许显式重试。恢复也无法验证时保持 unknown,后续探测失败不会盲目重新应用;致命异常图不进入补偿。此恢复能力只替换应用内 UI 配置,不取得偏好写入、网络或系统资源权限。 + +回归使用真实临时 JSON、facade、代际管理器、生产主程序的完整强调色表及隔离 UI 边界。覆盖全部十键、配套值与其他待办隔离、部分资源写入、失去回执、不可验证补偿、重试、启动重新观察和同一窗口下的代际替换。排队回调测试确认 Running 已持久保存后页面取消仍等待 UI 回调及最后保存,代际切换同时等待这条完整命令。主程序原生适配器也在无窗口进程中直接验证拒绝行为。这些适配器尚未注册进生产代际装配;托盘、磁贴等消费者的读取端口与刷新、正常窗口的实际交互仍在整体切换时接入和验收。 + ## StartupTask 与 Sampling 的实际服务适配 `StartupTaskSettingsParticipant` 和 `SamplingSettingsParticipant` 在访问运行时之前检查完整 generation descriptor、应用类别、允许的键和原许可的有效性。两者都不写偏好、不重新申请普通许可。StartupTask 通过生产 `StartupLaunchService` 读取 Windows 注册状态;已满足目标时不重复注册,拒绝或未知状态保留待办。应用回执丢失由后续独立平台探测判断。 @@ -131,10 +141,14 @@ Triggers 适配和入口顺序修复新增 22 项回归,本分支累计净增 同一源提交和验收脚本在 2026-09-08 14:18 UTC 完成实际 Windows Sandbox 运行 `9328bd3fefce42b7a3ed7b70dba422b8`。MSIX SHA-256 为 `20da82391439e06ce0877a5febdeb56c4e1af5be9f0d671afb725810e21bfb22`;12 步全部通过,实际包进程与 EXE 摘要匹配,主窗口稳定 30366 毫秒,凭据、主窗口及最后启动步骤各成功一次,启动失败记录为零。7 项来宾清理成功,沙箱 `4f30c13b-672d-4ac5-b113-fc825f429f49` 已销毁,输入和主机代理不变;收据为 `sandbox-package-validation-accent-runtime.json`。这验证了新资源检查下的默认启动,未执行自定义配色的实际页面操作、正常 WPF 安装器流程或优雅退出。 +验收记录提交 `ff0e065` 的[两项 CI 均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34237732342),实际四份 TRX 共 4878 项通过、零失败、零跳过,22 项强调色用例全部核对。合并提交 `35a11c6` 与源提交 tree 同为 `5fcf949bc9d2d71ef07cf2af64f312c31f4ae91a`,收据为 `ci-validation-accent-evidence.json`。该提交只有文档变化,安装器仅核验构建及元数据;原生证据继续绑定上述 `a4f6368` 候选。 + +Appearance 参与者及 UI 操作所有者新增 26 项回归,本分支累计净增 218 项。完整主程序 2839 项全部通过、零失败、零跳过,用时 59 秒;18 项目 Release x64 构建零警告、零错误,用时 27.66 秒,format 检查 1510 文件、零处变更。收据为 `local-validation-appearance-runtime.json`,报告为 `1.0.0-appearance-runtime-main.trx`、`build-appearance-runtime-complete.log` 和 `format-appearance-runtime-verified.log`。初次定向编译修正了一处异步异常断言的分析器用法,原日志 `test-appearance-dispatcher-components.log` 保留;此前两个定向集合分别通过 22 和 26 项。该节点没有激活新的生产设置权威,完整装配后的 UI 与安装器候选仍需原生验收。 + ## 完整切换的剩余依赖 1. 将偏好写入统一为应用层异步 change set;页面、磁贴、触发器和网络提交者使用同一个接口。独立控制端凭据已接入生产调用,后续代际重置继续使用该能力。 -2. 完成 Appearance、Network 的实际 apply/probe 适配器,并将已实现的 Internal、StartupTask、Sampling、Triggers 一起装配;明确读取 desired、有效状态和待办的消费者。 +2. 完成 Network 的实际 apply/probe 适配器,并将已实现的 Appearance、Internal、StartupTask、Sampling、Triggers 一起装配;明确读取 desired、有效状态和待办的消费者,并接通外观变化后的页面刷新。 3. 在设置驱动的启动步骤之前完成旧事务恢复、代际打开和偏好迁移。profile/log/trigger 与 settings 必须由同一代际容器解析、排空和替换。 4. 将导入、重置和回滚接入候选代际及 manifest 提交,完成生产消费者替换后,原子替换 `SettingsAuthorityArchitectureTests` 中的临时门禁。 5. 运行新候选的 CI、打包应用及隔离 Windows 验收,再将完整节点推送 main。 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index 0b8610c..8bc4373 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -33,7 +33,9 @@ - 内部设置提交 `04ded00` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34231353788),实际四份 TRX 共 4856 项通过,10 项新增用例全部核对;源与合并提交 tree 一致,收据为 `ci-validation-internal-settings.json`。安装器开发包构建成功,本次只核验制品元数据。 - 修复生产强调色在资源不可用或写入失败时仍报告已应用的问题,两项回归先在原主程序上复现。现在独立验证完整 48 项资源后才报告配置生效,主窗口启动也核对实际资源。追加 22 项回归,完整主程序 2813 项通过,18 项目构建零警告、零错误,format 检查 1502 文件、零处变更;本分支累计净增 192 项,收据为 `local-validation-accent-runtime.json`。 - 强调色提交 `a4f6368` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34236028856),实际四份 TRX 共 4878 项通过,22 项新增用例全部核对;源与合并提交 tree 一致。完整开发安装包的 8 个文件均验证,原生启动使用同一源提交及验收脚本,12 步通过、窗口稳定 30366 毫秒、启动失败为零。7 项来宾清理成功,沙箱已销毁,输入和主机代理保持;收据为 `ci-validation-accent-runtime.json`、`installer-artifact-accent-runtime.json` 和 `sandbox-package-validation-accent-runtime.json`。默认启动已验证,自定义配色实际页面交互、正常 WPF 安装器及完整代际切换仍分别验收。 -- 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。Appearance、Network 的适配及整体装配继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 +- 验收记录提交 `ff0e065` 的[两项 CI 成功](https://github.com/Water-Run/ClashSharp/actions/runs/34237732342),实际四份 TRX 共 4878 项通过、零失败、零跳过;收据为 `ci-validation-accent-evidence.json`。该提交仅修改文档,原生证据仍绑定上一候选。 +- Appearance 参与者覆盖语言、主题、强调色、托盘、地区显示及磁贴十项配置;原生资源独立观察,六项消费者配置原子安装并提供只读快照。代际拥有可排空的 UI 调度入口;排队拒绝、部分写入、补偿失败、回执丢失、显式重试、启动及同窗口代际切换均有回归。新增 26 项,完整主程序 2839 项通过,18 项目构建零警告、零错误,format 检查 1510 文件、零处变更;本分支累计净增 218 项,收据为 `local-validation-appearance-runtime.json`。平台成功路径使用隔离 UI 边界,未装配到生产设置入口。 +- 生产消费者和 profile/log/trigger 生命周期尚未切换,临时单一设置权威门禁保留。Network 适配、外观消费者读取与刷新及整体装配继续在开发分支完成;实现、验证边界和剩余依赖见[设置代际切换](../design/2026-09-08-settings-generation-cutover.md)。 - main 的证据提交 `e3f597c` 两项 CI 均成功;实际四份 TRX 共 4686 项通过,零失败、零跳过,收据为 `ci-validation-m3m-docs.json`。本次同步确认 origin/main 仍为该提交。 ## M3m 连接采样设置统一事务(2026-09-08) From cb378e57e7db75a5a66b9eb986e8ba7156956802 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Sat, 12 Sep 2026 17:50:35 +0800 Subject: [PATCH 12/22] fix(installer): support server desktops and keep cancellation responsive --- .../Platform/InstallerPlatformFacts.cs | 4 +- .../Platform/InstallerPlatformPolicy.cs | 16 ++- .../InstallerExecutableContractTests.cs | 14 ++- .../InstallerShellViewModelTests.cs | 115 ++++++++++++++++++ .../Presentation/InstallerShellViewModel.cs | 44 ++++++- .../Runtime/ProductionInstallerRuntime.cs | 32 ++--- .../InstallerPlatformPolicyTests.cs | 51 +++++++- .../WindowsInstallerEnvironmentTests.cs | 31 +++++ .../WindowsInstallerParentEngineTests.cs | 85 +++++++++++++ .../WindowsInstallerPlatformProbeTests.cs | 57 +++++++++ .../Execution/WindowsInstallerParentEngine.cs | 7 +- .../Platform/WindowsInstallerPlatformProbe.cs | 52 ++++++-- .../ClashSharp.Installer/MainWindow.xaml | 2 +- .../MigrationPreviewInstallerRuntime.cs | 8 +- ClashSharp/Installer/PackagingContract.psm1 | 35 ++++++ ClashSharp/Installer/README.md | 12 +- .../Installer/Test-InstallerBuildProfiles.ps1 | 38 +++++- ClashSharp/Installer/build.ps1 | 13 +- 18 files changed, 556 insertions(+), 60 deletions(-) create mode 100644 ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerPlatformProbeTests.cs diff --git a/ClashSharp/ClashSharp.Installer.Core/Platform/InstallerPlatformFacts.cs b/ClashSharp/ClashSharp.Installer.Core/Platform/InstallerPlatformFacts.cs index a11836e..ef8d836 100644 --- a/ClashSharp/ClashSharp.Installer.Core/Platform/InstallerPlatformFacts.cs +++ b/ClashSharp/ClashSharp.Installer.Core/Platform/InstallerPlatformFacts.cs @@ -6,9 +6,11 @@ namespace ClashSharp.Installer.Platform; /// Native Windows build number, independent of compatibility shims. /// Native operating-system architecture. /// Architecture of the running installer process. +/// Whether a native server product has the full desktop installation type. public sealed record InstallerPlatformFacts( bool IsWindows, bool IsWorkstation, int BuildNumber, InstallerCpuArchitecture OperatingSystemArchitecture, - InstallerCpuArchitecture ProcessArchitecture); + InstallerCpuArchitecture ProcessArchitecture, + bool IsServerDesktopExperience = false); diff --git a/ClashSharp/ClashSharp.Installer.Core/Platform/InstallerPlatformPolicy.cs b/ClashSharp/ClashSharp.Installer.Core/Platform/InstallerPlatformPolicy.cs index 7d40798..7256734 100644 --- a/ClashSharp/ClashSharp.Installer.Core/Platform/InstallerPlatformPolicy.cs +++ b/ClashSharp/ClashSharp.Installer.Core/Platform/InstallerPlatformPolicy.cs @@ -1,11 +1,14 @@ namespace ClashSharp.Installer.Platform; -/// Authorizes only native x64 Windows 11 client environments. +/// Authorizes native x64 Windows 11 and Windows Server 2025 desktop environments. public static class InstallerPlatformPolicy { /// The first Windows 11 build accepted by the installer. public const int MinimumWindowsBuild = 22000; + /// The first Windows Server 2025 build accepted with Desktop Experience. + public const int MinimumWindowsServerBuild = 26100; + /// Evaluates native facts in a deterministic fail-closed order. /// Facts captured by the Windows platform adapter. /// A stable support decision and diagnostic code. @@ -18,16 +21,21 @@ public static InstallerPlatformAssessment Evaluate(InstallerPlatformFacts facts) return Blocked("installer.environment.windows_required"); } - if (!facts.IsWorkstation) + if (!facts.IsWorkstation && !facts.IsServerDesktopExperience) { - return Blocked("installer.environment.windows_client_required"); + return Blocked("installer.environment.desktop_experience_required"); } - if (facts.BuildNumber < MinimumWindowsBuild) + if (facts.IsWorkstation && facts.BuildNumber < MinimumWindowsBuild) { return Blocked("installer.environment.windows_11_required"); } + if (!facts.IsWorkstation && facts.BuildNumber < MinimumWindowsServerBuild) + { + return Blocked("installer.environment.windows_server_2025_required"); + } + if (facts.OperatingSystemArchitecture != InstallerCpuArchitecture.X64) { return Blocked("installer.environment.x64_os_required"); diff --git a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs index fde8819..a6217bd 100644 --- a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs +++ b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs @@ -318,12 +318,20 @@ private static string NormalizeGeometry(string geometry) => private static string SourcePath(params string[] parts) { DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "ClashSharp.slnx"))) + while (directory is not null) { + if (File.Exists(Path.Combine(directory.FullName, "ClashSharp.slnx"))) + { + return Path.Combine([directory.FullName, .. parts]); + } + string sourceRoot = Path.Combine(directory.FullName, "ClashSharp"); + if (File.Exists(Path.Combine(sourceRoot, "ClashSharp.slnx"))) + { + return Path.Combine([sourceRoot, .. parts]); + } directory = directory.Parent; } - Assert.NotNull(directory); - return Path.Combine([directory.FullName, .. parts]); + throw new InvalidOperationException("Cannot locate Installer source contracts from the test output."); } } diff --git a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerShellViewModelTests.cs b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerShellViewModelTests.cs index efb312c..1d127e7 100644 --- a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerShellViewModelTests.cs +++ b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerShellViewModelTests.cs @@ -435,6 +435,121 @@ public async Task WindowShutdownCancellationUsesTheSameActiveGeneration() Assert.Equal("installer.cancelled", viewModel.DiagnosticCode); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CancellationImmediatelyReportsDrainAndWindowDisposesOnlyAfterBackendExit(bool inspection) + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var drain = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int cancellationRequests = 0; + var runtime = new ScriptedInstallerRuntime(); + async Task DrainAsync(CancellationToken token) + { + using CancellationTokenRegistration registration = token.Register(() => cancellationRequests++); + entered.SetResult(); + await drain.Task; + token.ThrowIfCancellationRequested(); + } + if (inspection) + { + runtime.Inspect = async token => + { + await DrainAsync(token); + return InstallerPresentationTestData.Readiness(); + }; + } + else + { + runtime.Execute = async (_, _, token) => + { + await DrainAsync(token); + return InstallerPresentationTestData.Result(); + }; + } + using var viewModel = new InstallerShellViewModel(runtime); + if (!inspection) { await viewModel.InitializeAsync(); } + Task active = inspection ? viewModel.InitializeAsync() : viewModel.PrimaryActionCommand.ExecuteAsync(); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + // This is the Window's close contract: request cancellation, dispose after IsBusy clears. + viewModel.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(viewModel.IsBusy) && !viewModel.IsBusy) { viewModel.Dispose(); } + }; + viewModel.RequestCancellation(); + Assert.True(viewModel.IsCancellationRequested); + Assert.True(viewModel.IsBusy); + Assert.False(active.IsCompleted); + Assert.Equal("正在取消操作", viewModel.StatusTitle); + Assert.Equal("正在等待操作收尾…", viewModel.ProgressStatus); + Assert.False(viewModel.CancelCommand.CanExecute(null)); + Assert.False(viewModel.PrimaryActionCommand.CanExecute(null)); + Assert.Equal(0, runtime.DisposeCount); + viewModel.RequestCancellation(); + viewModel.CancelCommand.Execute(null); + Assert.Equal(1, cancellationRequests); + } + finally + { + drain.TrySetResult(); + await active.WaitAsync(TimeSpan.FromSeconds(5)); + } + Assert.False(viewModel.IsBusy); + Assert.Equal("installer.cancelled", viewModel.DiagnosticCode); + Assert.Equal(1, runtime.DisposeCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DisposeDuringAcceptedOperationDefersRuntimeDisposalUntilDrain(bool inspection) + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var drain = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var runtime = new ScriptedInstallerRuntime(); + async Task DrainAsync(CancellationToken token) + { + entered.SetResult(); + await drain.Task; + token.ThrowIfCancellationRequested(); + } + if (inspection) + { + runtime.Inspect = async token => + { + await DrainAsync(token); + return InstallerPresentationTestData.Readiness(); + }; + } + else + { + runtime.Execute = async (_, _, token) => + { + await DrainAsync(token); + return InstallerPresentationTestData.Result(); + }; + } + using var viewModel = new InstallerShellViewModel(runtime); + if (!inspection) { await viewModel.InitializeAsync(); } + Task active = inspection ? viewModel.InitializeAsync() : viewModel.PrimaryActionCommand.ExecuteAsync(); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + viewModel.Dispose(); + viewModel.Dispose(); + Assert.False(active.IsCompleted); + Assert.Equal(0, runtime.DisposeCount); + } + finally + { + drain.TrySetResult(); + await active.WaitAsync(TimeSpan.FromSeconds(5)); + } + Assert.Equal(1, runtime.DisposeCount); + } + [Fact] public async Task ConcurrentRefreshIsIgnoredByTheSingleFlightGate() { diff --git a/ClashSharp/ClashSharp.Installer.Presentation/Presentation/InstallerShellViewModel.cs b/ClashSharp/ClashSharp.Installer.Presentation/Presentation/InstallerShellViewModel.cs index b3ac30e..885ab77 100644 --- a/ClashSharp/ClashSharp.Installer.Presentation/Presentation/InstallerShellViewModel.cs +++ b/ClashSharp/ClashSharp.Installer.Presentation/Presentation/InstallerShellViewModel.cs @@ -30,7 +30,9 @@ public sealed partial class InstallerShellViewModel : INotifyPropertyChanged, ID private CancellationTokenSource? _activeCancellation; private long _generation; private bool _disposed; + private bool _runtimeDisposePending; private bool _isBusy; + private bool _isCancellationRequested; private bool _canExecuteMutations; private bool _isProgressIndeterminate; private int _progressValue; @@ -67,7 +69,7 @@ public InstallerShellViewModel(IInstallerRuntime runtime) ExecuteSecondaryOperationAsync, () => !IsBusy && CanExecuteMutations && HasSecondaryAction, SetUnhandledCommandFailure); - CancelCommand = new DelegateCommand(CancelActiveOperation, () => IsBusy); + CancelCommand = new DelegateCommand(CancelActiveOperation, () => IsBusy && !IsCancellationRequested); OwnerTransferCommand = new AsyncDelegateCommand( () => ExecuteOperationAsync(null, ownerTransfer: true), () => IsOwnerTransferActionVisible, SetUnhandledCommandFailure); ConfirmOwnerTransferCommand = new DelegateCommand( @@ -154,6 +156,19 @@ private set /// Gets whether the single active generation exposes cancellation as its only action. public bool IsCancelActionVisible => IsBusy && !IsOwnerTransferConfirmationVisible && !IsRetiredUninstallConfirmationVisible; + /// Gets whether this generation is waiting for a requested cancellation to finish. + public bool IsCancellationRequested + { + get => _isCancellationRequested; + private set + { + if (SetProperty(ref _isCancellationRequested, value)) + { + CancelCommand.NotifyCanExecuteChanged(); + } + } + } + /// Gets whether the trusted runtime proved every mutation prerequisite. public bool CanExecuteMutations { @@ -271,10 +286,21 @@ public void RequestCancellation() CancellationTokenSource? cancellation; lock (_operationSync) { + if (_disposed || _activeCancellation is null || IsCancellationRequested) + { + return; + } + cancellation = _activeCancellation; + IsCancellationRequested = true; } - cancellation?.Cancel(); + StatusBadge = "正在取消"; + StatusTitle = "正在取消操作"; + StatusDetail = "已请求取消,正在等待当前操作安全结束。"; + ProgressStatus = "正在等待操作收尾…"; + IsProgressIndeterminate = true; + cancellation.Cancel(); } /// @@ -292,7 +318,8 @@ public void Dispose() _disposed = true; _generation++; cancellation = _activeCancellation; - runtimeLifetime = _runtime as IDisposable; + _runtimeDisposePending = cancellation is not null; + runtimeLifetime = _runtimeDisposePending ? null : _runtime as IDisposable; } cancellation?.Cancel(); @@ -307,6 +334,7 @@ private async Task RefreshAsync() return; } + IsCancellationRequested = false; IsBusy = true; IsProgressIndeterminate = true; StatusTitle = "正在检查安装状态"; @@ -390,6 +418,7 @@ private async Task ExecuteOperationAsync(InstallerOperation? requestedOperation, return; } + IsCancellationRequested = false; IsBusy = true; InvalidateReadiness(); IsProgressIndeterminate = ownerTransfer || retiredUninstall; @@ -408,7 +437,7 @@ private async Task ExecuteOperationAsync(InstallerOperation? requestedOperation, { var progress = new Progress(value => { - if (Volatile.Read(ref acceptProgress) == 0 || !IsCurrent(generation)) + if (Volatile.Read(ref acceptProgress) == 0 || !IsCurrent(generation) || IsCancellationRequested) { return; } @@ -542,16 +571,23 @@ private bool IsCurrent(OperationGeneration operation) private void CompleteOperation(OperationGeneration operation) { bool wasCurrent; + IDisposable? runtimeLifetime = null; lock (_operationSync) { wasCurrent = ReferenceEquals(_activeCancellation, operation.Cancellation); if (wasCurrent) { _activeCancellation = null; + if (_runtimeDisposePending) + { + _runtimeDisposePending = false; + runtimeLifetime = _runtime as IDisposable; + } } } operation.Cancellation.Dispose(); + runtimeLifetime?.Dispose(); if (wasCurrent && !_disposed) { IsBusy = false; diff --git a/ClashSharp/ClashSharp.Installer.Presentation/Runtime/ProductionInstallerRuntime.cs b/ClashSharp/ClashSharp.Installer.Presentation/Runtime/ProductionInstallerRuntime.cs index 580056a..429aba6 100644 --- a/ClashSharp/ClashSharp.Installer.Presentation/Runtime/ProductionInstallerRuntime.cs +++ b/ClashSharp/ClashSharp.Installer.Presentation/Runtime/ProductionInstallerRuntime.cs @@ -157,7 +157,7 @@ private static (string Title, string Detail) Describe( { return ( "当前系统不支持此操作", - "安装与修复仅支持 Windows 11 或更高版本的原生 x64 客户端系统;未执行任何系统更改。"); + "安装与修复需要 Windows 11+ 或 Windows Server 2025+ 桌面体验,且系统与进程均为 x64;不支持 Server Core。"); } if (!inspection.Environment.IsSupported && removalPathAvailable) @@ -189,34 +189,34 @@ private static IReadOnlyList BuildCapabilities( bool removalPathAvailable) => [ new( - "Windows 11+ x64 / 安全卸载", + "系统要求", inspection.Environment.IsSupported - ? "已确认 Windows 11+ 客户端、原生 x64 系统与 x64 安装器进程。" + ? "当前 Windows 桌面环境和 x64 系统符合安装要求。" : removalPathAvailable - ? "平台不满足安装要求;仅保留不依赖安装目标版本的安全卸载路径。" - : "安装与修复需要 Windows 11+ 原生 x64 客户端。", + ? "当前系统不符合安装要求,仍可卸载已安装的 ClashSharp。" + : "安装与修复需要 Windows 11+ 或 Windows Server 2025+ 桌面体验,且系统与进程均为 x64。", platformAllowsVisibleAction), new( - "签名安装器与内嵌清单", - "可信 backend 已验证当前 Installer 映像,并绑定严格内嵌发布身份。", + "安装器签名", + "安装器签名已通过验证,版本信息与此安装包一致。", true), new( - "当前用户包与进程", + "安装状态", inspection.Environment.InstalledPackageVersion is null - ? "未发现目标包注册,也未把无关同名进程视为产品实例。" + ? "当前账户尚未安装 ClashSharp。" : inspection.Environment.IsApplicationRunning - ? "目标包身份已确认,但应用仍在运行。" - : "目标包身份已确认,未发现其应用进程。", + ? "ClashSharp 正在运行,请关闭应用后继续。" + : "当前账户已安装 ClashSharp,应用已关闭。", !inspection.Environment.IsApplicationRunning), new( - "受保护恢复状态", + "未完成的操作", inspection.DurableTransaction is null - ? "未发现待恢复事务。" - : "已读取并绑定同一用户、同一发布的待恢复事务。", + ? "没有需要继续的安装或维护操作。" + : "发现上次未完成的操作,可使用此安装包继续。", true), new( - "认证提权事务", - "执行时由同一签名 Installer 的 PID 绑定 Helper 与受保护日志完成最终验证。", + "所需权限", + "配置系统组件时需要管理员权限;若 Windows 显示提示,请确认后继续。", true), ]; diff --git a/ClashSharp/ClashSharp.Installer.Tests/InstallerPlatformPolicyTests.cs b/ClashSharp/ClashSharp.Installer.Tests/InstallerPlatformPolicyTests.cs index 2745712..0ce44e1 100644 --- a/ClashSharp/ClashSharp.Installer.Tests/InstallerPlatformPolicyTests.cs +++ b/ClashSharp/ClashSharp.Installer.Tests/InstallerPlatformPolicyTests.cs @@ -46,14 +46,61 @@ public void NonWindowsKernelIsRejectedBeforeOtherFactsAreConsidered() } [Fact] - public void WindowsServerProductTypeIsNotTreatedAsWindowsElevenClient() + public void WindowsServerWithoutDesktopExperienceIsRejected() { InstallerPlatformAssessment result = InstallerPlatformPolicy.Evaluate(Facts( 26100, isWorkstation: false)); Assert.False(result.IsSupported); - Assert.Equal("installer.environment.windows_client_required", result.DiagnosticCode); + Assert.Equal("installer.environment.desktop_experience_required", result.DiagnosticCode); + } + + [Theory] + [InlineData(26100)] + [InlineData(int.MaxValue)] + public void Server2025DesktopExperiencePassesThePlatformGate(int buildNumber) + { + InstallerPlatformAssessment result = InstallerPlatformPolicy.Evaluate(Facts( + buildNumber, isWorkstation: false) with + { IsServerDesktopExperience = true }); + + Assert.True(result.IsSupported); + Assert.Equal("installer.environment.supported", result.DiagnosticCode); + } + + [Theory] + [InlineData(0)] + [InlineData(17763)] + [InlineData(20348)] + [InlineData(26099)] + public void OlderServerDesktopBuildsRemainUnsupported(int buildNumber) + { + InstallerPlatformAssessment result = InstallerPlatformPolicy.Evaluate(Facts( + buildNumber, isWorkstation: false) with + { IsServerDesktopExperience = true }); + + Assert.False(result.IsSupported); + Assert.Equal("installer.environment.windows_server_2025_required", result.DiagnosticCode); + } + + [Theory] + [InlineData(InstallerCpuArchitecture.Arm64, InstallerCpuArchitecture.X64, "installer.environment.x64_os_required")] + [InlineData(InstallerCpuArchitecture.X86, InstallerCpuArchitecture.X86, "installer.environment.x64_os_required")] + [InlineData(InstallerCpuArchitecture.X64, InstallerCpuArchitecture.X86, "installer.environment.x64_process_required")] + public void ServerDesktopStillRequiresNativeX64( + InstallerCpuArchitecture operatingSystemArchitecture, + InstallerCpuArchitecture processArchitecture, + string diagnosticCode) + { + InstallerPlatformAssessment result = InstallerPlatformPolicy.Evaluate(Facts( + 26100, isWorkstation: false, + operatingSystemArchitecture: operatingSystemArchitecture, + processArchitecture: processArchitecture) with + { IsServerDesktopExperience = true }); + + Assert.False(result.IsSupported); + Assert.Equal(diagnosticCode, result.DiagnosticCode); } [Theory] diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerEnvironmentTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerEnvironmentTests.cs index 75d75f4..8abee79 100644 --- a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerEnvironmentTests.cs +++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerEnvironmentTests.cs @@ -60,6 +60,37 @@ public async Task MissingPackageSkipsProcessEnumeration() Assert.Equal(0, processInspector.CallCount); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ServerDesktopPolicyReachesProductionPackageInspection(bool hasDesktopExperience) + { + using var fixture = Fixture(); + var packageManager = new RecordingPackageManager + { + Registrations = [Registration(fixture, isHealthy: true)], + }; + var environment = Create( + fixture, + packageManager, + new RecordingProcessInspector(isRunning: false), + SupportedFacts() with + { + IsWorkstation = false, + BuildNumber = 26100, + IsServerDesktopExperience = hasDesktopExperience, + }, + TargetSid); + + InstallerEnvironmentSnapshot snapshot = await environment.InspectAsync( + fixture.Request(targetSid: TargetSid), CancellationToken.None); + + Assert.Equal(hasDesktopExperience, snapshot.IsSupported); + Assert.Equal(hasDesktopExperience ? null : "installer.environment.desktop_experience_required", snapshot.BlockingDiagnosticCode); + Assert.Equal(fixture.Manifest.ExpectedPackageVersion, snapshot.InstalledPackageVersion); + Assert.Equal(string.Empty, packageManager.UserSecurityId); + } + [Fact] public async Task UnsupportedWindowsStillReportsPackageStateForSafeRemovalDecisions() { diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerParentEngineTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerParentEngineTests.cs index 64f7bde..c4743dd 100644 --- a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerParentEngineTests.cs +++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerParentEngineTests.cs @@ -249,6 +249,62 @@ await Assert.ThrowsAnyAsync(() => await execution; } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BlockingInspectionReturnsToCallerAndRetainsLifetimeUntilWorkerDrains(bool disposeWhileActive) + { + using var fixture = Fixture(); + using var cancellation = new CancellationTokenSource(); + using var release = new ManualResetEventSlim(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var returned = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + var inspector = new BlockingInspector(entered, release); + var factory = new RecordingSessionFactory(static () => + new RecordingSession(static (_, _, _) => Task.FromResult(Success()))); + using WindowsInstallerParentEngine engine = WindowsInstallerParentEngine.CreateForTesting( + fixture.Manifest, TargetSid, factory, new RecordingApplicationLock(), inspector); + int callerThread = 0; + Task caller = Task.Factory.StartNew(() => + { + callerThread = Environment.CurrentManagedThreadId; + returned.SetResult(engine.InspectAsync(cancellation.Token)); + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + Task? inspection = null; + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + inspection = await returned.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.NotEqual(callerThread, inspector.WorkerThread); + Assert.False(inspection.IsCompleted); + cancellation.Cancel(); + if (disposeWhileActive) + { + engine.Dispose(); + await Assert.ThrowsAsync(() => engine.InspectAsync(CancellationToken.None)); + } + else + { + InstallerExecutionResult concurrent = await engine.ExecuteAsync( + InstallerOperation.Install, null, CancellationToken.None); + Assert.Equal("installer.concurrent_action_rejected", concurrent.DiagnosticCode); + } + Assert.False(inspection.IsCompleted); + Assert.Equal(0, factory.CreateCount); + } + finally + { + cancellation.Cancel(); + release.Set(); + await caller.WaitAsync(TimeSpan.FromSeconds(5)); + inspection ??= await returned.Task; + await Assert.ThrowsAnyAsync(() => + inspection.WaitAsync(TimeSpan.FromSeconds(5))); + } + Assert.True(inspector.Drained); + } + [Fact] public async Task InspectionRejectsMissingOrDifferentReleaseResult() { @@ -479,6 +535,35 @@ public void Dispose() } } + private sealed class BlockingInspector( + TaskCompletionSource entered, + ManualResetEventSlim release) : IWindowsInstallerParentInspector + { + internal int WorkerThread { get; private set; } + + internal bool Drained { get; private set; } + + public Task InspectAsync(InstallerRequest request, CancellationToken cancellationToken) + { + request.Validate(); + WorkerThread = Environment.CurrentManagedThreadId; + entered.SetResult(); + try + { + if (!release.Wait(TimeSpan.FromSeconds(15), CancellationToken.None)) + { + throw new TimeoutException("Blocking inspection fixture was not released."); + } + cancellationToken.ThrowIfCancellationRequested(); + throw new InvalidOperationException("Fixture must be cancelled before release."); + } + finally + { + Drained = true; + } + } + } + private sealed class RecordingInspector : IWindowsInstallerParentInspector { private readonly InstallerRuntimeInspection? _result; diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerPlatformProbeTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerPlatformProbeTests.cs new file mode 100644 index 0000000..07e0725 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerPlatformProbeTests.cs @@ -0,0 +1,57 @@ +using System.Runtime.InteropServices; +using ClashSharp.Installer.Platform; +using ClashSharp.Installer.Windows.Platform; + +namespace ClashSharp.Installer.Windows.Tests; + +public sealed class WindowsInstallerPlatformProbeTests +{ + [Theory] + [InlineData(3, "Server", true)] + [InlineData(2, "Server", true)] + [InlineData(3, "Server Core", false)] + [InlineData(2, "Server Core", false)] + [InlineData(3, null, false)] + [InlineData(3, "", false)] + [InlineData(3, "Client", false)] + [InlineData(3, "server", false)] + [InlineData(3, "Server ", false)] + [InlineData(0, "Server", false)] + [InlineData(4, "Server", false)] + public void ServerAdmissionRequiresBothNativeProductAndExactDesktopInstallation( + byte productType, string? installationType, bool expectedSupported) + { + InstallerPlatformFacts facts = WindowsInstallerPlatformProbe.CreateFacts( + productType, 26100, 9, Architecture.X64, installationType); + + Assert.False(facts.IsWorkstation); + Assert.Equal(expectedSupported, facts.IsServerDesktopExperience); + Assert.Equal(expectedSupported, InstallerPlatformPolicy.Evaluate(facts).IsSupported); + } + + [Fact] + public void WorkstationDoesNotBecomeAServerFromTheRegistryValue() + { + InstallerPlatformFacts facts = WindowsInstallerPlatformProbe.CreateFacts( + 1, 22000, 9, Architecture.X64, "Server"); + + Assert.True(facts.IsWorkstation); + Assert.False(facts.IsServerDesktopExperience); + Assert.True(InstallerPlatformPolicy.Evaluate(facts).IsSupported); + } + + [Theory] + [InlineData(20348, 9, Architecture.X64, "installer.environment.windows_server_2025_required")] + [InlineData(26100, 12, Architecture.X64, "installer.environment.x64_os_required")] + [InlineData(26100, 9, Architecture.X86, "installer.environment.x64_process_required")] + public void NativeServerFactsPreserveVersionAndArchitectureRejections( + int build, ushort nativeArchitecture, Architecture processArchitecture, string expectedDiagnostic) + { + InstallerPlatformFacts facts = WindowsInstallerPlatformProbe.CreateFacts( + 3, build, nativeArchitecture, processArchitecture, "Server"); + + InstallerPlatformAssessment assessment = InstallerPlatformPolicy.Evaluate(facts); + Assert.False(assessment.IsSupported); + Assert.Equal(expectedDiagnostic, assessment.DiagnosticCode); + } +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Execution/WindowsInstallerParentEngine.cs b/ClashSharp/ClashSharp.Installer.Windows/Execution/WindowsInstallerParentEngine.cs index 267b047..a605b0b 100644 --- a/ClashSharp/ClashSharp.Installer.Windows/Execution/WindowsInstallerParentEngine.cs +++ b/ClashSharp/ClashSharp.Installer.Windows/Execution/WindowsInstallerParentEngine.cs @@ -207,8 +207,11 @@ public async Task InspectAsync( AllowReassociation: false, _manifest.ExpectedPackageVersion, _manifest.InstallerPayloadSha256); - InstallerRuntimeInspection inspection = await _inspector - .InspectAsync(request, cancellationToken) + // Authenticode and Windows package inspection contain synchronous native calls. + // Own and await their worker inside this generation, including cancellation cleanup. + InstallerRuntimeInspection inspection = await Task.Run( + () => _inspector.InspectAsync(request, cancellationToken), + cancellationToken) .ConfigureAwait(false) ?? throw new InstallerProtocolException( "installer.runtime.inspection_result_missing"); diff --git a/ClashSharp/ClashSharp.Installer.Windows/Platform/WindowsInstallerPlatformProbe.cs b/ClashSharp/ClashSharp.Installer.Windows/Platform/WindowsInstallerPlatformProbe.cs index 44c69c4..075dc56 100644 --- a/ClashSharp/ClashSharp.Installer.Windows/Platform/WindowsInstallerPlatformProbe.cs +++ b/ClashSharp/ClashSharp.Installer.Windows/Platform/WindowsInstallerPlatformProbe.cs @@ -1,6 +1,8 @@ using System.Runtime.InteropServices; +using System.Security; using ClashSharp.Installer.Contracts; using ClashSharp.Installer.Platform; +using Microsoft.Win32; namespace ClashSharp.Installer.Windows.Platform; @@ -8,6 +10,8 @@ namespace ClashSharp.Installer.Windows.Platform; public sealed class WindowsInstallerPlatformProbe : IInstallerPlatformProbe { private const byte WorkstationProductType = 1; + private const byte DomainControllerProductType = 2; + private const byte ServerProductType = 3; private const ushort ProcessorArchitectureIntel = 0; private const ushort ProcessorArchitectureArm = 5; private const ushort ProcessorArchitectureAmd64 = 9; @@ -39,16 +43,48 @@ public InstallerPlatformFacts Inspect(CancellationToken cancellationToken) } GetNativeSystemInfo(out NativeSystemInfo systemInfo); - InstallerCpuArchitecture processArchitecture = MapProcessArchitecture( - RuntimeInformation.ProcessArchitecture); + cancellationToken.ThrowIfCancellationRequested(); + string? installationType = version.ProductType is DomainControllerProductType or ServerProductType + ? ReadInstallationType() + : null; + cancellationToken.ThrowIfCancellationRequested(); - return new InstallerPlatformFacts( + return CreateFacts( + version.ProductType, + checked((int)version.BuildNumber), + systemInfo.ProcessorInfo.ProcessorArchitecture, + RuntimeInformation.ProcessArchitecture, + installationType); + } + + internal static InstallerPlatformFacts CreateFacts( + byte productType, + int buildNumber, + ushort nativeArchitecture, + Architecture processArchitecture, + string? installationType) => new( IsWindows: true, - IsWorkstation: version.ProductType == WorkstationProductType, - BuildNumber: checked((int)version.BuildNumber), - OperatingSystemArchitecture: MapNativeArchitecture( - systemInfo.ProcessorInfo.ProcessorArchitecture), - ProcessArchitecture: processArchitecture); + IsWorkstation: productType == WorkstationProductType, + BuildNumber: buildNumber, + OperatingSystemArchitecture: MapNativeArchitecture(nativeArchitecture), + ProcessArchitecture: MapProcessArchitecture(processArchitecture), + IsServerDesktopExperience: productType is DomainControllerProductType or ServerProductType + && string.Equals(installationType, "Server", StringComparison.Ordinal)); + + private static string? ReadInstallationType() + { + try + { + // The native product type alone cannot distinguish Server Core from the desktop + // installation. Missing, unreadable, or unknown installation types remain unsupported. + using RegistryKey machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using RegistryKey? version = machine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion", writable: false); + return version?.GetValue("InstallationType", null, RegistryValueOptions.DoNotExpandEnvironmentNames) as string; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or SecurityException) + { + return null; + } } private static InstallerCpuArchitecture MapNativeArchitecture(ushort architecture) => diff --git a/ClashSharp/ClashSharp.Installer/MainWindow.xaml b/ClashSharp/ClashSharp.Installer/MainWindow.xaml index ae3fabd..fee5645 100644 --- a/ClashSharp/ClashSharp.Installer/MainWindow.xaml +++ b/ClashSharp/ClashSharp.Installer/MainWindow.xaml @@ -62,7 +62,7 @@ - + diff --git a/ClashSharp/ClashSharp.Installer/Runtime/MigrationPreviewInstallerRuntime.cs b/ClashSharp/ClashSharp.Installer/Runtime/MigrationPreviewInstallerRuntime.cs index 8442a8f..c6d4688 100644 --- a/ClashSharp/ClashSharp.Installer/Runtime/MigrationPreviewInstallerRuntime.cs +++ b/ClashSharp/ClashSharp.Installer/Runtime/MigrationPreviewInstallerRuntime.cs @@ -58,8 +58,8 @@ private static InstallerRuntimeReadiness CreateReadiness( InstallerPlatformAssessment platform) { string platformDetail = platform.IsSupported - ? "已确认 Windows 11+ 客户端、原生 x64 系统与 x64 安装器进程。" - : "仅支持 Windows 11 或更高版本的原生 x64 客户端系统。"; + ? "已确认 Windows 桌面环境、原生 x64 系统与 x64 安装器进程。" + : "需要 Windows 11+ 或 Windows Server 2025+ 桌面体验,且系统与进程均为 x64。"; return new InstallerRuntimeReadiness( CanExecute: false, @@ -69,14 +69,14 @@ private static InstallerRuntimeReadiness CreateReadiness( StatusTitle: platform.IsSupported ? "此构建暂不提供安装" : "当前系统不受支持", StatusDetail: platform.IsSupported ? "这是开发验证版本。完成发布验证后,正式安装包将提供安装与维护操作。" - : "请在 Windows 11 或更高版本的 x64 电脑上运行。", + : "请使用 Windows 11+ 或 Windows Server 2025+ 桌面体验的 x64 电脑;不支持 Server Core。", DisplayVersion: typeof(MigrationPreviewInstallerRuntime).Assembly.GetName().Version?.ToString(3) ?? "—", ProductState: InstallerProductState.Available, RecoveryOperation: null, AllowedOperations: [], Capabilities: [ - new("Windows 11+ x64", platformDetail, platform.IsSupported), + new("Windows 桌面环境 / x64", platformDetail, platform.IsSupported), new("发布签名与固定清单", "内嵌清单、包内机器文件哈希与候选生成链已实现,尚未完成正式签名发布验证。", false), new("MSIX 用户包事务", "当前用户适配器与 production runtime 已组合;默认发布门关闭,仍待 Windows VM 验证。", false), new("系统服务与证书事务", "helper、认证 pipe、authority、SCM/payload 与目标用户证书事务已组合;默认 parent/helper authority 均禁用,仍待签名 VM 证据。", false), diff --git a/ClashSharp/Installer/PackagingContract.psm1 b/ClashSharp/Installer/PackagingContract.psm1 index d5b3e9a..7f29a45 100644 --- a/ClashSharp/Installer/PackagingContract.psm1 +++ b/ClashSharp/Installer/PackagingContract.psm1 @@ -1069,6 +1069,40 @@ function Get-ClashSharpPackageSignature { } } +function Get-ClashSharpAuthenticodeTimestampUri { + <# + .SYNOPSIS + Validates the configured RFC3161 timestamp endpoint for Windows SDK SignTool. + .DESCRIPTION + Accepts absolute HTTP or HTTPS endpoints without credentials, fragments, or whitespace. + Some SignTool versions require HTTP even when the provider supports HTTPS. The signed + RFC3161 response must still pass the build's final Authenticode and timestamp checks. + .PARAMETER Value + Timestamp endpoint explicitly configured by the release operator. + #> + [CmdletBinding()] + [OutputType([Uri])] + param( + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyString()] + [string] $Value + ) + + $timestampUri = $null + if ([string]::IsNullOrWhiteSpace($Value) -or + $Value -match '[\s\x00-\x1F\x7F]' -or + -not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref] $timestampUri) -or + -not $timestampUri.IsWellFormedOriginalString() -or + $timestampUri.Scheme -cnotin @('http', 'https') -or + [string]::IsNullOrEmpty($timestampUri.Host) -or + -not [string]::IsNullOrEmpty($timestampUri.UserInfo) -or + -not [string]::IsNullOrEmpty($timestampUri.Fragment)) { + throw 'CLASHSHARP_AUTHENTICODE_TIMESTAMP_URL must be an absolute HTTP or HTTPS URI without user information, fragments, or whitespace.' + } + return $timestampUri +} + function Get-ClashSharpInstallerMutationRuntimeProperty { <# .SYNOPSIS @@ -1102,6 +1136,7 @@ Export-ModuleMember -Function @( 'Get-ClashSharpMsixMachineFileContract', 'Get-ClashSharpMainPackageDependency', 'Get-ClashSharpPackageSignature', + 'Get-ClashSharpAuthenticodeTimestampUri', 'Get-ClashSharpInstallerMutationRuntimeProperty', 'New-ClashSharpInstallerReleaseManifest' ) diff --git a/ClashSharp/Installer/README.md b/ClashSharp/Installer/README.md index 78c356b..63fd089 100644 --- a/ClashSharp/Installer/README.md +++ b/ClashSharp/Installer/README.md @@ -2,7 +2,9 @@ 安装器采用 WPF,主程序采用 WinUI 3。Installer Core 定义事务与权限协议, Presentation 管理页面状态,Windows 适配器负责包、服务、证书和受保护状态。 -正常安装目标仍为 Windows 11 原生 x64。 +安装平台检查接受 Windows 11+ 和 Windows Server 2025+ 桌面体验,系统与安装器进程均须为原生 x64。 +服务器同时核对原生产品类型与 64 位注册表的 InstallationType;Server Core、未知安装类型和较旧服务器仍被拒绝。 +平台检查通过后仍须完成签名、离线依赖、包、服务及最终状态验证;服务器完整安装验收尚待执行。 ## 单页安装与维护 @@ -75,10 +77,16 @@ CI 的 Offline Installer package 在干净 Windows runner 上执行以上完整 不带 -Development 的构建另外需要受控的 MSIX PFX/CER、精确的 CLASHSHARP_MSIX_CERTIFICATE_THUMBPRINT、可用的 Authenticode 私钥、 CLASHSHARP_AUTHENTICODE_CERTIFICATE_THUMBPRINT、 -HTTPS CLASHSHARP_AUTHENTICODE_TIMESTAMP_URL 和固定 +HTTP 或 HTTPS CLASHSHARP_AUTHENTICODE_TIMESTAMP_URL 和固定 CLASHSHARP_WINDOWS_SDK_VERSION。脚本验证签名与时间戳后才产生正式文件名。 可选的 CLASHSHARP_WINDOWS_APP_RUNTIME_SIGNER_THUMBPRINT 必须与仓库固定输入一致。 +时间戳地址须为绝对 URI,不能含用户凭据、片段或空白。使用时间戳服务商公布的 +RFC3161 地址;例如 [DigiCert 官方地址](https://knowledge.digicert.com/general-information/rfc3161-compliant-time-stamp-authority-server) +为 `http://timestamp.digicert.com`。Windows SDK 10.0.26100.0 的 SignTool 在服务器 +实测中拒绝对应 HTTPS 地址,HTTP 地址成功取得签名时间戳。允许 HTTP 不改变 +`signtool verify /pa /all /tw`、签名者固定、信任链和时间戳证书的最终校验要求。 + 正式签名构建现在显式编译生产安装与 helper 入口;`-Development` 显式关闭该入口, 普通项目构建仍使用预览运行时。嵌入清单、签名者固定与可信时间戳校验继续决定 正式文件能否输出,重命名开发文件不能启用安装权限。CI 用真实 MSBuild 评估三个 diff --git a/ClashSharp/Installer/Test-InstallerBuildProfiles.ps1 b/ClashSharp/Installer/Test-InstallerBuildProfiles.ps1 index ada65d0..72f4c06 100644 --- a/ClashSharp/Installer/Test-InstallerBuildProfiles.ps1 +++ b/ClashSharp/Installer/Test-InstallerBuildProfiles.ps1 @@ -2,11 +2,12 @@ <# .SYNOPSIS - Verifies actual MSBuild evaluation for preview, development, and signed installer profiles. + Verifies MSBuild installer profiles and the release timestamp endpoint policy. .DESCRIPTION Evaluates the production WPF project and executes only its activation validation target. It never builds, launches, signs, installs, or imports a certificate. Invalid activation - combinations must fail before a compiler or any installer runtime can execute. + combinations must fail before a compiler or any installer runtime can execute. Timestamp URI + cases execute the same endpoint parser used by release signing, without contacting a server. #> [CmdletBinding()] param() @@ -17,6 +18,39 @@ Import-Module (Join-Path $PSScriptRoot 'PackagingContract.psm1') -Force $project = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\ClashSharp.Installer\ClashSharp.Installer.csproj')) $dotnet = (Get-Command dotnet -CommandType Application).Source +$validTimestampUris = @( + 'http://timestamp.digicert.com', + 'https://timestamp.digicert.com', + 'HTTP://timestamp.digicert.com', + 'https://tsa.example.test:8443/rfc3161', + 'https://tsa.example.test/rfc3161?profile=sha256' +) +foreach ($value in $validTimestampUris) { + $uri = Get-ClashSharpAuthenticodeTimestampUri -Value $value + if ($uri -isnot [Uri] -or $uri.AbsoluteUri -cne ([Uri]$value).AbsoluteUri) { + throw 'Release timestamp policy changed a valid endpoint.' + } +} +$invalidTimestampUris = @( + $null, '', ' ', 'timestamp.digicert.com', '/rfc3161', + 'file:///C:/timestamp.tsr', 'ftp://tsa.example.test/rfc3161', + 'http://user@tsa.example.test', 'https://user:secret@tsa.example.test', + 'https://tsa.example.test/#fragment', 'http://', + ' https://tsa.example.test', "https://tsa.example.test`n", 'https://tsa.example.test/time stamp' +) +foreach ($value in $invalidTimestampUris) { + $rejected = $false + try { $null = Get-ClashSharpAuthenticodeTimestampUri -Value $value } + catch { + if (-not $_.Exception.Message.StartsWith('CLASHSHARP_AUTHENTICODE_TIMESTAMP_URL', [StringComparison]::Ordinal)) { + throw + } + $rejected = $true + } + if (-not $rejected) { throw 'Release timestamp policy admitted an invalid endpoint.' } +} +Write-Output "Timestamp endpoint contract passed: $($validTimestampUris.Count) accepted and $($invalidTimestampUris.Count) rejected URIs." + function Invoke-InstallerProfileBuild { <# .SYNOPSIS diff --git a/ClashSharp/Installer/build.ps1 b/ClashSharp/Installer/build.ps1 index 97f09c3..e1d9b8d 100644 --- a/ClashSharp/Installer/build.ps1 +++ b/ClashSharp/Installer/build.ps1 @@ -810,17 +810,8 @@ Write-Host 'WPF Installer passed its isolated single-file build contract.' -Value "Development-only unsigned Installer. Do not publish or distribute this artifact." Write-Warning "Built an explicitly unsigned development Installer. It is not a release artifact." } else { - $timestampUrlText = [string]$env:CLASHSHARP_AUTHENTICODE_TIMESTAMP_URL - try { - $timestampUri = [Uri]$timestampUrlText - } catch { - throw "CLASHSHARP_AUTHENTICODE_TIMESTAMP_URL is not a valid absolute URI." - } - if (-not $timestampUri.IsAbsoluteUri -or - $timestampUri.Scheme -cne "https" -or - -not [string]::IsNullOrEmpty($timestampUri.UserInfo)) { - throw "CLASHSHARP_AUTHENTICODE_TIMESTAMP_URL must be an HTTPS URI without user information." - } + $timestampUri = Get-ClashSharpAuthenticodeTimestampUri ` + -Value ([string]$env:CLASHSHARP_AUTHENTICODE_TIMESTAMP_URL) $authenticodeCertificate = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { From 10dc4e45626fa0f894593f3b87e18a5386a3985c Mon Sep 17 00:00:00 2001 From: Water-Run Date: Sat, 12 Sep 2026 17:50:45 +0800 Subject: [PATCH 13/22] fix(core): validate default startup and retry transient config promotion --- .../Files/CoreConfigurationFilePromotion.cs | 87 +++++++++ .../DefaultRuntimeConfigurationNativeTests.cs | 64 +++++++ .../CoreConfigurationFilePromotionTests.cs | 175 ++++++++++++++++++ .../RuntimeConfigurationTransactionTests.cs | 4 + ...rationService.ProfileRuntimeTransaction.cs | 3 +- ...ConfigurationService.RuntimeTransaction.cs | 51 +++-- .../Service/CoreConfigurationService.cs | 3 +- .../MihomoRuntimeConfigurationBuilder.cs | 4 - Tools/New-DefaultConfigurationProbe.ps1 | 81 ++++++++ 9 files changed, 448 insertions(+), 24 deletions(-) create mode 100644 ClashSharp/ClashSharp.Infrastructure/Files/CoreConfigurationFilePromotion.cs create mode 100644 ClashSharp/ClashSharp.Tests/Integration/DefaultRuntimeConfigurationNativeTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Infrastructure/CoreConfigurationFilePromotionTests.cs create mode 100644 Tools/New-DefaultConfigurationProbe.ps1 diff --git a/ClashSharp/ClashSharp.Infrastructure/Files/CoreConfigurationFilePromotion.cs b/ClashSharp/ClashSharp.Infrastructure/Files/CoreConfigurationFilePromotion.cs new file mode 100644 index 0000000..aa204d6 --- /dev/null +++ b/ClashSharp/ClashSharp.Infrastructure/Files/CoreConfigurationFilePromotion.cs @@ -0,0 +1,87 @@ +using System.Runtime.ExceptionServices; +using ClashSharp.ApplicationModel.Diagnostics; + +namespace ClashSharp.Infrastructure.Files; + +/// Promotes already-written core configuration candidates without repeating their surrounding transaction. +internal static class CoreConfigurationFilePromotion +{ + private const int MaximumRetries = 5; + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(20); + + /// Preserves synchronous caller locks with at most five requested 20 ms retry waits. + internal static void Promote(string stagingPath, string targetPath, CancellationToken cancellationToken, + Action? move = null) + { + (string source, string target) = ValidatePaths(stagingPath, targetPath); + move ??= MoveFile; + ExceptionDispatchInfo? firstFailure = null; + for (int retries = 0; ; retries++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + move(source, target); + return; + } + catch (Exception failure) when (IsRetryable(failure)) + { + firstFailure ??= ExceptionDispatchInfo.Capture(failure); + if (retries == MaximumRetries) { firstFailure.Throw(); } + if (cancellationToken.CanBeCanceled) + { + if (cancellationToken.WaitHandle.WaitOne(RetryDelay)) { cancellationToken.ThrowIfCancellationRequested(); } + } + else + { + Thread.Sleep(RetryDelay); + } + } + } + } + + /// Retries only the same atomic promotion and preserves cancellation during its bounded wait. + internal static async Task PromoteAsync(string stagingPath, string targetPath, CancellationToken cancellationToken, + Action? move = null) + { + (string source, string target) = ValidatePaths(stagingPath, targetPath); + move ??= MoveFile; + ExceptionDispatchInfo? firstFailure = null; + for (int retries = 0; ; retries++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + move(source, target); + return; + } + catch (Exception failure) when (IsRetryable(failure)) + { + firstFailure ??= ExceptionDispatchInfo.Capture(failure); + if (retries == MaximumRetries) { firstFailure.Throw(); } + await Task.Delay(RetryDelay, cancellationToken).ConfigureAwait(false); + } + } + } + + private static (string Source, string Target) ValidatePaths(string stagingPath, string targetPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingPath); + ArgumentException.ThrowIfNullOrWhiteSpace(targetPath); + string source = Path.GetFullPath(stagingPath); + string target = Path.GetFullPath(targetPath); + if (StringComparer.OrdinalIgnoreCase.Equals(source, target) + || !StringComparer.OrdinalIgnoreCase.Equals(Path.GetDirectoryName(source), Path.GetDirectoryName(target))) + { + throw new ArgumentException("Core configuration promotion requires distinct files in the same directory."); + } + return (source, target); + } + + private static void MoveFile(string source, string target) => File.Move(source, target, overwrite: true); + + private static bool IsRetryable(Exception failure) => OperatingSystem.IsWindows() + && failure is IOException or UnauthorizedAccessException + && !ExceptionGraphClassifier.IsProcessFatal(failure) + && failure.HResult is unchecked((int)0x80070005) or unchecked((int)0x80070020) or unchecked((int)0x80070021); +} diff --git a/ClashSharp/ClashSharp.Tests/Integration/DefaultRuntimeConfigurationNativeTests.cs b/ClashSharp/ClashSharp.Tests/Integration/DefaultRuntimeConfigurationNativeTests.cs new file mode 100644 index 0000000..b378233 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/DefaultRuntimeConfigurationNativeTests.cs @@ -0,0 +1,64 @@ +extern alias ClashSharpUi; + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ClashSharp.ApplicationModel.Processes; +using ClashSharp.Infrastructure.Processes; +using ClashSharp.Model; +using Xunit.Abstractions; +using RuntimeConfigurationBuilder = ClashSharpUi::ClashSharp.Service.MihomoRuntimeConfigurationBuilder; + +namespace ClashSharp.Tests.Integration; + +/// Validates actual production defaults with the pinned bundled core in test-only mode. +public sealed class DefaultRuntimeConfigurationNativeTests(ITestOutputHelper output) +{ + [Theory] + [InlineData(ClashSharpMode.Disabled, false)] + [InlineData(ClashSharpMode.Standby, false)] + [InlineData(ClashSharpMode.RuleTakeover, false)] + [InlineData(ClashSharpMode.FullTakeover, false)] + [InlineData(ClashSharpMode.RuleTakeover, true)] + [InlineData(ClashSharpMode.FullTakeover, true)] + public async Task ProductionDefault_BundledCoreAcceptsConfiguration(ClashSharpMode mode, bool effectiveTunEnabled) + { + string assemblyDirectory = Path.GetDirectoryName(typeof(RuntimeConfigurationBuilder).Assembly.Location)!; + string binaryPath = Path.Combine(assemblyDirectory, "Binaries", "mihomo.exe"); + string manifestPath = Path.Combine(assemblyDirectory, "Binaries", "mihomo-manifest.json"); + Assert.True(File.Exists(binaryPath), $"Required bundled native validator is missing: {binaryPath}"); + Assert.True(File.Exists(manifestPath), $"Required bundled core manifest is missing: {manifestPath}"); + string binaryHash; + using (FileStream binary = File.OpenRead(binaryPath)) + { + binaryHash = Convert.ToHexStringLower(await SHA256.HashDataAsync(binary)); + } + using JsonDocument manifest = JsonDocument.Parse(await File.ReadAllTextAsync(manifestPath)); + Assert.Equal(manifest.RootElement.GetProperty("sha256").GetString(), binaryHash); + output.WriteLine($"Binary: {binaryPath}; SHA256: {binaryHash}; Mode: {mode}; EffectiveTun: {effectiveTunEnabled}"); + + // Only this unique directory and fixed test credential are passed to mihomo. + // The -t command validates TUN/DNS syntax without starting listeners or routing. + string directory = Path.Combine(Path.GetTempPath(), "clashsharp-native-default-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string configuration = RuntimeConfigurationBuilder.BuildDefaultConfiguration( + 10000, mode, effectiveTunEnabled, new string('1', 64)); + string candidatePath = Path.Combine(directory, "config.yaml"); + await File.WriteAllTextAsync(candidatePath, configuration, new UTF8Encoding(false)); + ProcessRequest request = new(binaryPath, ["-t", "-d", directory, "-f", candidatePath], + TimeSpan.FromSeconds(15), workingDirectory: directory); + ProcessRunResult result = await new WindowsProcessRunner().RunAsync(request, CancellationToken.None); + string diagnostic = $"Outcome={result.Outcome}; ExitCode={result.ExitCode}; Failure={result.FailureMessage}" + + $"{Environment.NewLine}stdout: {result.StandardOutput}{Environment.NewLine}stderr: {result.StandardError}"; + output.WriteLine(diagnostic); + Assert.True(result.Outcome == ProcessRunOutcome.Completed && result.ExitCode == 0 + && string.IsNullOrEmpty(result.FailureMessage), diagnostic); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Infrastructure/CoreConfigurationFilePromotionTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Infrastructure/CoreConfigurationFilePromotionTests.cs new file mode 100644 index 0000000..701c147 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Infrastructure/CoreConfigurationFilePromotionTests.cs @@ -0,0 +1,175 @@ +using ClashSharp.Infrastructure.Files; + +namespace ClashSharp.Tests.Unit.Infrastructure; + +public sealed class CoreConfigurationFilePromotionTests +{ + [Theory] + [InlineData(false, 5)] + [InlineData(false, 32)] + [InlineData(false, 33)] + [InlineData(true, 5)] + [InlineData(true, 32)] + [InlineData(true, 33)] + public async Task TransientNativeFailure_RetriesOnlyPromotionAndPreservesCandidate(bool asynchronous, int error) + { + using Candidate candidate = new(); + int attempts = 0; + await PromoteAsync(asynchronous, candidate, (source, target) => + { + Assert.Equal(candidate.Source, source); + Assert.Equal(candidate.Target, target); + Assert.Equal("verified candidate", File.ReadAllText(source)); + Assert.Equal("committed baseline", File.ReadAllText(target)); + if (++attempts <= 2) { throw NativeFailure(error); } + File.Move(source, target, overwrite: true); + }); + + Assert.Equal(3, attempts); + Assert.False(File.Exists(candidate.Source)); + Assert.Equal("verified candidate", File.ReadAllText(candidate.Target)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PermanentNativeFailure_IsBoundedAndPreservesFirstFailureForRollback(bool asynchronous) + { + using Candidate candidate = new(); + Exception first = NativeFailure(5); + int attempts = 0; + IOException failure = await Assert.ThrowsAsync(() => + PromoteAsync(asynchronous, candidate, (_, _) => + { + attempts++; + throw attempts == 1 ? first : NativeFailure(5); + })); + + Assert.Same(first, failure); + Assert.Equal(6, attempts); + Assert.Equal("verified candidate", File.ReadAllText(candidate.Source)); + Assert.Equal("committed baseline", File.ReadAllText(candidate.Target)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CancellationDuringRetry_StopsWithoutAnotherPromotion(bool asynchronous) + { + using Candidate candidate = new(); + using CancellationTokenSource cancellation = new(); + int attempts = 0; + OperationCanceledException failure = await Assert.ThrowsAnyAsync(() => + PromoteAsync(asynchronous, candidate, (_, _) => + { + attempts++; + cancellation.Cancel(); + throw NativeFailure(32); + }, cancellation.Token)); + + Assert.Equal(cancellation.Token, failure.CancellationToken); + Assert.Equal(1, attempts); + Assert.Equal("verified candidate", File.ReadAllText(candidate.Source)); + Assert.Equal("committed baseline", File.ReadAllText(candidate.Target)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FatalGraph_IsPropagatedWithoutRetry(bool asynchronous) + { + using Candidate candidate = new(); + Exception fatal = new UnauthorizedAccessException("isolated fatal wrapper", Activator.CreateInstance()); + int attempts = 0; + Exception observed = await Assert.ThrowsAsync(() => + PromoteAsync(asynchronous, candidate, (_, _) => + { + attempts++; + throw fatal; + })); + + Assert.Same(fatal, observed); + Assert.Equal(1, attempts); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OtherIoFailure_IsPropagatedWithoutRetry(bool asynchronous) + { + using Candidate candidate = new(); + IOException original = NativeFailure(112); + int attempts = 0; + IOException observed = await Assert.ThrowsAsync(() => + PromoteAsync(asynchronous, candidate, (_, _) => + { + attempts++; + throw original; + })); + + Assert.Same(original, observed); + Assert.Equal(1, attempts); + } + + [Fact] + public async Task DifferentDirectories_AreRejectedBeforeCallingMove() + { + using Candidate candidate = new(); + int attempts = 0; + await Assert.ThrowsAsync(() => CoreConfigurationFilePromotion.PromoteAsync( + candidate.Source, Path.Combine(candidate.Directory, "other", "target.yaml"), CancellationToken.None, + (_, _) => attempts++)); + Assert.Equal(0, attempts); + } + + [Fact] + public async Task ReadOnlyTarget_RemainsReadOnlyAndRetainsItsCommittedBytes() + { + using Candidate candidate = new(); + File.SetAttributes(candidate.Target, FileAttributes.ReadOnly); + try + { + await Assert.ThrowsAsync(() => CoreConfigurationFilePromotion.PromoteAsync( + candidate.Source, candidate.Target, CancellationToken.None)); + Assert.True(File.GetAttributes(candidate.Target).HasFlag(FileAttributes.ReadOnly)); + Assert.Equal("committed baseline", File.ReadAllText(candidate.Target)); + Assert.Equal("verified candidate", File.ReadAllText(candidate.Source)); + } + finally + { + File.SetAttributes(candidate.Target, FileAttributes.Normal); + } + } + + private static Task PromoteAsync(bool asynchronous, Candidate candidate, Action move, + CancellationToken cancellationToken = default) + { + if (asynchronous) + { + return CoreConfigurationFilePromotion.PromoteAsync(candidate.Source, candidate.Target, cancellationToken, move); + } + CoreConfigurationFilePromotion.Promote(candidate.Source, candidate.Target, cancellationToken, move); + return Task.CompletedTask; + } + + private static IOException NativeFailure(int error) => + new($"Isolated Windows file promotion failure {error}.", unchecked((int)0x80070000) | error); + + private sealed class Candidate : IDisposable + { + public Candidate() + { + Directory = Path.Combine(Path.GetTempPath(), "clashsharp-file-promotion-" + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(Directory); + Source = Path.Combine(Directory, "config.yaml.staging"); + Target = Path.Combine(Directory, "config.yaml"); + File.WriteAllText(Source, "verified candidate"); + File.WriteAllText(Target, "committed baseline"); + } + + public string Directory { get; } + public string Source { get; } + public string Target { get; } + public void Dispose() => System.IO.Directory.Delete(Directory, recursive: true); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/RuntimeConfigurationTransactionTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/RuntimeConfigurationTransactionTests.cs index 25e7f14..2dfac00 100644 --- a/ClashSharp/ClashSharp.Tests/Unit/Services/RuntimeConfigurationTransactionTests.cs +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/RuntimeConfigurationTransactionTests.cs @@ -510,6 +510,10 @@ public async Task ApplyRuntimeConfigurationAsync_RepeatedSuccess_RetainsOnlyBoun mixedPort: 18000 + index, new RecordingRuntime(), CancellationToken.None); + Assert.True(latest.IsApplied, + $"Iteration {index}: Outcome={latest.Outcome}; Failure={latest.Failure}; " + + $"RollbackFailure={latest.RollbackFailure}; MaintenanceFailure={latest.MaintenanceFailure}"); + Assert.Equal(index + 1, latest.GenerationState.AppliedGeneration); } Assert.NotNull(latest); diff --git a/ClashSharp/ClashSharp/Service/CoreConfigurationService.ProfileRuntimeTransaction.cs b/ClashSharp/ClashSharp/Service/CoreConfigurationService.ProfileRuntimeTransaction.cs index 9f74902..876dd27 100644 --- a/ClashSharp/ClashSharp/Service/CoreConfigurationService.ProfileRuntimeTransaction.cs +++ b/ClashSharp/ClashSharp/Service/CoreConfigurationService.ProfileRuntimeTransaction.cs @@ -3,6 +3,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using ClashSharp.Infrastructure.Files; using ClashSharp.Model; namespace ClashSharp.Service; @@ -148,7 +149,7 @@ await _validator cancellationToken.ThrowIfCancellationRequested(); lock (_syncLock) { - File.Move(stagingPath, profileConfigPath, overwrite: true); + CoreConfigurationFilePromotion.Promote(stagingPath, profileConfigPath, cancellationToken); sourcePromoted = true; } diff --git a/ClashSharp/ClashSharp/Service/CoreConfigurationService.RuntimeTransaction.cs b/ClashSharp/ClashSharp/Service/CoreConfigurationService.RuntimeTransaction.cs index 07a66a6..c01ef1d 100644 --- a/ClashSharp/ClashSharp/Service/CoreConfigurationService.RuntimeTransaction.cs +++ b/ClashSharp/ClashSharp/Service/CoreConfigurationService.RuntimeTransaction.cs @@ -10,6 +10,8 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.Infrastructure.Files; using ClashSharp.Model; namespace ClashSharp.Service; @@ -17,7 +19,9 @@ namespace ClashSharp.Service; /// Describes whether the manifest, applied snapshot, and live config agree. internal readonly record struct RuntimeConfigurationIntegrityObservation( bool IsKnown, - RuntimeConfigurationActivationPlan? AppliedPlan) + RuntimeConfigurationActivationPlan? AppliedPlan, + long? AppliedGeneration = null, + string? AppliedContentHash = null) { public static RuntimeConfigurationIntegrityObservation Unknown { get; } = new(false, null); @@ -115,9 +119,10 @@ internal RuntimeConfigurationIntegrityObservation ObserveRuntimeConfigurationInt return RuntimeConfigurationIntegrityObservation.Unknown; } - return new RuntimeConfigurationIntegrityObservation(true, state.AppliedPlan); + return new RuntimeConfigurationIntegrityObservation(true, state.AppliedPlan, + state.AppliedGeneration, state.AppliedContentHash); } - catch (Exception exception) when (exception is + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception) && exception is IOException or UnauthorizedAccessException or JsonException or @@ -166,6 +171,7 @@ internal async Task ApplyRuntimeConfigura ArgumentNullException.ThrowIfNull(runtime); await _runtimeConfigurationGate.WaitAsync(cancellationToken).ConfigureAwait(false); string? stagingPath = null; + bool fatalFailure = false; try { Directory.CreateDirectory(_configurationDirectoryPath); @@ -217,7 +223,7 @@ await _validator .ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); } - catch (OperationCanceledException validationCancellationFailure) + catch (OperationCanceledException validationCancellationFailure) when (!ExceptionGraphClassifier.IsProcessFatal(validationCancellationFailure)) { DeleteFileIfPresent(stagingPath); stagingPath = null; @@ -233,7 +239,7 @@ await _validator throw; } - catch (Exception validationFailure) + catch (Exception validationFailure) when (!ExceptionGraphClassifier.IsProcessFatal(validationFailure)) { DeleteFileIfPresent(stagingPath); stagingPath = null; @@ -251,7 +257,8 @@ stateRollbackFailure is null stateRollbackFailure); } - File.Move(stagingPath, _configurationFilePath, overwrite: true); + await CoreConfigurationFilePromotion.PromoteAsync(stagingPath, _configurationFilePath, cancellationToken) + .ConfigureAwait(false); stagingPath = null; Exception? activationFailure = null; @@ -303,7 +310,7 @@ await EnsureRuntimeSnapshotAsync( MaintenanceFailure = maintenanceFailure, }; } - catch (Exception exception) + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { activationFailure = exception; } @@ -334,14 +341,20 @@ await EnsureRuntimeSnapshotAsync( activationFailure, rollbackFailure); } + catch (Exception exception) when (ExceptionGraphClassifier.IsProcessFatal(exception)) + { + // Preserve desired intent and transaction residue for next-start recovery. + // Fatal graphs cannot safely run runtime compensation or obscure the original failure. + fatalFailure = true; + throw; + } finally { - if (stagingPath is not null) + try { - DeleteFileIfPresent(stagingPath); + if (!fatalFailure && stagingPath is not null) { DeleteFileIfPresent(stagingPath); } } - - _runtimeConfigurationGate.Release(); + finally { _runtimeConfigurationGate.Release(); } } } @@ -358,7 +371,7 @@ private async Task LoadOrBootstrapRuntimeGe manifest = JsonSerializer.Deserialize(json) ?? throw new InvalidDataException("Runtime configuration generation state is empty."); } - catch (JsonException exception) + catch (JsonException exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { throw new InvalidDataException("Runtime configuration generation state is invalid.", exception); } @@ -387,7 +400,7 @@ private async Task LoadOrBootstrapRuntimeGe null, null); } - catch (ArgumentException) + catch (ArgumentException exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { // Legacy bytes have never passed this transaction's exact-candidate validation. // Treat malformed residue as untrusted rather than inferring an applied owner plan. @@ -561,7 +574,7 @@ await PersistRuntimeGenerationStateAsync( return null; } - catch (Exception rollbackFailure) + catch (Exception rollbackFailure) when (!ExceptionGraphClassifier.IsProcessFatal(rollbackFailure)) { return rollbackFailure; } @@ -576,7 +589,7 @@ await PersistRuntimeGenerationStateAsync(state, CancellationToken.None) .ConfigureAwait(false); return null; } - catch (Exception persistenceFailure) + catch (Exception persistenceFailure) when (!ExceptionGraphClassifier.IsProcessFatal(persistenceFailure)) { return persistenceFailure; } @@ -706,7 +719,7 @@ await VerifyRuntimeSnapshotAsync(snapshotPath, contentHash, plan, cancellationTo return null; } - catch (Exception maintenanceFailure) + catch (Exception maintenanceFailure) when (!ExceptionGraphClassifier.IsProcessFatal(maintenanceFailure)) { return maintenanceFailure; } @@ -805,7 +818,8 @@ private async Task RestoreSnapshotFileAsync( destination.Flush(flushToDisk: true); } - File.Move(restorePath, _configurationFilePath, overwrite: true); + await CoreConfigurationFilePromotion.PromoteAsync(restorePath, _configurationFilePath, cancellationToken) + .ConfigureAwait(false); } finally { @@ -824,7 +838,8 @@ private async Task PersistRuntimeGenerationStateAsync( try { await WriteDurableTextAsync(temporaryPath, json, cancellationToken).ConfigureAwait(false); - File.Move(temporaryPath, statePath, overwrite: true); + await CoreConfigurationFilePromotion.PromoteAsync(temporaryPath, statePath, cancellationToken) + .ConfigureAwait(false); } finally { diff --git a/ClashSharp/ClashSharp/Service/CoreConfigurationService.cs b/ClashSharp/ClashSharp/Service/CoreConfigurationService.cs index 5ea9394..b695d6f 100644 --- a/ClashSharp/ClashSharp/Service/CoreConfigurationService.cs +++ b/ClashSharp/ClashSharp/Service/CoreConfigurationService.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using ClashSharp.ApplicationModel.Security; +using ClashSharp.Infrastructure.Files; using ClashSharp.Model; namespace ClashSharp.Service; @@ -290,7 +291,7 @@ await _validator lock (_syncLock) { commitAttempted = true; - File.Move(stagingPath, profileConfigPath, overwrite: true); + CoreConfigurationFilePromotion.Promote(stagingPath, profileConfigPath, cancellationToken); DeleteFileIfPresent(backupPath); } } diff --git a/ClashSharp/ClashSharp/Service/MihomoRuntimeConfigurationBuilder.cs b/ClashSharp/ClashSharp/Service/MihomoRuntimeConfigurationBuilder.cs index 5b7fc04..789390b 100644 --- a/ClashSharp/ClashSharp/Service/MihomoRuntimeConfigurationBuilder.cs +++ b/ClashSharp/ClashSharp/Service/MihomoRuntimeConfigurationBuilder.cs @@ -58,10 +58,6 @@ public static string BuildDefaultConfiguration( " type: select", " proxies:", " - DIRECT", - " - name: DIRECT", - " type: select", - " proxies:", - " - DIRECT", "rules:", " - MATCH,DIRECT", string.Empty, diff --git a/Tools/New-DefaultConfigurationProbe.ps1 b/Tools/New-DefaultConfigurationProbe.ps1 new file mode 100644 index 0000000..6eca3f2 --- /dev/null +++ b/Tools/New-DefaultConfigurationProbe.ps1 @@ -0,0 +1,81 @@ +#Requires -Version 7.6 + +<# +.SYNOPSIS + Exports one default candidate from an already built ClashSharp assembly for isolated mihomo -t validation. +.DESCRIPTION + Calls the real runtime configuration builder with a fixed test credential. Writes only a newly + created probe directory; does not open application settings or start mihomo, the app, or a service. +.PARAMETER AssemblyPath + Absolute or relative path to a built ClashSharp.dll with its dependencies beside it. +.PARAMETER OutputRoot + Existing directory under which a unique probe directory is created. Defaults to the system temp directory. +.EXAMPLE + ./tools/New-DefaultConfigurationProbe.ps1 -AssemblyPath ./ClashSharp/ClashSharp/bin/x64/Release/net10.0-windows10.0.22000.0/win-x64/ClashSharp.dll +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$AssemblyPath, + + [ValidateNotNullOrEmpty()] + [string]$OutputRoot = [System.IO.Path]::GetTempPath(), + + [ValidateSet('Disabled', 'Standby', 'RuleTakeover', 'FullTakeover')] + [string]$Mode = 'Disabled', + + [ValidateRange(1, 65535)] + [int]$MixedPort = 10000, + + [switch]$TransparentProxyEnabled +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$resolvedAssembly = (Get-Item -LiteralPath $AssemblyPath -ErrorAction Stop).FullName +$resolvedOutputRoot = (Get-Item -LiteralPath $OutputRoot -ErrorAction Stop).FullName +if (-not [System.IO.Directory]::Exists($resolvedOutputRoot)) { + throw 'OutputRoot must be an existing directory.' +} + +$builderAssembly = [System.Reflection.Assembly]::LoadFrom($resolvedAssembly) +$builderType = $builderAssembly.GetType('ClashSharp.Service.MihomoRuntimeConfigurationBuilder', $true) +$builderMethod = $builderType.GetMethod('BuildDefaultConfiguration', [System.Reflection.BindingFlags]'Public, Static') +if ($null -eq $builderMethod) { + throw 'The specified assembly does not expose the expected runtime configuration builder.' +} + +$modeType = $builderMethod.GetParameters()[1].ParameterType +$modeValue = [System.Enum]::Parse($modeType, $Mode) +$testCredential = '1' * 64 +$candidate = [string]$builderMethod.Invoke($null, [object[]]@( + $MixedPort, $modeValue, [bool]$TransparentProxyEnabled, $testCredential +)) + +$probeDirectory = Join-Path $resolvedOutputRoot ('clashsharp-default-probe-' + [System.Guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $probeDirectory -ErrorAction Stop +$candidatePath = Join-Path $probeDirectory 'config.yaml' +[System.IO.File]::WriteAllText($candidatePath, $candidate, [System.Text.UTF8Encoding]::new($false)) +$manifest = [ordered]@{ + schemaVersion = 1 + assemblyPath = $resolvedAssembly + assemblySha256 = (Get-FileHash -LiteralPath $resolvedAssembly -Algorithm SHA256).Hash.ToLowerInvariant() + mode = $Mode + mixedPort = $MixedPort + transparentProxyEnabled = [bool]$TransparentProxyEnabled + usesFixedTestCredential = $true + candidateSha256 = (Get-FileHash -LiteralPath $candidatePath -Algorithm SHA256).Hash.ToLowerInvariant() + createdAtUtc = [System.DateTimeOffset]::UtcNow.ToString('O') +} +$manifestPath = Join-Path $probeDirectory 'probe.json' +[System.IO.File]::WriteAllText($manifestPath, ($manifest | ConvertTo-Json), [System.Text.UTF8Encoding]::new($false)) + +[pscustomobject]@{ + ProbeDirectory = $probeDirectory + CandidatePath = $candidatePath + ManifestPath = $manifestPath + Mode = $Mode + UsesFixedTestCredential = $true +} From 4640685f6a80d3d4396f93b0cd7bac65e20ba379 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Sat, 12 Sep 2026 17:50:52 +0800 Subject: [PATCH 14/22] feat(settings): observe network ownership and drain repository operations --- .../Data/RepositoryOperationLifetime.cs | 65 +++ .../NetworkSettingsParticipantTests.cs | 334 +++++++++++++ .../ProductionRepositoryLifetimeTests.cs | 125 +++++ .../Unit/Services/LogStorageServiceTests.cs | 75 +++ .../Services/NetworkSettingsRuntimeTests.cs | 445 ++++++++++++++++++ .../Services/ProfileCatalogServiceTests.cs | 140 +++++- .../AppHost/ClashSharpAppHostFactory.cs | 3 + .../Settings/INetworkSettingsRuntime.cs | 41 ++ .../Settings/NetworkSettingsParticipant.cs | 126 +++++ .../Settings/NetworkSettingsRuntime.cs | 93 ++++ .../ClashSharp/Service/LogStorageService.cs | 66 ++- .../Service/LogStorageServiceFactory.cs | 19 +- .../NetworkTakeoverService.NetworkSettings.cs | 94 ++++ .../Service/NetworkTakeoverService.cs | 5 +- .../Service/ProfileCatalogService.cs | 60 ++- .../Service/ProfileCatalogServiceFactory.cs | 34 +- .../Service/WindowsProxyMutationJournal.cs | 9 +- .../ClashSharp/Service/WindowsProxyService.cs | 30 ++ 18 files changed, 1728 insertions(+), 36 deletions(-) create mode 100644 ClashSharp/ClashSharp.Application/Data/RepositoryOperationLifetime.cs create mode 100644 ClashSharp/ClashSharp.Tests/Integration/NetworkSettingsParticipantTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Integration/ProductionRepositoryLifetimeTests.cs create mode 100644 ClashSharp/ClashSharp.Tests/Unit/Services/NetworkSettingsRuntimeTests.cs create mode 100644 ClashSharp/ClashSharp/AppHost/Settings/INetworkSettingsRuntime.cs create mode 100644 ClashSharp/ClashSharp/AppHost/Settings/NetworkSettingsParticipant.cs create mode 100644 ClashSharp/ClashSharp/AppHost/Settings/NetworkSettingsRuntime.cs create mode 100644 ClashSharp/ClashSharp/Service/NetworkTakeoverService.NetworkSettings.cs diff --git a/ClashSharp/ClashSharp.Application/Data/RepositoryOperationLifetime.cs b/ClashSharp/ClashSharp.Application/Data/RepositoryOperationLifetime.cs new file mode 100644 index 0000000..df6e6fd --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Data/RepositoryOperationLifetime.cs @@ -0,0 +1,65 @@ +namespace ClashSharp.ApplicationModel.Data; + +/// Retires a repository only after every accepted operation has completed its full storage work. +public sealed class RepositoryOperationLifetime : IAsyncDisposable +{ + private readonly object _gate = new(); + private readonly object _repository; + private readonly TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _operations; + private bool _retired; + + /// Creates a lifetime without accessing storage or accepting any operations. + /// Repository identified by a rejection after retirement. + public RepositoryOperationLifetime(object repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + /// Accepts one operation whose lease must span all asynchronous work and compensation. + /// An idempotent lease released when the complete operation leaves the repository. + public IDisposable Enter() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_retired, _repository); + _operations++; + return new Operation(this); + } + } + + /// Rejects new operations immediately and asynchronously waits for accepted operations to drain. + /// The shared completion of repository retirement. + public ValueTask DisposeAsync() + { + lock (_gate) + { + _retired = true; + if (_operations == 0) + { + _drained.TrySetResult(); + } + + return new ValueTask(_drained.Task); + } + } + + private void Leave() + { + lock (_gate) + { + _operations--; + if (_retired && _operations == 0) + { + _drained.TrySetResult(); + } + } + } + + private sealed class Operation(RepositoryOperationLifetime owner) : IDisposable + { + private RepositoryOperationLifetime? _owner = owner; + + public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Leave(); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Integration/NetworkSettingsParticipantTests.cs b/ClashSharp/ClashSharp.Tests/Integration/NetworkSettingsParticipantTests.cs new file mode 100644 index 0000000..4f021eb --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/NetworkSettingsParticipantTests.cs @@ -0,0 +1,334 @@ +extern alias ClashSharpUi; + +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Infrastructure.Settings; +using ClashSharp.Model; +using ClashSharp.Settings; +using ClashSharp.Tests.Unit.Settings; +using NetworkSettingsConfiguration = ClashSharpUi::ClashSharp.Hosting.Settings.NetworkSettingsConfiguration; +using NetworkSettingsParticipant = ClashSharpUi::ClashSharp.Hosting.Settings.NetworkSettingsParticipant; +using NetworkSettingsRuntime = ClashSharpUi::ClashSharp.Hosting.Settings.NetworkSettingsRuntime; + +namespace ClashSharp.Tests.Integration; + +/// Exercises durable settings against independently observed, isolated network boundaries. +public sealed class NetworkSettingsParticipantTests +{ + [Fact] + public async Task CompleteBatch_PersistsRunningBeforeApplyingAndVerifiesAllFourKeys() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("CurrentMode", "RuleTakeover"), Change("ActiveProfileId", "test-profile"), + Change("TransparentProxyEnabled", "false"), Change("MixedPort", "18080"))).IsSucceeded); + fixture.Surface.BeforeApply = async () => + { + Assert.Equal(SettingsApplicationBatchState.Running, + Assert.Single((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.PendingApplications).State); + }; + SettingsAuthorityResult result = await fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort); + Assert.True(result.IsSucceeded, result.Code); + Assert.Empty(result.Envelope!.PendingApplications); + Assert.Equal(new NetworkSettingsConfiguration(ClashSharpMode.RuleTakeover, "test-profile", false, 18080), fixture.Surface.Actual); + Assert.Equal(1, fixture.Surface.Applies); + Assert.True(fixture.Surface.Reads >= 4); + } + + [Fact] + public async Task SingleKey_DoesNotConsumeOtherPendingNetworkIntent() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("ActiveProfileId", "pending-profile"))).IsSucceeded); + Assert.True((await fixture.ChangeAsync(Change("MixedPort", "18081"))).IsSucceeded); + SettingsAuthorityResult result = await fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal("builtin-direct", fixture.Surface.Actual.ProfileId); + Assert.Equal(18081, fixture.Surface.Actual.MixedPort); + Assert.Equal(SettingsRegistry.Keys.ActiveProfileId, Assert.Single(Assert.Single(result.Envelope!.PendingApplications).Entries).Key); + } + + [Theory] + [InlineData(ClashSharpMode.Disabled)] + [InlineData(ClashSharpMode.Standby)] + public async Task InactiveTunPreference_IsInstalledWithoutClaimingEffectiveTun(ClashSharpMode mode) + { + await using Fixture fixture = await Fixture.CreateAsync(); + // Force a pending retry even though the registry's initial Desired value is true. + Assert.True((await fixture.ChangeAsync(Change("TransparentProxyEnabled", "false"))).IsSucceeded); + Assert.True((await fixture.ApplyAsync(SettingsRegistry.Keys.TransparentProxyEnabled)).IsSucceeded); + Assert.True((await fixture.ChangeAsync(Change("TransparentProxyEnabled", "true"), Change("CurrentMode", mode.ToString()))).IsSucceeded); + Assert.True((await fixture.ApplyAsync(SettingsRegistry.Keys.TransparentProxyEnabled)).IsSucceeded); + NetworkSettingsConfiguration installed = await fixture.Runtime.ReadConfigurationAsync(CancellationToken.None); + Assert.True(installed.TransparentProxyEnabled); + Assert.False(installed.EffectiveTunEnabled); + Assert.False(fixture.Surface.Actual.TransparentProxyEnabled); + } + + [Fact] + public async Task NativeLostReply_UsesIndependentObservationToComplete() + { + await using Fixture fixture = await Fixture.CreateAsync(); + fixture.Surface.LoseReply = true; + Assert.True((await fixture.ChangeAsync(Change("MixedPort", "18082"))).IsSucceeded); + SettingsAuthorityResult result = await fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort); + Assert.True(result.IsSucceeded, result.Code); + Assert.Equal(18082, fixture.Surface.Actual.MixedPort); + Assert.Equal(1, fixture.Surface.Applies); + } + + [Fact] + public async Task UnknownBaseline_BlocksEveryEffectAndRetainsFailedDesired() + { + await using Fixture fixture = await Fixture.CreateAsync(); + fixture.Surface.Unavailable = true; + Assert.True((await fixture.ChangeAsync(Change("MixedPort", "18083"))).IsSucceeded); + SettingsAuthorityResult result = await fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, result.Status); + Assert.Equal(SettingsApplicationBatchState.Failed, Assert.Single(result.Envelope!.PendingApplications).State); + Assert.Equal(18083, result.Envelope.Desired[SettingsRegistry.Keys.MixedPort].Value.Get()); + Assert.Equal(0, fixture.Surface.Applies); + } + + [Fact] + public async Task FailedCompoundEffect_CannotAcknowledgeOneMatchingScalarOrBlindlyRetry() + { + await using Fixture fixture = await Fixture.CreateAsync(); + fixture.Surface.PartialProfileFailure = true; + Assert.True((await fixture.ChangeAsync(Change("MixedPort", "18084"))).IsSucceeded); + SettingsAuthorityResult result = await fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, result.Status); + Assert.Equal(18084, fixture.Surface.Actual.MixedPort); + Assert.Equal("unintended-profile", fixture.Surface.Actual.ProfileId); + SettingsApplicationRequest staleAttempt = fixture.Capture.Request!; + await Assert.ThrowsAsync(() => fixture.Runtime.ReadConfigurationAsync(CancellationToken.None)); + using MutationAdmissionLease lease = fixture.Admission.AcquireOrdinary(); + await Assert.ThrowsAsync(() => fixture.Participant.ApplyAsync(fixture.Capture.Request!, lease, CancellationToken.None)); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, + (await fixture.RetryAsync(SettingsRegistry.Keys.MixedPort)).Status); + int readsAfterRetry = fixture.Surface.Reads; + await Assert.ThrowsAsync(() => fixture.Participant.ProbeAsync(staleAttempt, lease, CancellationToken.None)); + Assert.Equal(readsAfterRetry, fixture.Surface.Reads); + Assert.Equal(1, fixture.Surface.Applies); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExplicitRetry_UnlocksOnlyAfterCompleteBaselineOrTargetRecovers(bool targetRecovered) + { + await using Fixture fixture = await Fixture.CreateAsync(); + fixture.Surface.BeforeApply = () => { fixture.Surface.Unavailable = true; return Task.CompletedTask; }; + fixture.Surface.LoseReply = targetRecovered; + fixture.Surface.Failure = targetRecovered ? null : new IOException("isolated apply and compensation reply failure"); + Assert.True((await fixture.ChangeAsync(Change("MixedPort", "18088"))).IsSucceeded); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, (await fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort)).Status); + fixture.Surface.Unavailable = false; + fixture.Surface.Failure = null; + fixture.Surface.LoseReply = false; + fixture.Surface.BeforeApply = null; + // Availability alone does not make a final probe or stale attempt safe. + await Assert.ThrowsAsync(() => fixture.Runtime.ReadConfigurationAsync(CancellationToken.None)); + int readsBeforeRetry = fixture.Surface.Reads; + SettingsAuthorityResult result = await fixture.RetryAsync(SettingsRegistry.Keys.MixedPort); + Assert.True(result.IsSucceeded, result.Code); + Assert.Empty(result.Envelope!.PendingApplications); + Assert.Equal(18088, fixture.Surface.Actual.MixedPort); + Assert.Equal(targetRecovered ? 1 : 2, fixture.Surface.Applies); + Assert.True(fixture.Surface.Reads > readsBeforeRetry); + } + + [Fact] + public async Task ExplicitRetry_WhenInactiveTunPolicyHasNoExternalEvidence_ReinstallsBeforePublishing() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("TransparentProxyEnabled", "false"))).IsSucceeded); + Assert.True((await fixture.ApplyAsync(SettingsRegistry.Keys.TransparentProxyEnabled)).IsSucceeded); + fixture.Surface.BeforeApply = () => { fixture.Surface.Unavailable = true; return Task.CompletedTask; }; + fixture.Surface.LoseReply = true; + Assert.True((await fixture.ChangeAsync(Change("TransparentProxyEnabled", "true"))).IsSucceeded); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, + (await fixture.ApplyAsync(SettingsRegistry.Keys.TransparentProxyEnabled)).Status); + fixture.Surface.Unavailable = false; + fixture.Surface.LoseReply = false; + fixture.Surface.BeforeApply = null; + int applies = fixture.Surface.Applies; + Assert.True((await fixture.RetryAsync(SettingsRegistry.Keys.TransparentProxyEnabled)).IsSucceeded); + Assert.Equal(applies + 1, fixture.Surface.Applies); + Assert.True((await fixture.Runtime.ReadConfigurationAsync(CancellationToken.None)).TransparentProxyEnabled); + Assert.False(fixture.Surface.Actual.TransparentProxyEnabled); + } + + [Fact] + public async Task SupersedingFailedBatch_RequiresExplicitRetryBeforeRecoveringAndApplyingNewIntent() + { + await using Fixture fixture = await Fixture.CreateAsync(); + fixture.Surface.BeforeApply = () => { fixture.Surface.Unavailable = true; return Task.CompletedTask; }; + fixture.Surface.Failure = new IOException("isolated runtime compensation reply unavailable"); + Assert.True((await fixture.ChangeAsync(Change("MixedPort", "18089"))).IsSucceeded); + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, (await fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort)).Status); + Guid originalBatch = fixture.Capture.Request!.Batch.BatchId; + fixture.Surface.Unavailable = false; + fixture.Surface.Failure = null; + fixture.Surface.BeforeApply = null; + Assert.True((await fixture.ChangeAsync(Change("MixedPort", "18090"))).IsSucceeded); + int reads = fixture.Surface.Reads; + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, (await fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort)).Status); + Assert.NotEqual(originalBatch, fixture.Capture.Request!.Batch.BatchId); + Assert.Equal(reads, fixture.Surface.Reads); + Assert.Equal(1, fixture.Surface.Applies); + SettingsAuthorityResult retry = await fixture.RetryAsync(SettingsRegistry.Keys.MixedPort); + Assert.True(retry.IsSucceeded, retry.Code); + Assert.Empty(retry.Envelope!.PendingApplications); + Assert.Equal(18090, fixture.Surface.Actual.MixedPort); + Assert.Equal(2, fixture.Surface.Applies); + } + + [Fact] + public async Task FatalNativeGraph_EscapesUnchangedAndLeavesRunningIntent() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Exception fatal = new AggregateException(new InvalidOperationException("fatal wrapper", Activator.CreateInstance())); + fixture.Surface.Failure = fatal; + Assert.True((await fixture.ChangeAsync(Change("MixedPort", "18085"))).IsSucceeded); + Assert.Same(fatal, await Assert.ThrowsAsync(() => fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort))); + Assert.Equal(SettingsApplicationBatchState.Running, + Assert.Single((await fixture.Repository.OpenAsync(CancellationToken.None)).Envelope!.PendingApplications).State); + Assert.Equal(10000, fixture.Surface.Actual.MixedPort); + } + + [Fact] + public async Task ForeignGenerationAndInactiveLease_AreRejectedBeforeRuntimeReads() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("MixedPort", "18086"))).IsSucceeded); + Assert.True((await fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort)).IsSucceeded); + int reads = fixture.Surface.Reads; + await using NetworkSettingsParticipant foreign = new(fixture.Directory.CreateGeneration(2), fixture.Admission, fixture.Runtime); + using MutationAdmissionLease own = fixture.Admission.AcquireOrdinary(); + await Assert.ThrowsAsync(() => foreign.ProbeAsync(fixture.Capture.Request!, own, CancellationToken.None)); + using MutationAdmissionLease other = new MutationAdmissionBarrier().AcquireOrdinary(); + await Assert.ThrowsAsync(() => fixture.Participant.ApplyAsync(fixture.Capture.Request!, other, CancellationToken.None)); + MutationAdmissionLease released = fixture.Admission.AcquireOrdinary(); + released.Dispose(); + await Assert.ThrowsAsync(() => fixture.Participant.ProbeAsync(fixture.Capture.Request!, released, CancellationToken.None)); + Assert.Equal(reads, fixture.Surface.Reads); + } + + [Fact] + public async Task Retirement_DrainsStartedNativeTransactionAndRejectsNewCalls() + { + await using Fixture fixture = await Fixture.CreateAsync(); + Assert.True((await fixture.ChangeAsync(Change("MixedPort", "18087"))).IsSucceeded); + TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Surface.BeforeApply = async () => { started.TrySetResult(); await release.Task; }; + Task operation = fixture.ApplyAsync(SettingsRegistry.Keys.MixedPort); + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Task retirement = fixture.Participant.DisposeAsync().AsTask(); + try + { + Assert.False(retirement.IsCompleted); + using MutationAdmissionLease lease = fixture.Admission.AcquireOrdinary(); + await Assert.ThrowsAsync(() => fixture.Participant.ProbeAsync(fixture.Capture.Request!, lease, CancellationToken.None)); + } + finally { release.TrySetResult(); } + await retirement; + // Direct retirement before the session's final probe intentionally leaves the + // durable attempt failed, while the already-started native transaction is drained. + Assert.Equal(SettingsAuthorityStatus.ApplicationFailed, (await operation).Status); + Assert.Equal(18087, fixture.Surface.Actual.MixedPort); + } + + private static SettingValueChange Change(string key, string value) => new(new(key), SettingsEnvelopeTestData.Value(key, value)); + + private sealed class Fixture : IAsyncDisposable + { + private Fixture() + { + DataGenerationDescriptor generation = Directory.CreateGeneration(1); + Repository = new(generation, SettingsRegistry.Default); + Session = new(Repository, SettingsRegistry.Default, Admission); + Runtime = new(Surface.ObserveAsync, Surface.ApplyAsync); + Participant = new(generation, Admission, Runtime); + Capture = new(Participant); + } + public DataGenerationTestDirectory Directory { get; } = new(); + public MutationAdmissionBarrier Admission { get; } = new(); + public Surface Surface { get; } = new(); + public JsonSettingsRepository Repository { get; } + public SettingsAuthoritySession Session { get; } + public NetworkSettingsRuntime Runtime { get; } + public NetworkSettingsParticipant Participant { get; } + public CaptureParticipant Capture { get; } + + public static async Task CreateAsync() + { + Fixture fixture = new(); + Assert.True((await fixture.Repository.SaveAsync(SettingsEnvelopeTestData.CreateMatchingEnvelope(), 0, CancellationToken.None)).IsSucceeded); + return fixture; + } + + public async Task ChangeAsync(params SettingValueChange[] changes) + { + using MutationAdmissionLease lease = Admission.AcquireOrdinary(); + return await Session.ChangeAdmittedAsync(changes, Guid.NewGuid(), lease, CancellationToken.None); + } + + public async Task ApplyAsync(SettingKey key) + { + using MutationAdmissionLease lease = Admission.AcquireOrdinary(); + SettingsApplicationBatch batch = Assert.Single((await Repository.OpenAsync(CancellationToken.None)).Envelope!.PendingApplications, + candidate => candidate.Entries.Any(entry => entry.Key == key)); + return await Session.ApplyBatchAdmittedAsync(batch.BatchId, batch.AttemptId, Capture, SettingsApplicationPhase.Live, lease, CancellationToken.None); + } + + public async Task RetryAsync(SettingKey key) + { + using MutationAdmissionLease lease = Admission.AcquireOrdinary(); + SettingsApplicationBatch batch = Assert.Single((await Repository.OpenAsync(CancellationToken.None)).Envelope!.PendingApplications, + candidate => candidate.Entries.Any(entry => entry.Key == key)); + Guid retry = Guid.NewGuid(); + Assert.True((await Session.RetryAdmittedAsync(batch.BatchId, batch.AttemptId, retry, lease, CancellationToken.None)).IsSucceeded); + return await Session.ApplyBatchAdmittedAsync(batch.BatchId, retry, Capture, SettingsApplicationPhase.Live, lease, CancellationToken.None); + } + + public async ValueTask DisposeAsync() { await Session.DisposeAsync(); await Participant.DisposeAsync(); await Directory.DisposeAsync(); } + } + + private sealed class CaptureParticipant(NetworkSettingsParticipant participant) : ISettingsApplicationParticipant + { + public SettingApplicationKind ApplicationKind => SettingApplicationKind.Network; + public SettingsApplicationRequest? Request { get; private set; } + public Task ProbeAsync(SettingsApplicationRequest request, MutationAdmissionLease lease, CancellationToken token) + { Request = request; return participant.ProbeAsync(request, lease, token); } + public Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease lease, CancellationToken token) => participant.ApplyAsync(request, lease, token); + } + + private sealed class Surface + { + public NetworkSettingsConfiguration Actual { get; private set; } = new(ClashSharpMode.Disabled, "builtin-direct", false, 10000); + public int Reads { get; private set; } + public int Applies { get; private set; } + public bool Unavailable { get; set; } + public bool LoseReply { get; set; } + public bool PartialProfileFailure { get; set; } + public Exception? Failure { get; set; } + public Func? BeforeApply { get; set; } + public Task ObserveAsync(CancellationToken token) + { + token.ThrowIfCancellationRequested(); + ++Reads; + return Unavailable ? Task.FromException(new IOException("Isolated unavailable network observation.")) : Task.FromResult(Actual); + } + public async Task ApplyAsync(NetworkSettingsConfiguration target, CancellationToken token) + { + Assert.False(token.CanBeCanceled); + ++Applies; + if (BeforeApply is not null) { await BeforeApply(); } + if (Failure is not null) { throw Failure; } + Actual = new(target.Mode, PartialProfileFailure ? "unintended-profile" : target.ProfileId, target.EffectiveTunEnabled, target.MixedPort); + if (LoseReply || PartialProfileFailure) { throw new IOException("Isolated native reply failure."); } + } + } +} diff --git a/ClashSharp/ClashSharp.Tests/Integration/ProductionRepositoryLifetimeTests.cs b/ClashSharp/ClashSharp.Tests/Integration/ProductionRepositoryLifetimeTests.cs new file mode 100644 index 0000000..17c7af1 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Integration/ProductionRepositoryLifetimeTests.cs @@ -0,0 +1,125 @@ +extern alias ClashSharpUi; + +using ClashSharp.ApplicationModel.Hosting; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Startup; +using Microsoft.Extensions.DependencyInjection; +using CoreConfigurationState = ClashSharpUi::ClashSharp.Model.CoreConfigurationState; +using IProfileCatalogAdmittedSettings = ClashSharpUi::ClashSharp.Service.IProfileCatalogAdmittedSettings; +using IProfileCatalogCoreConfiguration = ClashSharpUi::ClashSharp.Service.IProfileCatalogCoreConfiguration; +using IProfileCatalogLog = ClashSharpUi::ClashSharp.Service.IProfileCatalogLog; +using IProfileCatalogRuntime = ClashSharpUi::ClashSharp.Service.IProfileCatalogRuntime; +using IProfileCatalogSettings = ClashSharpUi::ClashSharp.Service.IProfileCatalogSettings; +using LogStorageService = ClashSharpUi::ClashSharp.Service.LogStorageService; +using LogStorageServiceFactory = ClashSharpUi::ClashSharp.Service.LogStorageServiceFactory; +using ProfileCatalogIds = ClashSharpUi::ClashSharp.Service.ProfileCatalogIds; +using ProfileCatalogMutationCoordinator = ClashSharpUi::ClashSharp.Service.ProfileCatalogMutationCoordinator; +using ProfileCatalogRuntimeImportResult = ClashSharpUi::ClashSharp.Service.ProfileCatalogRuntimeImportResult; +using ProfileCatalogService = ClashSharpUi::ClashSharp.Service.ProfileCatalogService; +using ProfileCatalogServiceFactory = ClashSharpUi::ClashSharp.Service.ProfileCatalogServiceFactory; +using ProfileImportResult = ClashSharpUi::ClashSharp.Model.ProfileImportResult; + +namespace ClashSharp.Tests.Integration; + +/// Exercises AppHost disposal against the actual main-program repository implementations. +public sealed class ProductionRepositoryLifetimeTests +{ + [Fact] + public async Task HostDisposal_DrainsActualCatalogThenRetiresActualLogStorage() + { + string root = Path.Combine(Path.GetTempPath(), "clashsharp-owned-repositories-" + Guid.NewGuid().ToString("N")); + LogStorageService? logs = null; + ProfileCatalogService? profiles = null; + Settings settings = new(); + Runtime runtime = new(); + AppHost host = AppHost.Build(services => + { + services.AddSingleton(_ => logs = LogStorageServiceFactory.CreateForDirectory(root, () => settings.ActiveProfileId)); + services.AddSingleton(provider => profiles = ProfileCatalogServiceFactory.CreateForDirectory( + root, settings, new Configuration(), runtime, + new Log(provider.GetRequiredService()), key => key, + new ProfileCatalogMutationCoordinator(new MutationAdmissionBarrier(), new FairAsyncMutationGate()))); + services.AddSingleton(provider => new Startup(provider.GetRequiredService())); + }); + try + { + Assert.False(Directory.Exists(root)); + _ = await host.StartAsync(new AppLaunchRequest(string.Empty), CancellationToken.None); + Assert.NotNull(profiles); + Assert.NotNull(logs); + logs.AppendLog("Info", "Startup", "before retirement", null); + Task activation = profiles.TryApplyActiveProfileAsync(ProfileCatalogIds.BuiltInDirect, CancellationToken.None); + await runtime.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Task retiringHost = host.DisposeAsync().AsTask(); + try + { + Assert.False(retiringHost.IsCompleted); + await Assert.ThrowsAsync(() => profiles.AddSubscriptionLinkAsync("late", "https://example.test/late", CancellationToken.None)); + // The catalog still owns an accepted operation, so its log dependency must remain available. + logs.AppendLog("Info", "Catalog", "finishing accepted operation", null); + } + finally + { + runtime.Release.TrySetResult(true); + } + + Assert.True(await activation); + await retiringHost.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Throws(() => logs.AppendLog("Info", "late", "late", null)); + Assert.Throws(() => profiles.GetProfiles()); + await using LogStorageService reopened = LogStorageServiceFactory.CreateForDirectory(root, () => settings.ActiveProfileId); + Assert.Equal(2, reopened.GetRecentLogs(10).Count); + Assert.Equal(ProfileCatalogIds.BuiltInDirect, settings.ActiveProfileId); + } + finally + { + runtime.Release.TrySetResult(true); + await host.DisposeAsync(); + if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } + } + } + + private sealed class Startup(ProfileCatalogService profiles) : IApplicationStartupCoordinator + { + public Task StartAsync(AppLaunchRequest request, CancellationToken cancellationToken) + { + _ = profiles.GetProfiles(); + return Task.FromResult(StartupStepResult.Succeeded()); + } + } + + private sealed class Settings : IProfileCatalogSettings, IProfileCatalogAdmittedSettings + { + public string ActiveProfileId { get; set; } = ProfileCatalogIds.BuiltInDirect; + + public void SetActiveProfileAdmitted(MutationAdmissionLease admissionLease, string profileId) => ActiveProfileId = profileId; + } + + private sealed class Log(LogStorageService logs) : IProfileCatalogLog + { + public void AppendLog(string level, string category, string message, string? detail) => logs.AppendLog(level, category, message, detail); + } + + private sealed class Runtime : IProfileCatalogRuntime + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task ApplyProfileAsync(string profileId, CancellationToken cancellationToken) + { + Entered.TrySetResult(); + return Release.Task; + } + + public Task ImportAndApplyProfileAsync(string profileId, string profileName, string configurationText, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task DeleteImportedProfileAsync(string profileId, CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private sealed class Configuration : IProfileCatalogCoreConfiguration + { + public Task ImportProfileConfigurationAsync(string profileId, string profileName, string configurationText, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ReadImportedProfileConfigurationAsync(string profileId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public CoreConfigurationState EnsureDefaultConfiguration() => throw new NotSupportedException(); + public Task ValidateImportedProfileAsync(string profileId, CancellationToken cancellationToken) => throw new NotSupportedException(); + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/LogStorageServiceTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/LogStorageServiceTests.cs index 6167289..946e78d 100644 --- a/ClashSharp/ClashSharp.Tests/Unit/Services/LogStorageServiceTests.cs +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/LogStorageServiceTests.cs @@ -8,6 +8,81 @@ namespace ClashSharp.Tests.Unit.Services; /// Unit tests for SQLite log storage behavior. public sealed class LogStorageServiceTests { + [Fact] + public async Task DisposeAsync_DrainsAcceptedSnapshotAndRejectsLateStorageWork() + { + using TempDatabase tempDatabase = new(); + using ManualResetEventSlim release = new(); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + LogStorageService service = new(tempDatabase.Path, () => "profile-a"); + Task write = Task.Run(() => service.AppendConnectionSnapshot(WaitForRelease())); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Task retirement = service.DisposeAsync().AsTask(); + try + { + Assert.False(retirement.IsCompleted); + Assert.Throws(() => service.AppendLog("Info", "late", "late", null)); + Assert.Throws(() => service.GetStorageSummary()); + Assert.Throws(service.ResetAfterDataDeletion); + } + finally + { + release.Set(); + } + + Assert.Equal(1, await write); + await retirement.WaitAsync(TimeSpan.FromSeconds(5)); + await service.DisposeAsync(); + await using LogStorageService reopened = new(tempDatabase.Path, () => "profile-a"); + Assert.Equal(1, reopened.GetTrafficStatisticsSummary().ConnectionCount); + + IEnumerable WaitForRelease() + { + entered.SetResult(); + if (!release.Wait(TimeSpan.FromSeconds(10))) { throw new TimeoutException("The test did not release the accepted snapshot."); } + yield return new ActiveConnection("1", "app", "example.test", "MATCH", string.Empty, "DIRECT", 10, 20, DateTimeOffset.UtcNow); + } + } + + [Fact] + public async Task DisposeAsync_ReleasesOnlyOwnedDatabaseHandlesAndPreservesCapturedLogs() + { + using TempDatabase tempDatabase = new(); + LogStorageService service = new(tempDatabase.Path, () => "profile-a"); + service.AppendLog("Info", "Before", "saved", null); + IReadOnlyList captured = service.GetRecentLogs(1); + await service.DisposeAsync(); + + using (FileStream exclusive = new(tempDatabase.Path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + Assert.True(exclusive.Length > 0); + } + + Assert.Equal("saved", Assert.Single(captured).Message); + string destination = Path.Combine(Path.GetDirectoryName(tempDatabase.Path)!, "new-directory", "export.sqlite3"); + Assert.Throws(() => service.ExportDatabase(destination)); + Assert.False(Directory.Exists(Path.GetDirectoryName(destination))); + } + + [Fact] + public async Task DirectoryFactory_IsPureAndRetiredInstanceCannotChangeEitherGeneration() + { + using TempDatabase tempDatabase = new(); + string root = Path.GetDirectoryName(tempDatabase.Path)!; + string first = Path.Combine(root, "first"); + string second = Path.Combine(root, "second"); + LogStorageService old = LogStorageServiceFactory.CreateForDirectory(first, () => "a"); + Assert.False(Directory.Exists(first)); + old.AppendLog("Info", "Old", "old", null); + await old.DisposeAsync(); + await using LogStorageService replacement = LogStorageServiceFactory.CreateForDirectory(second, () => "b"); + replacement.AppendLog("Info", "New", "new", null); + Assert.Throws(old.ClearAll); + await using LogStorageService reopened = LogStorageServiceFactory.CreateForDirectory(first, () => "a"); + Assert.Equal("old", Assert.Single(reopened.GetRecentLogs(1)).Message); + Assert.Equal("new", Assert.Single(replacement.GetRecentLogs(1)).Message); + } + [Fact] public void AppendLog_RedactsAndBoundsEveryFieldBeforePersistence() { diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/NetworkSettingsRuntimeTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/NetworkSettingsRuntimeTests.cs new file mode 100644 index 0000000..1e0a864 --- /dev/null +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/NetworkSettingsRuntimeTests.cs @@ -0,0 +1,445 @@ +extern alias ClashSharpUi; + +using System.Globalization; +using ClashSharp.Model; +using ClashSharp.Tests.Integration; +using Host = ClashSharpUi::ClashSharp; +using NetworkSettingsConfiguration = ClashSharpUi::ClashSharp.Hosting.Settings.NetworkSettingsConfiguration; +using RuntimeConfigurationActivationPlan = ClashSharpUi::ClashSharp.Service.RuntimeConfigurationActivationPlan; +using RuntimeConfigurationIntegrityObservation = ClashSharpUi::ClashSharp.Service.RuntimeConfigurationIntegrityObservation; + +namespace ClashSharp.Tests.Unit.Services; + +/// Verifies actual production network observation and transaction code with isolated operating-system ports. +public sealed class NetworkSettingsRuntimeTests +{ + [Fact] + public async Task EmptyRuntime_RequiresReleasedOwnershipAndAllowsUnownedProxyWithoutCreatingStorage() + { + await using DataGenerationTestDirectory directory = new(); + NativePorts ports = new(); + Host.Service.CoreConfigurationService configuration = CreateConfiguration(directory.RootPath); + NetworkSettingsConfiguration observed = await ports.Takeover.ObserveNetworkSettingsAsync( + configuration.ObserveRuntimeConfigurationIntegrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None); + Assert.Equal(new NetworkSettingsConfiguration(ClashSharpMode.Disabled, "builtin-direct", false, 10000), observed); + Assert.False(Directory.Exists(directory.RootPath)); + Assert.Equal(0, ports.Writes); + ports.Proxy = new(true, "other-proxy:8080"); + await Assert.ThrowsAsync(() => ports.Takeover.ObserveNetworkSettingsAsync( + configuration.ObserveRuntimeConfigurationIntegrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None)); + ports.Journal.Current = null; + Assert.Equal(observed, await ports.Takeover.ObserveNetworkSettingsAsync( + configuration.ObserveRuntimeConfigurationIntegrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None)); + Assert.True(ports.Proxy.IsEnabled); + Assert.Equal(0, ports.Writes); + } + + [Fact] + public async Task ExplicitProfileTransaction_DoesNotReadLegacySettingsAndExposesVerifiedGenerationHash() + { + await using DataGenerationTestDirectory directory = new(); + NativePorts ports = new(); + Host.Service.CoreConfigurationService configuration = CreateConfiguration(directory.RootPath); + NetworkSettingsConfiguration target = new(ClashSharpMode.RuleTakeover, "builtin-direct", false, 18301); + await ports.Takeover.ApplyNetworkSettingsConfigurationAsync(configuration, target, CancellationToken.None); + RuntimeConfigurationIntegrityObservation integrity = configuration.ObserveRuntimeConfigurationIntegrity(); + Assert.True(integrity.IsKnown); + Assert.Equal(1, integrity.AppliedGeneration); + Assert.NotNull(integrity.AppliedContentHash); + Assert.Equal(64, integrity.AppliedContentHash.Length); + NetworkSettingsConfiguration observed = await ports.Takeover.ObserveNetworkSettingsAsync( + configuration.ObserveRuntimeConfigurationIntegrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None); + Assert.Equal(target, observed); + Assert.Equal(integrity.AppliedGeneration, ports.LastReadinessGeneration); + Assert.Equal(integrity.AppliedContentHash, ports.LastReadinessHash); + Assert.True(ports.CoreRunning); + Assert.Equal(new Host.Model.WindowsProxyState(true, "127.0.0.1:18301"), ports.Proxy); + } + + [Fact] + public async Task ModifiedConfigOrAppliedSnapshot_CannotProvideGenerationEvidence() + { + await using DataGenerationTestDirectory directory = new(); + NativePorts ports = new(); + Host.Service.CoreConfigurationService configuration = CreateConfiguration(directory.RootPath); + await ports.Takeover.ApplyNetworkSettingsConfigurationAsync(configuration, + new(ClashSharpMode.Disabled, "builtin-direct", false, 18302), CancellationToken.None); + string path = Path.Combine(directory.RootPath, "config.yaml"); + string original = await File.ReadAllTextAsync(path); + await File.AppendAllTextAsync(path, "\n# isolated tamper\n"); + RuntimeConfigurationIntegrityObservation invalid = configuration.ObserveRuntimeConfigurationIntegrity(); + Assert.False(invalid.IsKnown); + Assert.Null(invalid.AppliedGeneration); + Assert.Null(invalid.AppliedContentHash); + await Assert.ThrowsAsync(() => ports.Takeover.ObserveNetworkSettingsAsync( + configuration.ObserveRuntimeConfigurationIntegrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None)); + await File.WriteAllTextAsync(path, original); + Assert.True(configuration.ObserveRuntimeConfigurationIntegrity().IsKnown); + string snapshot = Assert.Single(Directory.GetFiles(Path.Combine(directory.RootPath, "runtime-generations"), "*.yaml")); + await File.AppendAllTextAsync(snapshot, "\n# isolated snapshot tamper\n"); + Assert.False(configuration.ObserveRuntimeConfigurationIntegrity().IsKnown); + } + + [Fact] + public async Task MissingTunService_FailsBeforeConfigurationOrProxyEffects() + { + await using DataGenerationTestDirectory directory = new(); + NativePorts ports = new(); + Host.Service.CoreConfigurationService configuration = CreateConfiguration(directory.RootPath); + await Assert.ThrowsAsync(() => ports.Takeover.ApplyNetworkSettingsConfigurationAsync(configuration, + new(ClashSharpMode.RuleTakeover, "builtin-direct", true, 18303), CancellationToken.None)); + Assert.False(Directory.Exists(directory.RootPath)); + Assert.Equal(0, ports.Writes); + Assert.False(ports.CoreRunning); + } + + [Theory] + [InlineData("unknown-service")] + [InlineData("missing-owner")] + [InlineData("unknown-app-owner")] + [InlineData("controller-mismatch")] + [InlineData("wrong-proxy-port")] + [InlineData("owner-lost-during-readiness")] + [InlineData("generation-changed")] + public async Task IndependentRuntimeMismatch_RefusesAppliedObservation(string failure) + { + NativePorts ports = new() { CoreRunning = true, Proxy = new(true, "127.0.0.1:18304") }; + RuntimeConfigurationIntegrityObservation integrity = new(true, + new(ClashSharpMode.RuleTakeover, false, 18304, "builtin-direct"), 7, new string('a', 64)); + switch (failure) + { + case "unknown-service": ports.ServiceStatus = Host.Model.MihomoServiceStatus.Unknown("isolated unknown"); break; + case "missing-owner": ports.CoreRunning = false; break; + case "unknown-app-owner": ports.OwnerKnown = false; break; + case "controller-mismatch": ports.Ready = false; break; + case "wrong-proxy-port": ports.Proxy = new(true, "127.0.0.1:9999"); break; + case "owner-lost-during-readiness": ports.OnReadiness = () => ports.CoreRunning = false; break; + case "generation-changed": ports.OnReadiness = () => integrity = integrity with { AppliedGeneration = 8 }; break; + } + await Assert.ThrowsAsync(() => ports.Takeover.ObserveNetworkSettingsAsync( + () => integrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None)); + Assert.Equal(0, ports.Writes); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ServiceTun_RequiresExactSessionGenerationAndNoAppOwner(bool staleGeneration) + { + NativePorts ports = new(); + RuntimeConfigurationIntegrityObservation integrity = new(true, + new(ClashSharpMode.FullTakeover, true, 18305, "builtin-direct"), 9, new string('b', 64)); + ports.ServiceStatus = new(true, true, "isolated ready") + { + ServiceSessionId = Guid.NewGuid(), + ActiveGeneration = staleGeneration ? 8 : 9, + ActiveConfigurationHash = integrity.AppliedContentHash, + }; + if (staleGeneration) + { + await Assert.ThrowsAsync(() => ports.Takeover.ObserveNetworkSettingsAsync( + () => integrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None)); + } + else + { + NetworkSettingsConfiguration result = await ports.Takeover.ObserveNetworkSettingsAsync(() => integrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None); + Assert.True(result.EffectiveTunEnabled); + ports.CoreRunning = true; + await Assert.ThrowsAsync(() => ports.Takeover.ObserveNetworkSettingsAsync( + () => integrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None)); + } + Assert.Equal(0, ports.Writes); + } + + [Theory] + [InlineData("bypass")] + [InlineData("pac")] + [InlineData("server-kind")] + [InlineData("journal-missing")] + [InlineData("journal-pending")] + [InlineData("journal-invalid")] + [InlineData("journal-unreadable")] + public async Task SystemProxyProbe_RejectsTupleOrOwnershipDriftWithoutRegistryWrites(string drift) + { + NativePorts ports = new() { CoreRunning = true }; + ports.WindowsProxy.EnableProxy("127.0.0.1:18312"); + RuntimeConfigurationIntegrityObservation integrity = new(true, + new(ClashSharpMode.RuleTakeover, false, 18312, "builtin-direct"), 11, new string('c', 64)); + Assert.Equal(18312, (await ports.Takeover.ObserveNetworkSettingsAsync( + () => integrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None)).MixedPort); + switch (drift) + { + case "bypass": ports.Registry.Current = ports.Registry.Current with { ProxyOverride = new(true, "*") }; break; + case "pac": ports.Registry.Current = ports.Registry.Current with { AutoConfigUrl = new(true, "https://external.example/pac") }; break; + case "server-kind": + ports.Registry.Current = ports.Registry.Current with + { ProxyServer = new(true, "127.0.0.1:18312", Host.Service.WindowsProxyStringKind.ExpandString) }; break; + case "journal-missing": ports.Journal.Current = null; break; + case "journal-pending": + ports.Journal.Current = ports.Journal.Current! with + { Phase = Host.Service.WindowsProxyMutationPhase.Applying, PendingApplied = ports.Registry.Current }; break; + case "journal-invalid": ports.Journal.Current = ports.Journal.Current! with { SchemaVersion = 999 }; break; + case "journal-unreadable": ports.Journal.Failure = new IOException("isolated journal read failure"); break; + } + int writes = ports.Registry.Writes; + Assert.True(ports.Proxy.IsEnabled); + Assert.Equal("127.0.0.1:18312", ports.Proxy.ProxyServer); + await Assert.ThrowsAnyAsync(() => ports.Takeover.ObserveNetworkSettingsAsync( + () => integrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None)); + Assert.Equal(writes, ports.Registry.Writes); + Assert.Equal(0, ports.Writes); + } + + [Theory] + [InlineData(ClashSharpMode.Disabled, false)] + [InlineData(ClashSharpMode.Standby, false)] + [InlineData(ClashSharpMode.FullTakeover, true)] + public async Task ReleasingOwnedProxy_RestoresAndObservesEnabledThirdPartyBaseline(ClashSharpMode mode, bool tun) + { + await using DataGenerationTestDirectory directory = new(); + NativePorts ports = new(); + Host.Service.WindowsProxyRegistrySnapshot baseline = new(new(true, 1), new(true, "corporate.example:8080"), + new(true, "intranet.example"), new(true, "https://corporate.example/pac")); + ports.Registry.Current = baseline; + ports.ServiceStatus = new(true, false, "isolated installed"); + Host.Service.CoreConfigurationService configuration = CreateConfiguration(directory.RootPath); + await ports.Takeover.ApplyNetworkSettingsConfigurationAsync(configuration, + new(ClashSharpMode.RuleTakeover, "builtin-direct", false, 18313), CancellationToken.None); + Assert.NotNull(ports.Journal.Current); + NetworkSettingsConfiguration target = new(mode, "builtin-direct", tun, 18313); + await ports.Takeover.ApplyNetworkSettingsConfigurationAsync(configuration, target, CancellationToken.None); + Assert.Equal(baseline, ports.Registry.Current); + Assert.Null(ports.Journal.Current); + int writes = ports.Registry.Writes; + Assert.Equal(target, await ports.Takeover.ObserveNetworkSettingsAsync( + configuration.ObserveRuntimeConfigurationIntegrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None)); + Assert.Equal(writes, ports.Registry.Writes); + Assert.True(ports.Proxy.IsEnabled); + } + + [Fact] + public async Task ServiceSessionChangedDuringReadiness_RejectsEvenWithSameGenerationAndHash() + { + NativePorts ports = new(); + RuntimeConfigurationIntegrityObservation integrity = new(true, + new(ClashSharpMode.FullTakeover, true, 18314, "builtin-direct"), 12, new string('d', 64)); + ports.ServiceStatus = new(true, true, "isolated ready") + { ServiceSessionId = Guid.NewGuid(), ActiveGeneration = 12, ActiveConfigurationHash = integrity.AppliedContentHash }; + ports.OnReadiness = () => ports.ServiceStatus = ports.ServiceStatus with { ServiceSessionId = Guid.NewGuid() }; + await Assert.ThrowsAsync(() => ports.Takeover.ObserveNetworkSettingsAsync( + () => integrity, ports.WindowsProxy.ObserveOwnership, CancellationToken.None)); + Assert.Equal(0, ports.Writes); + } + + [Fact] + public async Task JournalPathOccupiedByDirectory_IsUnknownOwnershipInsteadOfReleased() + { + await using DataGenerationTestDirectory directory = new(); + Directory.CreateDirectory(directory.RootPath); + ProxyRegistry registry = new(); + Host.Service.WindowsProxyService proxy = new(registry, + new Host.Service.WindowsProxyMutationJournalFileStore(directory.RootPath)); + Assert.Throws(() => proxy.ObserveOwnership()); + Assert.Equal(0, registry.Writes); + } + + [Fact] + public async Task AbsentJournalAndParentDirectory_CanProveNoOwnershipWithoutCreatingStorage() + { + await using DataGenerationTestDirectory directory = new(); + ProxyRegistry registry = new(); + Host.Service.WindowsProxyService proxy = new(registry, + new Host.Service.WindowsProxyMutationJournalFileStore(Path.Combine(directory.RootPath, "journal.json"))); + Assert.True(proxy.ObserveOwnership().HasReleasedOwnership); + Assert.False(Directory.Exists(directory.RootPath)); + Assert.Equal(0, registry.Writes); + } + + [Theory] + [InlineData("validation")] + [InlineData("activation")] + [InlineData("readiness")] + [InlineData("commit")] + public async Task FatalRuntimeGraph_PreservesOriginalAndDurableDesiredWithoutCompensation(string stage) + { + await using DataGenerationTestDirectory directory = new(); + Validator validator = new(); + Host.Service.CoreConfigurationService configuration = CreateConfiguration(directory.RootPath, validator); + FaultRuntime runtime = new(); + await configuration.ApplyRuntimeConfigurationAsync("builtin-direct", ClashSharpMode.Standby, false, 18306, runtime, CancellationToken.None); + Exception fatal = new OperationCanceledException("isolated fatal wrapper", Activator.CreateInstance()); + if (stage == "validation") { validator.Failure = fatal; } + else { runtime.Failure = fatal; runtime.Stage = stage; } + int appliedBefore = runtime.Applies; + Assert.Same(fatal, await Assert.ThrowsAsync(() => configuration.ApplyRuntimeConfigurationAsync( + "builtin-direct", ClashSharpMode.Standby, false, 18307, runtime, CancellationToken.None))); + Assert.Equal(appliedBefore + (stage == "validation" ? 0 : 1), runtime.Applies); + Assert.Equal(1, runtime.Deactivations); + Host.Service.RuntimeConfigurationGenerationState retained = await configuration.GetRuntimeGenerationStateAsync(CancellationToken.None); + Assert.Equal(1, retained.AppliedGeneration); + Assert.Equal(2, retained.DesiredGeneration); + Assert.False(configuration.ObserveRuntimeConfigurationIntegrity().IsKnown); + if (stage == "validation") { Assert.Single(Directory.GetFiles(directory.RootPath, "config.yaml.runtime-staging.*")); } + } + + [Fact] + public async Task FatalRollbackGraph_EscapesInsteadOfReturningRecoverableFailure() + { + await using DataGenerationTestDirectory directory = new(); + Host.Service.CoreConfigurationService configuration = CreateConfiguration(directory.RootPath); + FaultRuntime runtime = new(); + await configuration.ApplyRuntimeConfigurationAsync("builtin-direct", ClashSharpMode.Standby, false, 18308, runtime, CancellationToken.None); + Exception fatal = new AggregateException(Activator.CreateInstance()); + runtime.Stage = "activation"; + runtime.Failure = new IOException("isolated activation failure"); + runtime.RollbackFailure = fatal; + Assert.Same(fatal, await Assert.ThrowsAsync(() => configuration.ApplyRuntimeConfigurationAsync( + "builtin-direct", ClashSharpMode.Standby, false, 18309, runtime, CancellationToken.None))); + Assert.Equal(3, runtime.Applies); + Assert.Equal(1, runtime.Deactivations); + Assert.False(configuration.ObserveRuntimeConfigurationIntegrity().IsKnown); + } + + [Fact] + public async Task FatalControllerGraph_IsNotRetriedByReadinessLoop() + { + await using DataGenerationTestDirectory directory = new(); + NativePorts ports = new(); + Host.Service.CoreConfigurationService configuration = CreateConfiguration(directory.RootPath); + await ports.Takeover.ApplyNetworkSettingsConfigurationAsync(configuration, + new(ClashSharpMode.Standby, "builtin-direct", false, 18310), CancellationToken.None); + Exception fatal = new InvalidOperationException("isolated controller wrapper", Activator.CreateInstance()); + int readsBefore = ports.ReadinessCalls; + ports.ReadinessFailure = fatal; + Assert.Same(fatal, await Assert.ThrowsAsync(() => ports.Takeover.ApplyNetworkSettingsConfigurationAsync(configuration, + new(ClashSharpMode.Standby, "builtin-direct", false, 18311), CancellationToken.None))); + Assert.Equal(readsBefore + 1, ports.ReadinessCalls); + Assert.False(configuration.ObserveRuntimeConfigurationIntegrity().IsKnown); + } + + private static Host.Service.CoreConfigurationService CreateConfiguration(string path, Validator? validator = null) => + new(path, new RejectLegacySettings(), new FixedControllerCredentialProvider(), new EmptyMetrics(), validator ?? new Validator(), key => key); + + private sealed class RejectLegacySettings : Host.Service.ICoreConfigurationSettings + { + public bool TransparentProxyEnabled => throw new InvalidOperationException("Legacy preferences must not be read."); + public int MixedPort => throw new InvalidOperationException("Legacy preferences must not be read."); + public string ActiveProfileId => throw new InvalidOperationException("Legacy preferences must not be read."); + } + private sealed class EmptyMetrics : Host.Service.ICoreConfigurationProfileMetrics + { + public int CountNodes(string configurationText) => 0; + public int CountRules(string configurationText) => 0; + } + private sealed class Validator : Host.Service.ICoreConfigurationValidator + { + public Exception? Failure { get; set; } + public Task ValidateAsync(string workingDirectory, string configurationPath, CancellationToken cancellationToken) => + Failure is null ? Task.CompletedTask : Task.FromException(Failure); + } + + private sealed class FaultRuntime : Host.Service.ICoreConfigurationRuntime + { + public int Applies { get; private set; } + public int Deactivations { get; private set; } + public string? Stage { get; set; } + public Exception? Failure { get; set; } + public Exception? RollbackFailure { get; set; } + public Task ApplyAsync(Host.Model.CoreConfigurationState configuration, long generation, + RuntimeConfigurationActivationPlan plan, CancellationToken cancellationToken) + { + ++Applies; + Exception? failure = generation == 1 && Applies > 1 ? RollbackFailure : Stage == "activation" ? Failure : null; + return failure is null ? Task.CompletedTask : Task.FromException(failure); + } + public Task WaitUntilReadyAsync(long generation, string configurationHash, RuntimeConfigurationActivationPlan plan, CancellationToken cancellationToken) => + Stage == "readiness" && Failure is not null ? Task.FromException(Failure) : Task.FromResult(true); + public Task CommitAsync(long generation, RuntimeConfigurationActivationPlan plan, CancellationToken cancellationToken) => + Stage == "commit" && Failure is not null ? Task.FromException(Failure) : Task.CompletedTask; + public Task DeactivateAsync(CancellationToken cancellationToken) { ++Deactivations; return Task.CompletedTask; } + } + + private sealed class NativePorts : Host.Service.INetworkTakeoverCoreConfiguration, Host.Service.INetworkTakeoverCore, + Host.Service.INetworkTakeoverWindowsProxy, Host.Service.INetworkTakeoverMihomoService, + Host.Service.INetworkTakeoverProxyRecovery, Host.Service.INetworkTakeoverReadiness + { + public NativePorts() + { + WindowsProxy = new(Registry, Journal); + Takeover = new(this, this, this, this, this, this, key => key); + } + public Host.Service.NetworkTakeoverService Takeover { get; } + public ProxyRegistry Registry { get; } = new(); + public ProxyJournal Journal { get; } = new(); + public Host.Service.WindowsProxyService WindowsProxy { get; } + public Host.Model.WindowsProxyState Proxy + { + get => WindowsProxy.GetCurrentState(); + set + { + Registry.Current = new(new(true, value.IsEnabled ? 1 : 0), new(true, value.ProxyServer ?? string.Empty), + new(true, ""), new(false, null, Host.Service.WindowsProxyStringKind.None)); + Journal.Current = value.IsEnabled ? new(Host.Service.WindowsProxyMutationJournal.CurrentSchemaVersion, + Registry.Current, Registry.Current) : null; + } + } + public Host.Model.MihomoServiceStatus ServiceStatus { get; set; } = new(false, false, "isolated missing"); + public bool CoreRunning { get; set; } + public bool OwnerKnown { get; set; } = true; + public bool Ready { get; set; } = true; + public int Writes { get; private set; } + public Action? OnReadiness { get; set; } + public long LastReadinessGeneration { get; private set; } + public string? LastReadinessHash { get; private set; } + public int ReadinessCalls { get; private set; } + public Exception? ReadinessFailure { get; set; } + bool Host.Service.INetworkTakeoverCore.IsRunning => CoreRunning; + bool Host.Service.INetworkTakeoverCore.IsOwnershipKnown => OwnerKnown; + public void Restart(Host.Model.CoreConfigurationState configurationState) { ++Writes; CoreRunning = true; } + public void Stop() { ++Writes; CoreRunning = false; } + public void DisableProxy() { ++Writes; WindowsProxy.DisableProxy(); } + public void EnableProxy(string proxyServer) { ++Writes; WindowsProxy.EnableProxy(proxyServer); } + public string BuildLoopbackProxyServer(int mixedPort) => "127.0.0.1:" + mixedPort.ToString(CultureInfo.InvariantCulture); + public Task GetStatusAsync(CancellationToken cancellationToken) => Task.FromResult(ServiceStatus); + public Task StopAsync(CancellationToken cancellationToken) + { ++Writes; ServiceStatus = new(ServiceStatus.IsInstalled, false, "isolated stopped"); return Task.FromResult(ServiceStatus); } + public Task RestartAsync(long generation, string configurationHash, CancellationToken cancellationToken) + { + ++Writes; + ServiceStatus = new(true, true, "isolated running") + { ServiceSessionId = Guid.NewGuid(), ActiveGeneration = generation, ActiveConfigurationHash = configurationHash }; + return Task.FromResult(ServiceStatus); + } + public Task MatchesRuntimeConfigurationAsync(RuntimeConfigurationActivationPlan plan, long generation, string configurationHash, + Host.Model.MihomoServiceStatus observedServiceStatus, CancellationToken cancellationToken) + { + ++ReadinessCalls; + LastReadinessGeneration = generation; + LastReadinessHash = configurationHash; + OnReadiness?.Invoke(); + return ReadinessFailure is null ? Task.FromResult(Ready) : Task.FromException(ReadinessFailure); + } + public Task ApplyConfigurationAsync(ClashSharpMode mode, bool transparentProxyEnabled, + int mixedPort, Host.Service.ICoreConfigurationRuntime runtime, CancellationToken cancellationToken) => + throw new InvalidOperationException("The explicit settings path must not use legacy configuration selection."); + } + + private sealed class ProxyRegistry : Host.Service.IWindowsProxyRegistryStore + { + public Host.Service.WindowsProxyRegistrySnapshot Current { get; set; } = new(new(true, 0), + new(false, null, Host.Service.WindowsProxyStringKind.None), new(false, null, Host.Service.WindowsProxyStringKind.None), + new(false, null, Host.Service.WindowsProxyStringKind.None)); + public int Writes { get; private set; } + public Host.Service.WindowsProxyRegistrySnapshot Read() => Current; + public void Write(Host.Service.WindowsProxyRegistrySnapshot snapshot) { ++Writes; Current = snapshot; } + } + + private sealed class ProxyJournal : Host.Service.IWindowsProxyMutationJournalStore + { + public Host.Service.WindowsProxyMutationJournal? Current { get; set; } + public Exception? Failure { get; set; } + public Host.Service.WindowsProxyMutationJournal? Read() => Failure is null ? Current : throw Failure; + public void Write(Host.Service.WindowsProxyMutationJournal journal) => Current = journal; + public void Clear() => Current = null; + } +} diff --git a/ClashSharp/ClashSharp.Tests/Unit/Services/ProfileCatalogServiceTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Services/ProfileCatalogServiceTests.cs index 0026248..cbe1525 100644 --- a/ClashSharp/ClashSharp.Tests/Unit/Services/ProfileCatalogServiceTests.cs +++ b/ClashSharp/ClashSharp.Tests/Unit/Services/ProfileCatalogServiceTests.cs @@ -7,6 +7,137 @@ namespace ClashSharp.Tests.Unit.Services; /// Unit tests for profile catalog composition. public sealed class ProfileCatalogServiceTests { + [Fact] + public async Task DisposeAsync_DrainsActivationBeforeRetiringAndPreservesCapturedProfiles() + { + using TempFile tempFile = new(); + FakeProfileCatalogSettings settings = new(); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + FakeProfileCatalogRuntime runtime = new() + { + ApplyAsync = () => { entered.TrySetResult(); return release.Task; }, + }; + ProfileCatalogService service = CreateService(tempFile.Path, settings, runtime: runtime); + IReadOnlyList snapshot = service.GetProfiles(); + Task activation = service.TryApplyActiveProfileAsync(ProfileCatalogIds.BuiltInDirect, CancellationToken.None); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Task retirement = service.DisposeAsync().AsTask(); + try + { + Assert.False(retirement.IsCompleted); + await Assert.ThrowsAsync(() => service.AddSubscriptionLinkAsync("late", "https://example.test/late", CancellationToken.None)); + Assert.Throws(() => service.GetProfiles()); + Assert.Throws(service.ResetAfterDataDeletion); + } + finally + { + release.TrySetResult(true); + } + + Assert.True(await activation); + await retirement.WaitAsync(TimeSpan.FromSeconds(5)); + await service.DisposeAsync(); + Assert.Equal(ProfileCatalogIds.BuiltInDirect, settings.ActiveProfileId); + Assert.Equal(ProfileCatalogIds.BuiltInDirect, Assert.Single(snapshot).Id); + } + + [Fact] + public async Task DisposeAsync_WaitsForRuntimeCompensationAfterPointerFailure() + { + using TempFile tempFile = new(); + FakeProfileCatalogSettings settings = new() { FailNextValue = ProfileCatalogIds.BuiltInDirect }; + TaskCompletionSource compensating = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + int attempts = 0; + FakeProfileCatalogRuntime runtime = new() + { + ApplyAsync = () => + { + if (++attempts == 1) { return Task.FromResult(true); } + compensating.TrySetResult(); + return release.Task; + }, + }; + ProfileCatalogService service = CreateService(tempFile.Path, settings, runtime: runtime); + Task activation = service.TryApplyActiveProfileAsync(ProfileCatalogIds.BuiltInDirect, CancellationToken.None); + await compensating.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Task retirement = service.DisposeAsync().AsTask(); + try + { + Assert.False(retirement.IsCompleted); + } + finally + { + release.TrySetResult(true); + } + + await Assert.ThrowsAsync(() => activation); + await retirement.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(2, attempts); + Assert.Equal(ProfileCatalogIds.BuiltInDirect, settings.ActiveProfileId); + } + + [Fact] + public async Task DisposeAsync_OwnsQueuedMutationUntilItsDurableCommit() + { + using TempFile tempFile = new(); + MutationAdmissionBarrier admission = new(); + FairAsyncMutationGate gate = new(); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task blocker = gate.ExecuteAsync(Guid.NewGuid(), async (_, _) => + { + entered.SetResult(); + await release.Task; + return true; + }, CancellationToken.None); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + ProfileCatalogService service = CreateService(tempFile.Path, new FakeProfileCatalogSettings(), + coordinator: new ProfileCatalogMutationCoordinator(admission, gate)); + Task mutation = service.AddSubscriptionLinkAsync("accepted", "https://example.test/accepted", CancellationToken.None); + Task retirement = service.DisposeAsync().AsTask(); + try + { + Assert.False(mutation.IsCompleted); + Assert.False(retirement.IsCompleted); + } + finally + { + release.TrySetResult(); + } + + Assert.True(await blocker); + ProfileSubscriptionLink link = await mutation; + await retirement.WaitAsync(TimeSpan.FromSeconds(5)); + await using ProfileCatalogService reopened = CreateService(tempFile.Path, new FakeProfileCatalogSettings()); + Assert.Equal(link.Id, Assert.Single(reopened.GetSubscriptionLinks()).Id); + } + + [Fact] + public async Task DirectoryFactory_IsPureAndRetiredInstanceCannotWriteIntoReplacement() + { + using TempFile tempFile = new(); + string root = Path.GetDirectoryName(tempFile.Path)!; + string first = Path.Combine(root, "first"); + string second = Path.Combine(root, "second"); + ProfileCatalogService old = CreateForDirectory(first); + Assert.False(Directory.Exists(first)); + ProfileSubscriptionLink saved = await old.AddSubscriptionLinkAsync("old", "https://example.test/old", CancellationToken.None); + await old.DisposeAsync(); + await using ProfileCatalogService replacement = CreateForDirectory(second); + _ = await replacement.AddSubscriptionLinkAsync("new", "https://example.test/new", CancellationToken.None); + await Assert.ThrowsAsync(() => old.TryDeleteSubscriptionLinkAsync(saved.Id, CancellationToken.None)); + await using ProfileCatalogService reopened = CreateForDirectory(first); + Assert.Equal(saved.Id, Assert.Single(reopened.GetSubscriptionLinks()).Id); + Assert.Equal("new", Assert.Single(replacement.GetSubscriptionLinks()).Name); + + static ProfileCatalogService CreateForDirectory(string directory) => ProfileCatalogServiceFactory.CreateForDirectory( + directory, new FakeProfileCatalogSettings(), new FakeProfileCatalogCoreConfiguration(), + new FakeProfileCatalogRuntime(), new FakeProfileCatalogLog(), key => key, + UncoordinatedProfileCatalogMutationCoordinator.Instance); + } + [Fact] public async Task MutationCoordinator_QueuesBehindProcessWideFairGate() { @@ -448,7 +579,8 @@ private static ProfileCatalogService CreateService( string catalogPath, FakeProfileCatalogSettings settings, FakeProfileCatalogCoreConfiguration? core = null, - FakeProfileCatalogRuntime? runtime = null) + FakeProfileCatalogRuntime? runtime = null, + IProfileCatalogMutationCoordinator? coordinator = null) { return new ProfileCatalogService( catalogPath, @@ -463,7 +595,7 @@ private static ProfileCatalogService CreateService( "ProfileCatalog.Status.Available" => "localized available", _ => key, }, - UncoordinatedProfileCatalogMutationCoordinator.Instance); + coordinator ?? UncoordinatedProfileCatalogMutationCoordinator.Instance); } private sealed class FakeProfileCatalogSettings : IProfileCatalogSettings @@ -539,6 +671,8 @@ public void AppendLog(string level, string category, string message, string? det private sealed class FakeProfileCatalogRuntime : IProfileCatalogRuntime { + public Func>? ApplyAsync { get; init; } + public bool ApplyResult { get; set; } = true; public Exception? DeleteException { get; set; } @@ -550,7 +684,7 @@ private sealed class FakeProfileCatalogRuntime : IProfileCatalogRuntime public Task ApplyProfileAsync(string profileId, CancellationToken cancellationToken) { AppliedProfileIds.Add(profileId); - return Task.FromResult(ApplyResult); + return ApplyAsync?.Invoke() ?? Task.FromResult(ApplyResult); } public Task ImportAndApplyProfileAsync( diff --git a/ClashSharp/ClashSharp/AppHost/ClashSharpAppHostFactory.cs b/ClashSharp/ClashSharp/AppHost/ClashSharpAppHostFactory.cs index 5acb607..f1632e4 100644 --- a/ClashSharp/ClashSharp/AppHost/ClashSharpAppHostFactory.cs +++ b/ClashSharp/ClashSharp/AppHost/ClashSharpAppHostFactory.cs @@ -66,6 +66,9 @@ public static AppHost Build( services.AddSingleton(_ => ConnectionSamplingService.Instance); services.AddSingleton(provider => { + // Catalog compensation can append a log while disposal drains an active operation. + // Capture its dependency first so the host retires the catalog before log storage. + _ = provider.GetRequiredService(); LateBoundProfileCatalogMutationCoordinator.Instance.Configure( provider.GetRequiredService(), provider.GetRequiredService()); diff --git a/ClashSharp/ClashSharp/AppHost/Settings/INetworkSettingsRuntime.cs b/ClashSharp/ClashSharp/AppHost/Settings/INetworkSettingsRuntime.cs new file mode 100644 index 0000000..cbc5082 --- /dev/null +++ b/ClashSharp/ClashSharp/AppHost/Settings/INetworkSettingsRuntime.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ClashSharp.Model; +using ClashSharp.Settings; + +namespace ClashSharp.Hosting.Settings; + +/// Owns a complete installed network policy and independently observes the resulting runtime. +internal interface INetworkSettingsRuntime +{ + Task ReadConfigurationAsync(CancellationToken cancellationToken); + Task RecoverConfigurationAsync(CancellationToken cancellationToken); + Task ApplyConfigurationAsync(NetworkSettingsConfiguration configuration, CancellationToken cancellationToken); +} + +/// Separates the installed TUN preference from its mode-dependent effective routing state. +internal sealed record NetworkSettingsConfiguration +{ + public NetworkSettingsConfiguration(ClashSharpMode mode, string profileId, bool transparentProxyEnabled, int mixedPort) + { + if (!Enum.IsDefined(mode) || mode == ClashSharpMode.Faulted) { throw new ArgumentOutOfRangeException(nameof(mode)); } + ArgumentOutOfRangeException.ThrowIfLessThan(mixedPort, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(mixedPort, 65535); + SettingNormalizationResult normalized = SettingsRegistry.Default.Get(SettingsRegistry.Keys.ActiveProfileId.Value).NormalizeValue(profileId); + if (!normalized.IsSuccess || !StringComparer.Ordinal.Equals(normalized.Value!.Get(), profileId)) + { + throw new ArgumentException("The runtime profile identity must be canonical.", nameof(profileId)); + } + Mode = mode; + ProfileId = profileId; + TransparentProxyEnabled = transparentProxyEnabled; + MixedPort = mixedPort; + } + + public ClashSharpMode Mode { get; } + public string ProfileId { get; } + public bool TransparentProxyEnabled { get; } + public int MixedPort { get; } + public bool EffectiveTunEnabled => TransparentProxyEnabled && Mode is ClashSharpMode.RuleTakeover or ClashSharpMode.FullTakeover; +} diff --git a/ClashSharp/ClashSharp/AppHost/Settings/NetworkSettingsParticipant.cs b/ClashSharp/ClashSharp/AppHost/Settings/NetworkSettingsParticipant.cs new file mode 100644 index 0000000..a2aaae5 --- /dev/null +++ b/ClashSharp/ClashSharp/AppHost/Settings/NetworkSettingsParticipant.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Model; +using ClashSharp.Settings; + +namespace ClashSharp.Hosting.Settings; + +/// Applies explicit network batches under the settings authority's existing admission. +internal sealed class NetworkSettingsParticipant : ISettingsApplicationParticipant, IAsyncDisposable +{ + private readonly SettingsParticipantBinding _binding; + private readonly MutationAdmissionBarrier _admission; + private readonly INetworkSettingsRuntime _runtime; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly object _lifetimeGate = new(); + private int _retiring; + private Task? _retirement; + private Guid? _failedBatchId; + private readonly HashSet _failedAttempts = []; + + public NetworkSettingsParticipant(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, INetworkSettingsRuntime runtime) + { + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _binding = new(generation, admission, SettingApplicationKind.Network, SettingsRegistry.Keys.CurrentMode, + SettingsRegistry.Keys.ActiveProfileId, SettingsRegistry.Keys.TransparentProxyEnabled, SettingsRegistry.Keys.MixedPort); + } + + public SettingApplicationKind ApplicationKind => SettingApplicationKind.Network; + + public async Task ProbeAsync(SettingsApplicationRequest request, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + Validate(request, admissionLease); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + Validate(request, admissionLease); + if (_failedBatchId == request.Batch.BatchId && _failedAttempts.Add(request.Batch.AttemptId)) + { + // Retry retains the batch identity and explicitly installs a new attempt. + // A final probe for the failed attempt must never unlock a partial mutation. + await _runtime.RecoverConfigurationAsync(cancellationToken).ConfigureAwait(false); + } + NetworkSettingsConfiguration observed = await _runtime.ReadConfigurationAsync(cancellationToken).ConfigureAwait(false); + return _binding.Observe(request, key => Read(key, observed)); + } + catch (Exception failure) when (!ExceptionGraphClassifier.IsProcessFatal(failure)) + { + // A new edit can replace the failed batch. Its first failed probe records + // identity only; the user's later explicit retry may recover the old runtime. + RecordFailedAttempt(request); + throw; + } + finally { _gate.Release(); } + } + + public async Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + Validate(request, admissionLease); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + Validate(request, admissionLease); + NetworkSettingsConfiguration before = await _runtime.ReadConfigurationAsync(cancellationToken).ConfigureAwait(false); + T Target(SettingKey key) where T : notnull => request.Values.TryGetValue(key, out SettingValue? value) + ? value.Get() : (T)Read(key, before); + NetworkSettingsConfiguration target = new(Target(SettingsRegistry.Keys.CurrentMode), + Target(SettingsRegistry.Keys.ActiveProfileId), Target(SettingsRegistry.Keys.TransparentProxyEnabled), + Target(SettingsRegistry.Keys.MixedPort)); + cancellationToken.ThrowIfCancellationRequested(); + // Once the complete native transaction starts, its rollback and verification belong + // to this owner. Neither page cancellation nor generation retirement can detach it. + try { await _runtime.ApplyConfigurationAsync(target, CancellationToken.None).ConfigureAwait(false); } + catch (Exception failure) when (!ExceptionGraphClassifier.IsProcessFatal(failure)) + { + RecordFailedAttempt(request); + throw; + } + } + finally { _gate.Release(); } + } + + public ValueTask DisposeAsync() + { + lock (_lifetimeGate) { return new(_retirement ??= RetireAsync()); } + } + + private async Task RetireAsync() + { + Volatile.Write(ref _retiring, 1); + await _gate.WaitAsync().ConfigureAwait(false); + _gate.Release(); + } + + private void RecordFailedAttempt(SettingsApplicationRequest request) + { + if (_failedBatchId != request.Batch.BatchId) { _failedAttempts.Clear(); } + _failedBatchId = request.Batch.BatchId; + _failedAttempts.Add(request.Batch.AttemptId); + } + + private void Validate(SettingsApplicationRequest request, MutationAdmissionLease lease) + { + _binding.Validate(request, lease); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _retiring) != 0, this); + if (request.Phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(lease); } + foreach ((SettingKey key, SettingValue value) in request.Values) + { + if (!value.Equals(SettingsRegistry.Default.Get(key.Value).Normalize(value.CanonicalText).Value)) + { + throw new InvalidOperationException("The network attempt contains a noncanonical setting."); + } + } + } + + private static object Read(SettingKey key, NetworkSettingsConfiguration configuration) => key == SettingsRegistry.Keys.CurrentMode + ? configuration.Mode : key == SettingsRegistry.Keys.ActiveProfileId ? configuration.ProfileId + : key == SettingsRegistry.Keys.TransparentProxyEnabled ? configuration.TransparentProxyEnabled : configuration.MixedPort; +} diff --git a/ClashSharp/ClashSharp/AppHost/Settings/NetworkSettingsRuntime.cs b/ClashSharp/ClashSharp/AppHost/Settings/NetworkSettingsRuntime.cs new file mode 100644 index 0000000..fd4ddcc --- /dev/null +++ b/ClashSharp/ClashSharp/AppHost/Settings/NetworkSettingsRuntime.cs @@ -0,0 +1,93 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.Model; +using ClashSharp.Service; + +namespace ClashSharp.Hosting.Settings; + +/// Installs network preferences only after the real configuration, owner, controller and proxy agree. +internal sealed class NetworkSettingsRuntime : INetworkSettingsRuntime +{ + private readonly Func> _observe; + private readonly Func _apply; + private bool _inactiveTransparentProxyPolicy; + private UnresolvedConfiguration? _unresolved; + + public NetworkSettingsRuntime(CoreConfigurationService configuration, NetworkTakeoverService takeover, WindowsProxyService proxy) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(takeover); + ArgumentNullException.ThrowIfNull(proxy); + _observe = token => takeover.ObserveNetworkSettingsAsync(configuration.ObserveRuntimeConfigurationIntegrity, proxy.ObserveOwnership, token); + _apply = (target, token) => takeover.ApplyNetworkSettingsConfigurationAsync(configuration, target, token); + } + + internal NetworkSettingsRuntime(Func> observe, + Func apply) + { + _observe = observe ?? throw new ArgumentNullException(nameof(observe)); + _apply = apply ?? throw new ArgumentNullException(nameof(apply)); + } + + public async Task ReadConfigurationAsync(CancellationToken cancellationToken) + { + if (_unresolved is not null) { throw new InvalidOperationException("The network transaction requires runtime recovery before another settings attempt."); } + NetworkSettingsConfiguration observed = await _observe(cancellationToken).ConfigureAwait(false); + // Disabled and Standby deliberately have no effective TUN. The preference is an + // installed strategy for the next takeover, published only after actual verification. + // In a takeover mode the preference is reported from effective routing, never Desired. + bool policy = observed.Mode is ClashSharpMode.Disabled or ClashSharpMode.Standby + ? _inactiveTransparentProxyPolicy : observed.TransparentProxyEnabled; + return new(observed.Mode, observed.ProfileId, policy, observed.MixedPort); + } + + /// Allows an admitted explicit retry only after the entire previous baseline or target is observed. + public async Task RecoverConfigurationAsync(CancellationToken cancellationToken) + { + if (_unresolved is not UnresolvedConfiguration unresolved) { return; } + NetworkSettingsConfiguration observed = await _observe(cancellationToken).ConfigureAwait(false); + // Prefer the baseline when effective routing is identical: an inactive preference + // has no external evidence until its installation transaction completes successfully. + NetworkSettingsConfiguration recovered = Matches(unresolved.Before, observed) ? unresolved.Before + : Matches(unresolved.Target, observed) ? unresolved.Target + : throw new InvalidOperationException("Neither complete network baseline nor complete target has recovered."); + _inactiveTransparentProxyPolicy = recovered.TransparentProxyEnabled; + _unresolved = null; + } + + public async Task ApplyConfigurationAsync(NetworkSettingsConfiguration configuration, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(configuration); + NetworkSettingsConfiguration before = await ReadConfigurationAsync(cancellationToken).ConfigureAwait(false); + try + { + await _apply(configuration, cancellationToken).ConfigureAwait(false); + if (!Matches(configuration, await _observe(cancellationToken).ConfigureAwait(false))) + { + throw new InvalidOperationException("The requested network configuration could not be independently verified."); + } + } + catch (Exception failure) when (!ExceptionGraphClassifier.IsProcessFatal(failure)) + { + NetworkSettingsConfiguration? after = null; + try { after = await _observe(CancellationToken.None).ConfigureAwait(false); } + catch (Exception probeFailure) when (!ExceptionGraphClassifier.IsProcessFatal(probeFailure)) { } + if (after is null || !Matches(configuration, after)) + { + // The existing runtime transaction owns compensation. A failed compensation + // must not clear a partial settings batch merely because one scalar matches. + _unresolved = after is null || !Matches(before, after) ? new(before, configuration) : null; + throw; + } + } + _inactiveTransparentProxyPolicy = configuration.TransparentProxyEnabled; + } + + private static bool Matches(NetworkSettingsConfiguration expected, NetworkSettingsConfiguration observed) => + expected.Mode == observed.Mode && StringComparer.Ordinal.Equals(expected.ProfileId, observed.ProfileId) + && expected.MixedPort == observed.MixedPort && expected.EffectiveTunEnabled == observed.TransparentProxyEnabled; + + private sealed record UnresolvedConfiguration(NetworkSettingsConfiguration Before, NetworkSettingsConfiguration Target); +} diff --git a/ClashSharp/ClashSharp/Service/LogStorageService.cs b/ClashSharp/ClashSharp/Service/LogStorageService.cs index 2e32154..f67d1ea 100644 --- a/ClashSharp/ClashSharp/Service/LogStorageService.cs +++ b/ClashSharp/ClashSharp/Service/LogStorageService.cs @@ -3,6 +3,8 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Data; using ClashSharp.Diagnostics; using ClashSharp.Model; using Microsoft.Data.Sqlite; @@ -20,8 +22,13 @@ namespace ClashSharp.Service; /// Thread safety: Public methods serialize database access through a private lock. /// Side effects: Creates and mutates a local SQLite database under the application data directory. /// -public sealed partial class LogStorageService +public sealed partial class LogStorageService : IAsyncDisposable { + private readonly RepositoryOperationLifetime _operations; + private readonly object _disposalLock = new(); + private readonly string _connectionString; + private Task? _disposeTask; + /// Synchronization object guarding all SQLite operations for this service lifetime. private readonly object _syncLock = new(); @@ -40,6 +47,29 @@ internal LogStorageService(string databasePath, Func getActiveProfileId) _databasePath = Path.GetFullPath(databasePath); _getActiveProfileId = getActiveProfileId ?? throw new ArgumentNullException(nameof(getActiveProfileId)); + _connectionString = new SqliteConnectionStringBuilder + { + DataSource = _databasePath, + Mode = SqliteOpenMode.ReadWriteCreate, + }.ToString(); + _operations = new RepositoryOperationLifetime(this); + } + + /// Rejects new storage work, drains accepted operations, and releases this database's pooled handles. + public ValueTask DisposeAsync() + { + lock (_disposalLock) + { + _disposeTask ??= DisposeCoreAsync(); + return new ValueTask(_disposeTask); + } + } + + private async Task DisposeCoreAsync() + { + await _operations.DisposeAsync().ConfigureAwait(false); + using SqliteConnection poolIdentity = new(_connectionString); + SqliteConnection.ClearPool(poolIdentity); } /// Gets the absolute SQLite database path used by this service. @@ -50,6 +80,7 @@ internal LogStorageService(string databasePath, Func getActiveProfileId) /// A snapshot for the current database state. public LogStorageSummary GetStorageSummary() { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { EnsureInitialized(); @@ -67,6 +98,7 @@ public LogStorageSummary GetStorageSummary() /// A snapshot for the current database state. public TrafficStatisticsSummary GetTrafficStatisticsSummary() { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { EnsureInitialized(); @@ -98,6 +130,7 @@ public TrafficStatisticsSummary GetTrafficStatisticsSummary() /// Total recent traffic bytes. public long GetTrafficBytesSince(DateTimeOffset cutoff) { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { EnsureInitialized(); @@ -117,6 +150,7 @@ public long GetTrafficBytesSince(DateTimeOffset cutoff) /// is less than or equal to zero. public IReadOnlyList GetProfileTrafficRows(int limit) { + using IDisposable operation = _operations.Enter(); if (limit <= 0) { throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be greater than zero."); @@ -145,6 +179,7 @@ ORDER BY (UploadBytes + DownloadBytes) DESC, UpdatedAtUnixTime DESC /// is less than or equal to zero. public IReadOnlyList GetDailyTrafficRows(int limit) { + using IDisposable operation = _operations.Enter(); if (limit <= 0) { throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be greater than zero."); @@ -179,6 +214,7 @@ ORDER BY UpdatedAtUnixTime DESC /// is less than or equal to zero. public IReadOnlyList GetNodeTrafficRows(int limit) { + using IDisposable operation = _operations.Enter(); if (limit <= 0) { throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be greater than zero."); @@ -209,6 +245,7 @@ ORDER BY (UploadBytes + DownloadBytes) DESC, UpdatedAtUnixTime DESC /// is whitespace. public void UpsertNodeHealth(string nodeName, string regionCode, int? latencyMilliseconds) { + using IDisposable operation = _operations.Enter(); ArgumentNullException.ThrowIfNull(nodeName); ArgumentNullException.ThrowIfNull(regionCode); @@ -261,6 +298,7 @@ ON CONFLICT(NodeName) DO UPDATE SET /// is null. public int? GetNodeLatencyMilliseconds(string nodeName) { + using IDisposable operation = _operations.Enter(); ArgumentNullException.ThrowIfNull(nodeName); if (string.IsNullOrWhiteSpace(nodeName)) @@ -291,6 +329,7 @@ FROM NodeHealthStats /// is null. public void EnsureRuleHitRows(IEnumerable rules) { + using IDisposable operation = _operations.Enter(); ArgumentNullException.ThrowIfNull(rules); lock (_syncLock) @@ -329,6 +368,7 @@ INSERT INTO RuleHitStats (RuleName, HitCount, UpdatedAtUnixTime) /// is less than or equal to zero. public void IncrementRuleHit(string ruleName, long increment = 1) { + using IDisposable operation = _operations.Enter(); ArgumentNullException.ThrowIfNull(ruleName); if (string.IsNullOrWhiteSpace(ruleName)) @@ -369,6 +409,7 @@ ON CONFLICT(RuleName) DO UPDATE SET /// is null. public int AppendConnectionSnapshot(IEnumerable connections) { + using IDisposable operation = _operations.Enter(); ArgumentNullException.ThrowIfNull(connections); IReadOnlyList snapshot = connections as IReadOnlyList @@ -434,6 +475,7 @@ INSERT INTO Connections (CreatedAtUnixTime, ProcessName, Host, RuleName, ProxyNa /// Dictionary of rule hit counts. public IReadOnlyDictionary GetRuleHitCounts() { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { EnsureInitialized(); @@ -462,6 +504,7 @@ public IReadOnlyDictionary GetRuleHitCounts() /// , , or is whitespace. public void AppendLog(string level, string source, string message, string? detail) { + using IDisposable operation = _operations.Enter(); ArgumentNullException.ThrowIfNull(level); ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(message); @@ -510,6 +553,7 @@ public void AppendLog(string level, string source, string message, string? detai /// is less than or equal to zero. public IReadOnlyList GetRecentLogs(int limit) { + using IDisposable operation = _operations.Enter(); return GetRecentLogs(limit, source: null); } @@ -521,6 +565,7 @@ public IReadOnlyList GetRecentLogs(int limit) /// is less than or equal to zero. public IReadOnlyList GetRecentLogs(string source, int limit) { + using IDisposable operation = _operations.Enter(); ArgumentException.ThrowIfNullOrWhiteSpace(source); return GetRecentLogs(limit, source.Trim()); } @@ -534,6 +579,7 @@ public IReadOnlyList GetRecentLogs(string source, int limit) /// is less than or equal to zero. public IReadOnlyList GetLogs(int limit, string? source = null, string? level = null, string? searchText = null) { + using IDisposable operation = _operations.Enter(); if (limit <= 0) { throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be greater than zero."); @@ -580,6 +626,7 @@ public IReadOnlyList GetLogs(int limit, string? source = null, string /// Returns a cleanup preview for logs matching optional level and source filters. public LogCleanupPreview PreviewLogCleanup(string? level = null, string? source = null) { + using IDisposable operation = _operations.Enter(); string? normalizedLevel = NormalizeOptionalFilter(level); string? normalizedSource = NormalizeOptionalFilter(source); @@ -602,6 +649,7 @@ public LogCleanupPreview PreviewLogCleanup(string? level = null, string? source /// Deletes logs matching optional level and source filters and compacts the database. public long CleanupLogs(string? level = null, string? source = null) { + using IDisposable operation = _operations.Enter(); string? normalizedLevel = NormalizeOptionalFilter(level); string? normalizedSource = NormalizeOptionalFilter(source); @@ -623,6 +671,7 @@ public long CleanupLogs(string? level = null, string? source = null) /// Distinct source names; never null. public IReadOnlyList GetLogSources() { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { EnsureInitialized(); @@ -650,6 +699,7 @@ public IReadOnlyList GetLogSources() /// is null, empty, or points to the live database. public void ExportDatabase(string destinationPath) { + using IDisposable operation = _operations.Enter(); ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); string fullDestinationPath = Path.GetFullPath(destinationPath); @@ -674,6 +724,7 @@ public void ExportDatabase(string destinationPath) { DataSource = fullDestinationPath, Mode = SqliteOpenMode.ReadWriteCreate, + Pooling = false, }; using SqliteConnection destinationConnection = new(destinationBuilder.ToString()); destinationConnection.Open(); @@ -779,6 +830,7 @@ private static void DeleteExistingDatabaseFiles(string databasePath) /// Exclusive upper bound for records to delete; must be a valid timestamp. public void CleanupBefore(DateTimeOffset cutoff) { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { EnsureInitialized(); @@ -802,6 +854,7 @@ public void CleanupBefore(DateTimeOffset cutoff) /// is negative. public void CleanupToSize(long targetSizeBytes) { + using IDisposable operation = _operations.Enter(); if (targetSizeBytes < 0) { throw new ArgumentOutOfRangeException(nameof(targetSizeBytes), "Target size must be zero or greater."); @@ -834,6 +887,7 @@ public void CleanupToSize(long targetSizeBytes) /// is negative. public void CleanupToLogCount(long maxLogCount) { + using IDisposable operation = _operations.Enter(); if (maxLogCount < 0) { throw new ArgumentOutOfRangeException(nameof(maxLogCount), "Maximum log count must be zero or greater."); @@ -856,6 +910,7 @@ public void CleanupToLogCount(long maxLogCount) /// Deletes all persistent log, connection, traffic, and rule-hit records and compacts the database. public void ClearAll() { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { EnsureInitialized(); @@ -877,6 +932,7 @@ public void ClearAll() /// Forgets schema initialization after the database file has been deleted externally. internal void ResetAfterDataDeletion() { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { _isInitialized = false; @@ -910,13 +966,7 @@ private void EnsureDatabaseDirectoryExists() /// An open instance owned by the caller. private SqliteConnection OpenConnection() { - SqliteConnectionStringBuilder builder = new() - { - DataSource = _databasePath, - Mode = SqliteOpenMode.ReadWriteCreate, - }; - - SqliteConnection connection = new(builder.ToString()); + SqliteConnection connection = new(_connectionString); connection.Open(); return connection; } diff --git a/ClashSharp/ClashSharp/Service/LogStorageServiceFactory.cs b/ClashSharp/ClashSharp/Service/LogStorageServiceFactory.cs index 09c8e33..a0393a2 100644 --- a/ClashSharp/ClashSharp/Service/LogStorageServiceFactory.cs +++ b/ClashSharp/ClashSharp/Service/LogStorageServiceFactory.cs @@ -1,3 +1,4 @@ +using System; using System.IO; namespace ClashSharp.Service; @@ -15,8 +16,22 @@ internal static class LogStorageServiceFactory /// Creates the default SQLite log storage service. public static LogStorageService CreateDefault() { - return new LogStorageService( - Path.Combine(AppDataPathService.ResolveLocalDataDirectory(), "ClashSharpLogs.sqlite3"), + return CreateForDirectory( + AppDataPathService.ResolveLocalDataDirectory(), () => AppSettingsService.Instance.ActiveProfileId); } + + /// Creates one database service for an explicit data root without opening or creating files. + public static LogStorageService CreateForDirectory(string dataDirectory, Func getActiveProfileId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory); + if (!Path.IsPathFullyQualified(dataDirectory)) + { + throw new ArgumentException("The log data directory must be absolute.", nameof(dataDirectory)); + } + + return new LogStorageService( + Path.Combine(Path.GetFullPath(dataDirectory), "ClashSharpLogs.sqlite3"), + getActiveProfileId); + } } diff --git a/ClashSharp/ClashSharp/Service/NetworkTakeoverService.NetworkSettings.cs b/ClashSharp/ClashSharp/Service/NetworkTakeoverService.NetworkSettings.cs new file mode 100644 index 0000000..19327b4 --- /dev/null +++ b/ClashSharp/ClashSharp/Service/NetworkTakeoverService.NetworkSettings.cs @@ -0,0 +1,94 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ClashSharp.Hosting.Settings; +using ClashSharp.Model; +using ClashSharp.Settings; + +namespace ClashSharp.Service; + +public sealed partial class NetworkTakeoverService +{ + /// Applies an explicit profile and effective TUN plan without legacy preference reads or fallback. + internal async Task ApplyNetworkSettingsConfigurationAsync(CoreConfigurationService configuration, + NetworkSettingsConfiguration target, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(target); + await _transitionGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (target.EffectiveTunEnabled) + { + MihomoServiceStatus status = await _mihomoService.GetStatusAsync(cancellationToken).ConfigureAwait(false); + if (!status.IsKnown || !status.IsInstalled) + { + throw new InvalidOperationException("The requested transparent proxy requires an available installed service."); + } + } + RuntimeConfigurationTransactionResult result = await configuration.ApplyRuntimeConfigurationAsync( + target.ProfileId, target.Mode, target.EffectiveTunEnabled, target.MixedPort, this, cancellationToken).ConfigureAwait(false); + if (!result.IsApplied) { throw CreateRuntimeTransactionFailure(result); } + } + finally { _transitionGate.Release(); } + } + + /// Observes external state without reading preferences, bootstrapping storage, or mutating network state. + internal async Task ObserveNetworkSettingsAsync( + Func readIntegrity, Func readProxy, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(readIntegrity); + ArgumentNullException.ThrowIfNull(readProxy); + await _transitionGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RuntimeConfigurationIntegrityObservation integrity = readIntegrity(); + if (!integrity.IsKnown) { throw new InvalidOperationException("The runtime configuration is not independently known."); } + MihomoServiceStatus service = await _mihomoService.GetStatusAsync(cancellationToken).ConfigureAwait(false); + RuntimeConfigurationActivationPlan? plan = integrity.AppliedPlan; + if (plan is null) + { + if (integrity.AppliedGeneration is not null || integrity.AppliedContentHash is not null + || !_core.IsOwnershipKnown || _core.IsRunning || !service.HasReleasedChildOwnership || !readProxy().HasReleasedOwnership + || readIntegrity() != integrity) + { + throw new InvalidOperationException("An empty runtime cannot claim a clean inactive network baseline."); + } + return new(ClashSharpMode.Disabled, SettingsRegistry.Default.Get(SettingsRegistry.Keys.ActiveProfileId.Value).DefaultValue.Get(), + false, SettingsRegistry.Default.Get(SettingsRegistry.Keys.MixedPort.Value).SafeFallback.Get()); + } + if (!NetworkSettingsOwnerMatches(integrity, service) || integrity.AppliedGeneration is not long generation || generation < 1 || integrity.AppliedContentHash is null + || plan.Mode != ClashSharpMode.Disabled && !await _readiness.MatchesRuntimeConfigurationAsync(plan, + generation, integrity.AppliedContentHash, service, cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException("The runtime owner or authenticated controller does not match the verified configuration."); + } + Guid? readinessSession = service.ServiceSessionId; + service = await _mihomoService.GetStatusAsync(cancellationToken).ConfigureAwait(false); + WindowsProxyOwnershipObservation proxy = readProxy(); + bool needsProxy = plan.Mode is ClashSharpMode.RuleTakeover or ClashSharpMode.FullTakeover && !plan.TunEnabled; + if (!NetworkSettingsOwnerMatches(integrity, service) || plan.TunEnabled && service.ServiceSessionId != readinessSession + || !(needsProxy ? proxy.MatchesOwnedProxy(_proxyRecovery.BuildLoopbackProxyServer(plan.MixedPort)) : proxy.HasReleasedOwnership) + || readIntegrity() != integrity) + { + throw new InvalidOperationException("The system proxy or configuration changed during network observation."); + } + return new(plan.Mode, plan.ProfileId, plan.TunEnabled, plan.MixedPort); + } + finally { _transitionGate.Release(); } + } + + private bool NetworkSettingsOwnerMatches(RuntimeConfigurationIntegrityObservation integrity, MihomoServiceStatus service) + { + RuntimeConfigurationActivationPlan plan = integrity.AppliedPlan!; + return plan.Mode == ClashSharpMode.Disabled + ? !_core.IsRunning && _core.IsOwnershipKnown && service.HasReleasedChildOwnership + : plan.TunEnabled + ? !_core.IsRunning && _core.IsOwnershipKnown && service.IsKnown && service.IsReady + && service.ServiceSessionId is Guid session && session != Guid.Empty + && service.ActiveGeneration == integrity.AppliedGeneration + && StringComparer.Ordinal.Equals(service.ActiveConfigurationHash, integrity.AppliedContentHash) + : _core.IsRunning && _core.IsOwnershipKnown && service.HasReleasedChildOwnership; + } +} diff --git a/ClashSharp/ClashSharp/Service/NetworkTakeoverService.cs b/ClashSharp/ClashSharp/Service/NetworkTakeoverService.cs index 8e4c8c3..68ebae8 100644 --- a/ClashSharp/ClashSharp/Service/NetworkTakeoverService.cs +++ b/ClashSharp/ClashSharp/Service/NetworkTakeoverService.cs @@ -8,6 +8,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Diagnostics; using ClashSharp.Model; using ClashSharp.ServiceProtocol; @@ -645,12 +646,12 @@ async Task ICoreConfigurationRuntime.WaitUntilReadyAsync( { throw; } - catch (OperationCanceledException) + catch (OperationCanceledException exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { // HttpClient can time out an individual readiness probe without // cancelling the transition's bounded readiness window. } - catch (Exception exception) when (exception is + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception) && exception is HttpRequestException or JsonException or IOException or diff --git a/ClashSharp/ClashSharp/Service/ProfileCatalogService.cs b/ClashSharp/ClashSharp/Service/ProfileCatalogService.cs index de63d0e..788158f 100644 --- a/ClashSharp/ClashSharp/Service/ProfileCatalogService.cs +++ b/ClashSharp/ClashSharp/Service/ProfileCatalogService.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using ClashSharp.ApplicationModel.Data; using ClashSharp.ApplicationModel.Diagnostics; using ClashSharp.ApplicationModel.Mutations; using ClashSharp.Model; @@ -93,8 +94,10 @@ internal readonly record struct ProfileCatalogFallbackStrings( /// Thread safety: Public members serialize mutable state through a private lock. /// Side effects: Reads and writes the local profile catalog JSON file; persists active profile selection to application settings. /// -public sealed partial class ProfileCatalogService +public sealed partial class ProfileCatalogService : IAsyncDisposable { + private readonly RepositoryOperationLifetime _operations; + /// Synchronization object guarding active profile mutations for this service lifetime. private readonly object _syncLock = new(); @@ -182,12 +185,17 @@ internal ProfileCatalogService( _getString = getString ?? throw new ArgumentNullException(nameof(getString)); _mutationCoordinator = mutationCoordinator ?? throw new ArgumentNullException(nameof(mutationCoordinator)); + _operations = new RepositoryOperationLifetime(this); } + /// Rejects new catalog work and waits for accepted imports, commits, and compensation to finish. + public ValueTask DisposeAsync() => _operations.DisposeAsync(); + /// Returns all known configuration profiles with active-profile state applied. /// A read-only snapshot of known configuration profiles. public IReadOnlyList GetProfiles() { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { ProfileCatalogDocument document = LoadDocument(); @@ -207,6 +215,7 @@ public IReadOnlyList GetProfiles() /// A read-only snapshot of known subscription links. public IReadOnlyList GetSubscriptionLinks() { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { return [.. LoadDocument().Links]; @@ -216,6 +225,7 @@ public IReadOnlyList GetSubscriptionLinks() /// Returns retained versions for one profile, newest first. public IReadOnlyList GetProfileHistory(string profileId) { + using IDisposable operation = _operations.Enter(); ArgumentException.ThrowIfNullOrWhiteSpace(profileId); lock (_syncLock) @@ -229,6 +239,7 @@ public IReadOnlyList GetProfileHistory(string profileId) /// Returns enabled subscription links whose update interval has elapsed. public IReadOnlyList GetDueSubscriptionLinks(DateTimeOffset now) { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { ProfileCatalogDocument document = LoadDocument(); @@ -262,13 +273,14 @@ private void RecordSubscriptionUpdateOutcome( } } - internal Task RecordSubscriptionUpdateOutcomeAsync( + internal async Task RecordSubscriptionUpdateOutcomeAsync( string linkId, bool succeeded, DateTimeOffset attemptedAt, CancellationToken cancellationToken) { - return _mutationCoordinator.ExecuteAsync( + using IDisposable operation = _operations.Enter(); + _ = await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (_, token) => { @@ -276,7 +288,7 @@ internal Task RecordSubscriptionUpdateOutcomeAsync( RecordSubscriptionUpdateOutcome(linkId, succeeded, attemptedAt); return Task.FromResult(true); }, - cancellationToken); + cancellationToken).ConfigureAwait(false); } /// @@ -288,6 +300,7 @@ internal Task RecordSubscriptionUpdateOutcomeAsync( /// internal ProfileCatalogSummary GetSummary(ProfileCatalogFallbackStrings fallbackStrings) { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { ProfileCatalogDocument document = LoadDocument(key => key switch @@ -354,19 +367,20 @@ private ProfileSubscriptionLink AddSubscriptionLinkCore(string name, string uri) } /// Adds a subscription link inside process-wide mutation admission. - public Task AddSubscriptionLinkAsync( + public async Task AddSubscriptionLinkAsync( string name, string uri, CancellationToken cancellationToken) { - return _mutationCoordinator.ExecuteAsync( + using IDisposable operation = _operations.Enter(); + return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (_, token) => { token.ThrowIfCancellationRequested(); return Task.FromResult(AddSubscriptionLinkCore(name, uri)); }, - cancellationToken); + cancellationToken).ConfigureAwait(false); } /// Updates editable subscription properties while retaining status and timestamps. @@ -446,6 +460,7 @@ internal async Task TryUpdateSubscriptionLinkAsync( int updateIntervalHours, CancellationToken cancellationToken) { + using IDisposable operation = _operations.Enter(); return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), async (_, token) => @@ -487,6 +502,7 @@ internal async Task TryDeleteSubscriptionLinkAsync( string linkId, CancellationToken cancellationToken) { + using IDisposable operation = _operations.Enter(); return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), async (_, token) => @@ -534,6 +550,7 @@ public async Task TryRenameProfileAsync( string name, CancellationToken cancellationToken) { + using IDisposable operation = _operations.Enter(); return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), async (_, token) => @@ -554,6 +571,7 @@ public async Task TryRenameProfileAsync( /// Deletes a user profile after moving any active runtime to the built-in profile. public async Task TryDeleteProfileAsync(string profileId, CancellationToken cancellationToken) { + using IDisposable operation = _operations.Enter(); return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (admissionLease, token) => TryDeleteProfileCoordinatedAsync( @@ -774,19 +792,20 @@ private bool TryUpdateSubscriptionLinkStatus(string linkId, string status) } } - internal Task TryUpdateSubscriptionLinkStatusAsync( + internal async Task TryUpdateSubscriptionLinkStatusAsync( string linkId, string status, CancellationToken cancellationToken) { - return _mutationCoordinator.ExecuteAsync( + using IDisposable operation = _operations.Enter(); + return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (_, token) => { token.ThrowIfCancellationRequested(); return Task.FromResult(TryUpdateSubscriptionLinkStatus(linkId, status)); }, - cancellationToken); + cancellationToken).ConfigureAwait(false); } /// Checks that a subscription link is reachable without importing it. @@ -796,6 +815,7 @@ internal Task TryUpdateSubscriptionLinkStatusAsync( /// The subscription endpoint cannot be reached successfully. public async Task CheckSubscriptionLinkAsync(ProfileSubscriptionLink link, CancellationToken cancellationToken) { + using IDisposable operation = _operations.Enter(); return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (_, token) => CheckSubscriptionLinkCoordinatedAsync(link, token), @@ -838,6 +858,7 @@ private async Task CheckSubscriptionLinkCoordinatedAsync( /// Configuration validation fails. public async Task ImportSubscriptionLinkAsync(ProfileSubscriptionLink link, CancellationToken cancellationToken) { + using IDisposable operation = _operations.Enter(); ProfileImportResult? result = await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (_, token) => ImportSubscriptionLinkCoordinatedAsync( @@ -851,19 +872,20 @@ public async Task ImportSubscriptionLinkAsync(ProfileSubscr } /// Imports a scheduler snapshot only if the same enabled revision is still due. - internal Task ImportDueSubscriptionLinkAsync( + internal async Task ImportDueSubscriptionLinkAsync( ProfileSubscriptionLink link, DateTimeOffset now, CancellationToken cancellationToken) { - return _mutationCoordinator.ExecuteAsync( + using IDisposable operation = _operations.Enter(); + return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (_, token) => ImportSubscriptionLinkCoordinatedAsync( link, requireDue: true, now: now, cancellationToken: token), - cancellationToken); + cancellationToken).ConfigureAwait(false); } private async Task ImportSubscriptionLinkCoordinatedAsync( @@ -1064,6 +1086,7 @@ private void TryRecordSubscriptionFailure( /// Configuration validation fails. public async Task ImportLocalProfileAsync(string filePath, CancellationToken cancellationToken) { + using IDisposable operation = _operations.Enter(); return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (_, token) => ImportLocalProfileCoordinatedAsync(filePath, token), @@ -1151,6 +1174,7 @@ public async Task RollbackProfileAsync( ProfileHistoryEntry historyEntry, CancellationToken cancellationToken) { + using IDisposable operation = _operations.Enter(); return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (_, token) => RollbackProfileCoordinatedAsync(historyEntry, token), @@ -1294,6 +1318,7 @@ await CompensateImportedConfigurationAsync( /// Configuration validation fails. public async Task ValidateProfileAsync(ConfigurationProfile profile, CancellationToken cancellationToken) { + using IDisposable operation = _operations.Enter(); return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (_, token) => ValidateProfileCoordinatedAsync(profile, token), @@ -1339,6 +1364,7 @@ public async Task TryApplyActiveProfileAsync( string profileId, CancellationToken cancellationToken) { + using IDisposable operation = _operations.Enter(); return await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), (admissionLease, token) => TryApplyActiveProfileCoordinatedAsync( @@ -1408,6 +1434,7 @@ private async Task TryApplyActiveProfileCoordinatedAsync( /// Forgets the cached catalog after local profile data has been deleted externally. internal void ResetAfterDataDeletion() { + using IDisposable operation = _operations.Enter(); lock (_syncLock) { _cachedDocument = null; @@ -1415,9 +1442,10 @@ internal void ResetAfterDataDeletion() } /// Retries durable post-delete source/history cleanup without reopening a committed delete. - internal Task RetryPendingProfileCleanupAsync(CancellationToken cancellationToken) + internal async Task RetryPendingProfileCleanupAsync(CancellationToken cancellationToken) { - return _mutationCoordinator.ExecuteAsync( + using IDisposable operation = _operations.Enter(); + _ = await _mutationCoordinator.ExecuteAsync( Guid.NewGuid(), async (_, token) => { @@ -1425,7 +1453,7 @@ internal Task RetryPendingProfileCleanupAsync(CancellationToken cancellationToke await RetryPendingProfileCleanupCoreAsync().ConfigureAwait(false); return true; }, - cancellationToken); + cancellationToken).ConfigureAwait(false); } private async Task RetryPendingProfileCleanupCoreAsync() diff --git a/ClashSharp/ClashSharp/Service/ProfileCatalogServiceFactory.cs b/ClashSharp/ClashSharp/Service/ProfileCatalogServiceFactory.cs index ec0fe81..e2c9967 100644 --- a/ClashSharp/ClashSharp/Service/ProfileCatalogServiceFactory.cs +++ b/ClashSharp/ClashSharp/Service/ProfileCatalogServiceFactory.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -19,9 +20,8 @@ internal static class ProfileCatalogServiceFactory /// Creates the default service used by profiles, links, statistics, and maintenance flows. public static ProfileCatalogService CreateDefault() { - return new ProfileCatalogService( - Path.Combine(AppDataPathService.ResolveLocalDataDirectory(), "ProfileCatalog.json"), - Path.Combine(AppDataPathService.ResolveLocalDataDirectory(), "mihomo", "history"), + return CreateForDirectory( + AppDataPathService.ResolveLocalDataDirectory(), new ProfileCatalogSettingsAdapter(AppSettingsService.Instance), new ProfileCatalogCoreConfigurationAdapter(CoreConfigurationService.Instance), new ProfileCatalogRuntimeAdapter( @@ -32,6 +32,34 @@ public static ProfileCatalogService CreateDefault() LocalizationService.Instance.GetString, LateBoundProfileCatalogMutationCoordinator.Instance); } + + /// Creates one catalog for an explicit data root with dependencies owned by the same host or generation. + public static ProfileCatalogService CreateForDirectory( + string dataDirectory, + IProfileCatalogSettings settings, + IProfileCatalogCoreConfiguration coreConfiguration, + IProfileCatalogRuntime runtime, + IProfileCatalogLog log, + Func getString, + IProfileCatalogMutationCoordinator mutationCoordinator) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory); + if (!Path.IsPathFullyQualified(dataDirectory)) + { + throw new ArgumentException("The catalog data directory must be absolute.", nameof(dataDirectory)); + } + + string root = Path.GetFullPath(dataDirectory); + return new ProfileCatalogService( + Path.Combine(root, "ProfileCatalog.json"), + Path.Combine(root, "mihomo", "history"), + settings, + coreConfiguration, + runtime, + log, + getString, + mutationCoordinator); + } } internal sealed class ProfileCatalogRuntimeAdapter( diff --git a/ClashSharp/ClashSharp/Service/WindowsProxyMutationJournal.cs b/ClashSharp/ClashSharp/Service/WindowsProxyMutationJournal.cs index f2a20ca..4ff1d28 100644 --- a/ClashSharp/ClashSharp/Service/WindowsProxyMutationJournal.cs +++ b/ClashSharp/ClashSharp/Service/WindowsProxyMutationJournal.cs @@ -140,7 +140,12 @@ public WindowsProxyMutationJournalFileStore(string journalPath) public WindowsProxyMutationJournal? Read() { - if (!File.Exists(_journalPath)) + string content; + try { content = File.ReadAllText(_journalPath); } + // Only native missing-path leaves prove absence. Wrapped failures, including + // fatal inner exceptions, propagate unchanged in every source-linked host. + catch (Exception failure) when ((failure is FileNotFoundException or DirectoryNotFoundException) + && failure.InnerException is null) { return null; } @@ -148,7 +153,7 @@ public WindowsProxyMutationJournalFileStore(string journalPath) try { WindowsProxyMutationJournal journal = JsonSerializer.Deserialize( - File.ReadAllText(_journalPath), + content, JsonOptions) ?? throw new InvalidDataException("Windows proxy journal is empty."); if (journal.SchemaVersion == WindowsProxyMutationJournal.LegacyAppliedOnlySchemaVersion) { diff --git a/ClashSharp/ClashSharp/Service/WindowsProxyService.cs b/ClashSharp/ClashSharp/Service/WindowsProxyService.cs index d8e57b7..e36d1db 100644 --- a/ClashSharp/ClashSharp/Service/WindowsProxyService.cs +++ b/ClashSharp/ClashSharp/Service/WindowsProxyService.cs @@ -61,6 +61,18 @@ public WindowsProxyState GetCurrentState() snapshot.ProxyServer.Value ?? string.Empty); } + /// Reads the complete effective tuple and durable ownership without restoring or claiming any fields. + internal WindowsProxyOwnershipObservation ObserveOwnership() + { + lock (_syncLock) + { + WindowsProxyRegistrySnapshot current = _registry.Read(); + WindowsProxyMutationJournal? journal = _mutationJournal.Read(); + journal?.Validate(); + return new(current, journal); + } + } + /// Enables Windows system proxy for the current user with . /// Proxy server string accepted by Windows, such as "127.0.0.1:7890"; must not be null or whitespace. /// is null. @@ -222,3 +234,21 @@ private static void NotifyProxySettingsChanged() [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] private static extern bool InternetSetOption(nint internet, int option, nint buffer, int bufferLength); } + +/// Independent WinINet tuple and journal evidence; a foreign proxy is not Clash# ownership. +internal sealed record WindowsProxyOwnershipObservation( + WindowsProxyRegistrySnapshot Current, WindowsProxyMutationJournal? Journal) +{ + // Releasing Clash# restores a possibly enabled third-party baseline. Requiring + // ProxyEnable=0 here would incorrectly claim ownership of that external proxy. + public bool HasReleasedOwnership => Journal is null; + + public bool MatchesOwnedProxy(string proxyServer) => Journal is + { Phase: WindowsProxyMutationPhase.Applied, PendingApplied: null } + && Current == Journal.Applied + && Current.ProxyEnable == new WindowsProxyDwordValue(true, 1) + && Current.ProxyServer.Exists && Current.ProxyServer.Kind == WindowsProxyStringKind.String + && StringComparer.OrdinalIgnoreCase.Equals(Current.ProxyServer.Value, proxyServer) + && Current.ProxyOverride == new WindowsProxyStringValue(true, "", WindowsProxyStringKind.String) + && Current.AutoConfigUrl == new WindowsProxyStringValue(false, null, WindowsProxyStringKind.None); +} From f3b668dcef3bd1e3ec0b73af0c6f89324bb3a52e Mon Sep 17 00:00:00 2001 From: Water-Run Date: Sat, 12 Sep 2026 18:01:18 +0800 Subject: [PATCH 15/22] test: locate installer source contracts in Git worktrees --- .../Unit/Resources/InstallerBuildScriptTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ClashSharp/ClashSharp.Tests/Unit/Resources/InstallerBuildScriptTests.cs b/ClashSharp/ClashSharp.Tests/Unit/Resources/InstallerBuildScriptTests.cs index 530695c..9d026e7 100644 --- a/ClashSharp/ClashSharp.Tests/Unit/Resources/InstallerBuildScriptTests.cs +++ b/ClashSharp/ClashSharp.Tests/Unit/Resources/InstallerBuildScriptTests.cs @@ -434,7 +434,8 @@ private static string FindRepositoryRoot() DirectoryInfo? directory = new(AppContext.BaseDirectory); while (directory is not null) { - if (Directory.Exists(Path.Combine(directory.FullName, ".git"))) + string gitPath = Path.Combine(directory.FullName, ".git"); + if (Directory.Exists(gitPath) || File.Exists(gitPath)) { return directory.FullName; } From e30c4700e3230a7421e5f7d62c0a2460c3fce1e9 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Sat, 12 Sep 2026 18:22:03 +0800 Subject: [PATCH 16/22] docs: record full server startup and installer acceptance --- .../2026-09-08-settings-generation-cutover.md | 8 +++- docs/reviews/1.0.0-execution-ledger.md | 9 ++++ docs/reviews/2026-09-12-server-acceptance.md | 48 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 docs/reviews/2026-09-12-server-acceptance.md diff --git a/docs/design/2026-09-08-settings-generation-cutover.md b/docs/design/2026-09-08-settings-generation-cutover.md index 77f0310..d1c78a9 100644 --- a/docs/design/2026-09-08-settings-generation-cutover.md +++ b/docs/design/2026-09-08-settings-generation-cutover.md @@ -1,6 +1,6 @@ # Settings generation cutover -版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口、内部设置运行快照,以及 Appearance、StartupTask、Sampling、Triggers 的服务适配器。控制端凭据已从偏好中拆分并接入生产启动、运行时和数据清理;生产偏好仍使用现有设置入口。页面写入、全部运行时参与者和 profile/log/trigger 仓库寿命需要一起接入后,才替换临时架构门禁并合入 main。 +版本保持 `1.0.0`。完整切换在 `feat/settings-generation` 分支和[草稿 PR #5](https://github.com/Water-Run/ClashSharp/pull/5) 推进,基础提交为 `e3f597c`。当前已实现迁移、异步设置会话、应用状态流转、代际内服务访问、公共异步入口、内部设置运行快照,以及 Appearance、StartupTask、Sampling、Triggers、Network 的服务适配器。控制端凭据已从偏好中拆分并接入生产启动、运行时和数据清理;生产偏好仍使用现有设置入口。Profile 和 Log 仓库已能退休并等待已接收操作结束,但完整代际容器及页面消费者尚未切换。整体接入后,才替换临时架构门禁并合入 main。 ## 已实现的存储与迁移 @@ -148,7 +148,11 @@ Appearance 参与者及 UI 操作所有者新增 26 项回归,本分支累计 ## 完整切换的剩余依赖 1. 将偏好写入统一为应用层异步 change set;页面、磁贴、触发器和网络提交者使用同一个接口。独立控制端凭据已接入生产调用,后续代际重置继续使用该能力。 -2. 完成 Network 的实际 apply/probe 适配器,并将已实现的 Appearance、Internal、StartupTask、Sampling、Triggers 一起装配;明确读取 desired、有效状态和待办的消费者,并接通外观变化后的页面刷新。 +2. 将已实现的 Network、Appearance、Internal、StartupTask、Sampling、Triggers 一起装配;先恢复启动时的运行时归属,再执行真实观察,不能用 desired 推定 applied。明确读取 desired、有效状态和待办的消费者,并接通外观变化后的页面刷新。 3. 在设置驱动的启动步骤之前完成旧事务恢复、代际打开和偏好迁移。profile/log/trigger 与 settings 必须由同一代际容器解析、排空和替换。 4. 将导入、重置和回滚接入候选代际及 manifest 提交,完成生产消费者替换后,原子替换 `SettingsAuthorityArchitectureTests` 中的临时门禁。 5. 运行新候选的 CI、打包应用及隔离 Windows 验收,再将完整节点推送 main。 + +2026-09-12 的 `4640685` 已补齐 Network 四键批次,观察实际配置 generation/hash、SCM 会话、认证控制端以及完整 Windows 代理状态和自有 journal;只有独立观察到上一状态或目标状态,才允许显式重试。Profile 和 Log 的每个已接受操作持有寿命租约到异步工作及补偿结束,退休排空后再释放存储。它们尚不构成生产 JSON 权威切换。 + +该源码的 CI 5012 项全部通过,完整服务器新包通过实际安装、启动日志及窗口、修复、WPF 窗口和卸载验证。实测修复了默认 DIRECT 循环及配置文件瞬时替换失败;这两项和安装器改进已独立移植到 main `db21085`。新包实测、源码分支和未完成项的准确边界见 [Windows 实机开发与验收记录](../reviews/2026-09-12-server-acceptance.md)。 diff --git a/docs/reviews/1.0.0-execution-ledger.md b/docs/reviews/1.0.0-execution-ledger.md index 8bc4373..1fab649 100644 --- a/docs/reviews/1.0.0-execution-ledger.md +++ b/docs/reviews/1.0.0-execution-ledger.md @@ -468,3 +468,12 @@ - 18 项目 Release x64 构建零警告/错误,本机安全子集 4370 项全部通过;另六项真实证书修改测试留在隔离环境。format 检查 1394 文件零处变更。 - Windows 11 客体通过 20 个独立进程、138 项断言,包括真实 MSIX 引用保留、组合安装/修复/卸载、四个进程退出点及重启恢复。十项客体清理检查和主机的会话销毁、输入不变、代理不变检查全部通过。 - 客体测试临时收紧并最终恢复卷根一条精确 ACL;生产策略未放宽。一次过宽的本机测试筛选误执行临时用户证书往返,已清理并记录,随后加入测试环境限制。详细证据和未覆盖边界见[机器证书信任](../design/2026-09-07-installer-machine-certificate-trust.md)。正式签名 WPF 安装及完整机器故障矩阵继续推进,1.0.0 生产执行门仍关闭。 + +## M5c Server Desktop 新包安装、首次启动、修复与卸载(2026-09-12) + +- 已有交互式桌面的 Windows Server 2025 x64 上,真实安装发现默认 `DIRECT` 组循环导致 WinUI 启动失败。删除自引用,六项默认配置通过实际 bundled core 校验;同时修复配置同目录提交的短暂 Windows 文件访问失败,保留原异常、取消和回滚语义。 +- WPF 安装器增加 Server Desktop 平台支持、受操作寿命管理的后台检查及即时取消反馈。Rust 安装器不在当前源码树中;实际候选继续使用自包含 WPF、认证 helper 和受签名保护的载荷。 +- 从准确 `4640685` 干净源码独立构建完整包,原生安装、修复、卸载全部成功;正常 WinUI 启动步骤 140、600、710 各成功一次、错误为零,主窗口稳定 30260 ms。新版 WPF 候选通过签名、时间戳及载荷校验,正常窗口稳定 30085 ms。 +- 开发分支本机 5006 项、隔离 CI 5012 项全部通过。安装器及启动修复独立推送 main `db21085`,本机 4734 项、隔离 CI 4740 项全部通过,两分支均完成 18 项目 Release 构建和格式检查。 +- 测试包、服务、进程、任务及临时 EXE 签名信任和私钥已清理,既有依赖及 MSIX 签名材料保留,本机与服务器代理不变。六层残留空目录由验收收尾手动验证归属后清除;安装器自动清理仍在独立开发,未计为产品能力。 +- 页面按钮、取消交互和正常退出尚未实机验收。生产偏好仍使用 LocalSettings,完整 generation 切换未完成;本轮候选是内部测试签名,不是正式发行包。准确包摘要、CI 链接与清理边界见[实机验收记录](2026-09-12-server-acceptance.md)。 diff --git a/docs/reviews/2026-09-12-server-acceptance.md b/docs/reviews/2026-09-12-server-acceptance.md new file mode 100644 index 0000000..2d3b1dc --- /dev/null +++ b/docs/reviews/2026-09-12-server-acceptance.md @@ -0,0 +1,48 @@ +# 2026-09-12 Windows 实机开发与验收记录 + +本记录区分已取得的运行证据、源码修复和待复验事项。目标版本为 **1.0.0**,当前尚不能认定完整开发或正式发行验收完成。后续状态同步到 [1.0.0 执行账本](1.0.0-execution-ledger.md)。 + +验收环境为 **Windows Server 2025 Desktop Experience x64**。基线源码为 `ea940a140775636ac78952a01ef6d7d866c391b0`;基线 MSIX 的 SHA-256 为 `ecc83c1d72bb1f98d65c8061b0f0993a7b23393fdcb5e73ecedfc34d6317ac50`。该包使用临时测试签名,供内部验收,不是正式发行包。来源为本地证据 `artifacts/verification/server-acceptance-initial-20260912.json`;公开记录不包含机器连接信息、用户路径或私钥材料。初始快照保留当时的清理待办,后续最终清理结果记录于下文新包验收收据。 + +| 项目 | 已取得证据 | 验收边界 | +|---|---|---| +| 安装、修复、卸载 | 真实 Windows native 安装引擎依次完成 install / repair / uninstall,均返回 Succeeded | 并非仅模拟平台测试;也不等同于完整 WPF 页面点击验收 | +| 卸载后资源 | 包注册、服务、产品运行文件及安装事务管理的证书均无残留;预装依赖保留,代理指纹前后相同 | 存在下述空目录残留;临时验收签名材料与产品卸载资源分开记录 | +| WPF 窗口 | 正常窗口启动并稳定运行 30 秒 | 尚未执行完整按钮点击、交互与取消流程的实机验收 | +| WinUI 首次启动 | 基线包在启动步骤 450 出现 Fatal,诊断为 `configuration.rejected` | 基线首次启动未通过 | +| 默认配置修复 | 原配置交给实际随包 core 执行 `-t` 返回 exit 1,报出 DIRECT 循环;修复后在远端使用完全相同的 core 执行 `-t` 返回 exit 0 | 后续完整新包的正常首次启动也已通过,见下文 `4640685` 验收 | + +首次启动的根因为 [MihomoRuntimeConfigurationBuilder](../../ClashSharp/ClashSharp/Service/MihomoRuntimeConfigurationBuilder.cs) 额外声明了名为 `DIRECT` 的 select group,并让该组引用 `DIRECT` 自身。修复删除这四行声明,继续使用 core 内建的 `DIRECT`。六项真实 core 默认配置校验回归已通过;独立 `-t` 与完整包启动分别验收,不能替代页面交互证据。 + +当前生产设置仍以 **LocalSettings 为唯一权威来源**。JSON generation、desired/applied 流程和 Network 等 participants 已有实现与隔离测试,但尚未完整接入生产主机。完成接线前仍需明确启动时运行时 owner 的恢复和 reconcile,不能把期望设置当成已应用状态。 + +第一轮主体测试共 **2876 项,2875 通过、1 项失败**。失败项为 `RuntimeConfigurationTransactionTests.ApplyRuntimeConfigurationAsync_RepeatedSuccess_RetainsOnlyBoundedVerifiedSnapshots`,当次断言 expected 8、actual 7。增强每轮事务结果诊断后,第二轮完整回归 **2899 项,2898 通过、1 项失败**,另一配置导入用例在 `File.Move(overwrite: true)` 返回 `UnauthorizedAccessException`;两个相关 IO 用例的有限复跑在第二轮再次捕获状态清单替换被拒绝、事务成功回滚。尚无证据将其归因于特定外部软件。 + +修复只针对核心配置的同目录文件提交:对 Windows 原生访问拒绝、共享冲突和锁冲突最多重试五次,每次请求等待 20 ms;不重复外围事务,不修改权限或只读属性,持续失败保留首次异常并沿用回滚。异步等待可取消,进程致命异常不重试。此边界不代表既有外围所有异常处理都已重构。新增十六项注入故障与真实只读文件回归,并纳入下述统一验证。 + +统一回归所测源码已整理为 `cb378e5`(安装器)、`10dc4e4`(默认启动及文件提交)、`4640685`(网络状态观察及仓库退出等待)。本机主程序 **2915/2915**、安装器 Core **979/979**、Presentation **129/129**、Windows 安全套 **983/983** 全部通过,合计 **5006**,零失败、零跳过;Windows 的六项真实证书修改用例按准确类名排除,留给隔离 CI,不能计入本机结果。原失败的两个 IO 用例另连续复跑十轮,20/20 次通过,不重复累计入全套数量。 + +格式检查覆盖 1522 个文件,零处变更。PowerShell 5.1 与 7 均解析 21 个源文件,无语法错误;安装器构建配置与时间戳地址的 25 项检查通过。完整日志和 TRX 保存在 `artifacts/verification`,包括 `1.0.0-server-readiness-final-main.trx` 与 `installer-final-20260912`。新完整包从准确提交 `4640685f6a80d3d4396f93b0cd7bac65e20ba379` 独立构建。 + +18 项目 Release x64 解决方案构建通过,零警告、零错误。开发分支 `4640685` 的[两项 CI 均已成功](https://github.com/Water-Run/ClashSharp/actions/runs/34686949527)。下载测试归档并核对其 SHA-256 后,四份实际 TRX 为主程序 2915、Core 979、Presentation 129、Windows 989,合计 **5012/5012**,零失败、零跳过,包含六项隔离证书测试。收据为 `artifacts/verification/ci-server-readiness-4640685-counts.json`。 + +服务器从准确 `4640685` 提交独立构建了完整新包,构建前后源码均干净,原仓库及旧源码目录保持。MSIX 为 **192630568 字节**,SHA-256 **`af4e73f60fc53e5950b92ea1bcf6413e14569966727ee47d4e77324bd27a342a`**;构建及输出摘要见 `artifacts/verification/server-source-build-4640685.json`。验收候选使用现有服务器测试签名,生产安装引擎和 helper 按相同 EXE 身份运行,未放宽验证规则。 + +新包的实际安装与修复均返回 `installer.completed`,无待恢复操作。普通 WinUI 进程通过新源码收据、manifest、MSIX 内 DLL 和实际安装 DLL 的摘要绑定;启动 140、600、710 各成功一次,错误数为 0,主窗口稳定 **30260 ms**,完整代理指纹前后相同。观察脚本没有操作页面。为了继续修复及卸载验收,随后按准确 PID、创建时间、路径和 EXE 摘要结束自有测试进程,该动作明确记录为强制结束,不算正常退出验收。 + +安装器和启动修复已独立移植并推送 **main `db21085`**,包含 `888cc6e`、`02ce053` 和只修复 Git worktree 测试定位的 `db21085`。独立 main 工作树完整本机回归 **4734/4734**(主程序 2643、Core 979、Presentation 129、Windows 安全集 983),零失败、零跳过;锁定还原、18 项目 Release 构建和 1443 文件格式检查通过,锁文件未变。[main CI 两项任务均成功](https://github.com/Water-Run/ClashSharp/actions/runs/34687600298),实际下载的四份 TRX 共 **4740/4740**,包含六项隔离证书测试,零失败、零跳过。测试制品 `10296365906` 为 1492297 字节,SHA-256 `4922f0285a8d19243a73b05fd6af001ec1de7e5d9266ea32f4a4a74821c77c23`,与 GitHub 摘要一致。这份 main 源码验证和上述 `4640685` 服务器完整包验证分别记录,不混用包摘要。 + +新版 WPF 候选已完成实际发布、有效测试签名、时间戳校验及 `--verify-payload`,核对 4 个外部文件、7 个机器文件,载荷与完整源码包一致。EXE 为 **90736304 字节**,SHA-256 为 **`5ac54526f2a684430104b612a922e2f4f7d29bdd31072cee784d99cfbee317e3`**;正常窗口在桌面会话中稳定 **30085 ms**。没有执行完整按钮点击或取消交互;随后为卸载测试按准确进程身份强制结束窗口,未计为正常关闭。 + +新包真实卸载返回成功,复核程序包、服务和测试程序进程均为 0,自有机器 TrustedPeople 证书已移除,原有依赖与既有 MSIX 签名私钥保留,代理指纹保持。卸载仍留有下述六层空目录,已由测试收尾按初始不存在、路径无重解析点且为空逐层清除,**这不属于安装器自动清理功能**。本轮五个任务已移除;临时 EXE 测试签名的 Root 信任、My 证书及 CNG 私钥均已删除并复核,公开验收证据保留。 + +最终归档为 `artifacts/verification/server-acceptance-fixed-4640685.zip`(27489 字节,SHA-256 `66b56a5955d5d984e3d1a4c822f9d0ae106bb29266fab980f7657a97e0ac0d24`),脱敏索引为同目录 `server-acceptance-fixed-4640685.json`。**完整页面交互、正常退出和自动空目录清理仍未验收完成。** + +空目录自动清理已进入后续独立开发批次,尚未完成产品实现。初始环境中不存在、卸载后仍为空的目录包括 `%ProgramFiles%\ClashSharp`,以及 `%ProgramData%\ClashSharp` 下的 `Installer\v2`、`InstallerAuthority\v1` 及其空父目录。现有清理只覆盖 `Service`、`MihomoService` 叶目录;事务与证书存储只删除文件。 + +- `%ProgramFiles%\ClashSharp` 需在机器部署 guard 释放后,补充父目录归属与为空验证。 +- ProgramData 中的事务目录、私有 authority 目录及公共父目录需要改变清理生命周期:父进程只读事务 reader 当前长期持有禁止删除共享的目录句柄,helper 的存储也持有保护句柄。不能直接在现有删除循环中追加路径。 +- 拟在成功卸载的 `Clear` 清除 journal 前保存受保护的卸载终态检查点,清除并复核 journal 后、成功回复前完成目录清理:父进程 reader 每次读取完成即释放观察句柄;helper 释放存储句柄后仍持有独占 authority/application lease,再按固定路径清单由子到父清理、复核,最后确认成功。中断后的恢复不能只以 journal 缺失推断完成。该方案尚未实施。 +- 删除前必须验证完整路径链、无 reparse、对应目录归属及为空;公共 `ClashSharp` 祖先现有的 rename-anchor 验证不足以直接授权删除。保留非空或归属不能确认的目录,不递归删除,不放松 handle/owner 保护,不为清理修改 ACL。后续须补句柄释放顺序、外部条目保留、IO/权限失败和成功回复丢失的回归。 + +空目录当前不包含运行文件、服务、证书或用户数据,不阻断本轮首次启动修复;它们仍是“彻底清理”验收的未完成项。 From c5a488ff810a566d3998740583a0193811cd344e Mon Sep 17 00:00:00 2001 From: Water-Run Date: Sat, 12 Sep 2026 18:24:59 +0800 Subject: [PATCH 17/22] fix(installer): release transaction observation leases after each read --- ...nstallerProtectedTransactionReaderTests.cs | 284 ++++++++++++++++++ ...dowsInstallerProtectedTransactionReader.cs | 66 ++-- 2 files changed, 328 insertions(+), 22 deletions(-) create mode 100644 ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerProtectedTransactionReaderTests.cs diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerProtectedTransactionReaderTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerProtectedTransactionReaderTests.cs new file mode 100644 index 0000000..9f2c0cc --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerProtectedTransactionReaderTests.cs @@ -0,0 +1,284 @@ +using System.Security.AccessControl; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; +using ClashSharp.Installer.Windows.Transactions; +using ClashSharp.Windows.FileSecurity; + +namespace ClashSharp.Installer.Windows.Tests; + +public sealed class WindowsInstallerProtectedTransactionReaderTests +{ + private const string TargetSid = "S-1-5-21-100-200-300-1001"; + + [Fact] + public async Task MissingAppearingAndRemovedStateAreObservedWithoutRetainedLeases() + { + using var fixture = new Fixture(); + using WindowsInstallerProtectedTransactionReader reader = fixture.CreateReader(); + fixture.Native.Present = false; + + Assert.Null(await reader.LoadAsync(CancellationToken.None)); + Assert.Equal(0, fixture.Native.ActiveLeases); + + fixture.Native.Present = true; + Assert.Equal(fixture.Snapshot, await reader.LoadAsync(CancellationToken.None)); + Assert.Equal(0, fixture.Native.ActiveLeases); + + File.Delete(fixture.JournalPath); + fixture.Native.Present = false; + Assert.Null(await reader.LoadAsync(CancellationToken.None)); + Assert.Equal(0, fixture.Native.ActiveLeases); + Assert.Equal(0, fixture.Native.CreateCount); + } + + [Theory] + [InlineData("reparse")] + [InlineData("acl")] + [InlineData("io")] + public async Task EveryReadRevalidatesTheCurrentDirectoryChain(string change) + { + using var fixture = new Fixture(); + using WindowsInstallerProtectedTransactionReader reader = fixture.CreateReader(); + Assert.Equal(fixture.Snapshot, await reader.LoadAsync(CancellationToken.None)); + fixture.Native.Change = change; + + InstallerProtocolException failure = await Assert.ThrowsAsync( + () => reader.LoadAsync(CancellationToken.None)); + + Assert.Equal(change switch + { + "reparse" => "installer.transaction.root_reparse_rejected", + "acl" => "installer.transaction.root_acl_invalid", + _ => "installer.transaction.root_verification_failed", + }, failure.DiagnosticCode); + Assert.Equal(0, fixture.Native.ActiveLeases); + Assert.Equal(0, fixture.Native.CreateCount); + } + + [Fact] + public async Task InvalidJournalReleasesAllLeasesAndDoesNotPoisonTheNextRead() + { + using var fixture = new Fixture(); + using WindowsInstallerProtectedTransactionReader reader = fixture.CreateReader(); + File.WriteAllText(fixture.JournalPath, "{}"); + + await Assert.ThrowsAsync(() => reader.LoadAsync(CancellationToken.None)); + Assert.Equal(0, fixture.Native.ActiveLeases); + + File.WriteAllBytes(fixture.JournalPath, InstallerTransactionCodec.Serialize(fixture.Snapshot.Journal)); + Assert.Equal(fixture.Snapshot, await reader.LoadAsync(CancellationToken.None)); + Assert.Equal(0, fixture.Native.ActiveLeases); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DisposeDrainsAnAcceptedReadWithoutReleasingItsProtection(bool cancelRead) + { + using var fixture = new Fixture(); + using WindowsInstallerProtectedTransactionReader reader = fixture.CreateReader(); + using var cancellation = new CancellationTokenSource(); + using var releaseRead = new ManualResetEventSlim(); + var enteredRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var enteredDispose = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int paused = 0; + fixture.Native.OnRootObservation = () => + { + if (Interlocked.Exchange(ref paused, 1) == 0) + { + enteredRead.SetResult(); + if (!releaseRead.Wait(TimeSpan.FromSeconds(15))) + { + throw new TimeoutException("The test did not release its directory observation."); + } + } + }; + Task read = Task.Run(() => reader.LoadAsync(cancellation.Token)); + Task? disposal = null; + try + { + await enteredRead.Task.WaitAsync(TimeSpan.FromSeconds(10)); + disposal = Task.Run(() => + { + enteredDispose.SetResult(); + reader.Dispose(); + }); + await enteredDispose.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + // Wait for disposal admission without relying on scheduling delays. Any read accepted + // before Dispose entered is independently guarded and fully awaited here. + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (true) + { + try + { + await reader.LoadAsync(deadline.Token); + await Task.Yield(); + } + catch (ObjectDisposedException) + { + break; + } + } + + Assert.False(disposal.IsCompleted); + Assert.True(fixture.Native.ActiveLeases > 0); + if (cancelRead) + { + cancellation.Cancel(); + } + } + finally + { + releaseRead.Set(); + if (disposal is not null) + { + await disposal.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + if (cancelRead) + { + await Assert.ThrowsAnyAsync(() => read); + } + else + { + Assert.Equal(fixture.Snapshot, await read); + } + Assert.Equal(0, fixture.Native.ActiveLeases); + await Assert.ThrowsAsync(() => reader.LoadAsync(CancellationToken.None)); + } + + [Fact] + public async Task PreCancelledReadDoesNotOpenDirectories() + { + using var fixture = new Fixture(); + using WindowsInstallerProtectedTransactionReader reader = fixture.CreateReader(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => reader.LoadAsync(cancellation.Token)); + + Assert.Equal(0, fixture.Native.OpenCount); + Assert.Equal(0, fixture.Native.ActiveLeases); + } + + private sealed class Fixture : IDisposable + { + private readonly string _temporaryRoot; + + internal Fixture() + { + WindowsPayloadFixture.AssertWindows11X64(); + _temporaryRoot = Path.Combine(Path.GetTempPath(), "ClashSharp.Reader.Tests." + Guid.NewGuid().ToString("N")); + ProgramDataPath = Path.Combine(_temporaryRoot, "ProgramData"); + string stateRoot = Path.Combine(ProgramDataPath, "ClashSharp", "Installer", "v2"); + Directory.CreateDirectory(stateRoot); + JournalPath = Path.Combine(stateRoot, InstallerStateLayout.JournalFileName); + Snapshot = InstallerTransactionSnapshot.Create(InstallerTransactionJournal.Create( + new InstallerRequest(InstallerOperation.Uninstall, TargetSid, false, "1.0.0.0", new string('a', 64)))); + File.WriteAllBytes(JournalPath, InstallerTransactionCodec.Serialize(Snapshot.Journal)); + Native = new FakeDirectories(ProgramDataPath, stateRoot); + } + + internal string ProgramDataPath { get; } + internal string JournalPath { get; } + internal InstallerTransactionSnapshot Snapshot { get; } + internal FakeDirectories Native { get; } + + internal WindowsInstallerProtectedTransactionReader CreateReader() => + WindowsInstallerProtectedTransactionReader.CreateForTesting(ProgramDataPath, TargetSid, Native); + + public void Dispose() + { + File.Delete(JournalPath); + string? current = Path.GetDirectoryName(JournalPath); + while (current is not null && current.StartsWith(_temporaryRoot, StringComparison.OrdinalIgnoreCase)) + { + Directory.Delete(current, recursive: false); + if (string.Equals(current, _temporaryRoot, StringComparison.OrdinalIgnoreCase)) + { + break; + } + current = Path.GetDirectoryName(current); + } + } + } + + private sealed class FakeDirectories(string programDataPath, string stateRoot) : IWindowsInstallerDirectoryNative + { + private readonly string _stateRoot = stateRoot; + private int _activeLeases; + private int _openCount; + private int _createCount; + + internal bool Present { get; set; } = true; + internal string? Change { get; set; } + internal Action? OnRootObservation { get; set; } + internal int ActiveLeases => Volatile.Read(ref _activeLeases); + internal int OpenCount => Volatile.Read(ref _openCount); + internal int CreateCount => Volatile.Read(ref _createCount); + + public void CreateDirectory(string path, DirectorySecurity security) => + Interlocked.Increment(ref _createCount); + + public IWindowsInstallerDirectoryLease OpenDirectory(string path) + { + Interlocked.Increment(ref _openCount); + if (!Present && path.StartsWith(programDataPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + throw new DirectoryNotFoundException(); + } + if (Change == "io" && path == _stateRoot) + { + throw new IOException("Injected directory observation failure."); + } + Interlocked.Increment(ref _activeLeases); + return new Lease(this, path); + } + + private sealed class Lease(FakeDirectories owner, string path) : IWindowsInstallerDirectoryLease + { + private bool _disposed; + + public WindowsDirectoryObservation Observe() + { + ObjectDisposedException.ThrowIf(_disposed, this); + bool exact = path == owner._stateRoot || path == Path.GetDirectoryName(owner._stateRoot); + if (path == owner._stateRoot) + { + owner.OnRootObservation?.Invoke(); + } + AceFlags flags = exact ? AceFlags.ContainerInherit | AceFlags.ObjectInherit : AceFlags.None; + var entries = new List + { + Ace(WindowsInstallerDirectorySecurityPolicy.LocalSystemSid, FileSystemRights.FullControl, flags), + Ace(WindowsInstallerDirectorySecurityPolicy.AdministratorsSid, FileSystemRights.FullControl, flags), + }; + if (exact) + { + entries.Add(Ace(TargetSid, WindowsInstallerDirectorySecurityPolicy.TargetUserReadOnlyRights, flags)); + } + if (owner.Change == "acl" && path == owner._stateRoot) + { + entries.Add(Ace("S-1-5-32-545", FileSystemRights.Write, flags)); + } + return new WindowsDirectoryObservation(true, owner.Change == "reparse" && path == owner._stateRoot, + new WindowsDirectorySecuritySnapshot(WindowsInstallerDirectorySecurityPolicy.AdministratorsSid, + HasDacl: true, DaclProtected: exact, entries)); + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + Interlocked.Decrement(ref owner._activeLeases); + } + } + } + + private static WindowsDirectoryAce Ace(string sid, FileSystemRights rights, AceFlags flags) => + new(sid, WindowsDirectoryAceKind.Allow, (int)rights, flags, IsObjectSpecific: false); + } +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerProtectedTransactionReader.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerProtectedTransactionReader.cs index 53be8c5..6d8fa9b 100644 --- a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerProtectedTransactionReader.cs +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerProtectedTransactionReader.cs @@ -9,58 +9,80 @@ public sealed class WindowsInstallerProtectedTransactionReader : IInstallerTransactionReader, IDisposable { - private readonly WindowsInstallerTransactionRootGuard _rootGuard; - private readonly FileInstallerTransactionStore _store; + private readonly object _gate = new(); + private readonly Func _createRootGuard; + private int _activeReads; private bool _disposed; private WindowsInstallerProtectedTransactionReader( - WindowsInstallerTransactionRootGuard rootGuard) + Func createRootGuard) { - ArgumentNullException.ThrowIfNull(rootGuard); - _rootGuard = rootGuard; - _store = new FileInstallerTransactionStore(rootGuard.RootPath, rootGuard); + ArgumentNullException.ThrowIfNull(createRootGuard); + _createRootGuard = createRootGuard; + // Preserve eager path/SID validation without opening or creating directories. + using WindowsInstallerTransactionRootGuard validation = _createRootGuard(); } /// /// Creates a non-creating reader for the canonical ProgramData root and exact target SID. /// public static WindowsInstallerProtectedTransactionReader CreateDefault(string targetSid) => - new(WindowsInstallerTransactionRootGuard.CreateReadOnlyDefault(targetSid)); + new(() => WindowsInstallerTransactionRootGuard.CreateReadOnlyDefault(targetSid)); /// public async Task LoadAsync( CancellationToken cancellationToken) { - ObjectDisposedException.ThrowIf(_disposed, this); - await _rootGuard - .EnsureProtectedAsync(_rootGuard.RootPath, cancellationToken) - .ConfigureAwait(false); - if (!_rootGuard.IsProtectedRootPresent) + lock (_gate) { - return null; + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + _activeReads++; } - return await _store.LoadAsync(cancellationToken).ConfigureAwait(false); + try + { + // Pin the complete chain through the file read, parsing, and content hash. No handles + // survive this observation, so the elevated helper can finalize an empty state root. + using WindowsInstallerTransactionRootGuard rootGuard = _createRootGuard(); + await rootGuard.EnsureProtectedAsync(rootGuard.RootPath, cancellationToken) + .ConfigureAwait(false); + if (!rootGuard.IsProtectedRootPresent) + { + return null; + } + + using var store = new FileInstallerTransactionStore(rootGuard.RootPath, rootGuard); + return await store.LoadAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + lock (_gate) + { + _activeReads--; + Monitor.PulseAll(_gate); + } + } } - /// Releases the read-only journal and pinned-directory leases. + /// Rejects new reads and waits for accepted reads to release their pinned leases. public void Dispose() { - if (_disposed) + lock (_gate) { - return; + _disposed = true; + while (_activeReads != 0) + { + Monitor.Wait(_gate); + } } - - _store.Dispose(); - _rootGuard.Dispose(); - _disposed = true; } internal static WindowsInstallerProtectedTransactionReader CreateForTesting( string programDataPath, string targetSid, IWindowsInstallerDirectoryNative native) => - new(WindowsInstallerTransactionRootGuard.CreateReadOnlyForTesting( + new(() => WindowsInstallerTransactionRootGuard.CreateReadOnlyForTesting( programDataPath, targetSid, native)); From 55e753e65d3e459dd2f7134dfc860006a6ed4a29 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Sat, 12 Sep 2026 18:34:21 +0800 Subject: [PATCH 18/22] feat(installer): report verified uninstall directory outcomes before completion --- .../Contracts/InstallerClearReceipt.cs | 10 + .../InstallerDirectoryCleanupReport.cs | 110 +++++++ .../Contracts/InstallerExecutionResult.cs | 6 +- .../Contracts/InstallerPorts.cs | 2 +- .../Execution/InstallerCoordinator.cs | 21 +- .../InstallerMachineHelperAuthorityLoop.cs | 29 ++ .../Machines/InstallerMachineHelperResult.cs | 15 + .../InstallerMachineHelperResultCodec.cs | 98 +++++- ...tallerDirectoryCleanupPresentationTests.cs | 222 +++++++++++++ .../Presentation/InstallerShellViewModel.cs | 52 +++- .../InstallerDirectoryCleanupReportTests.cs | 243 +++++++++++++++ ...rMachineHelperAuthorityLoopCleanupTests.cs | 291 ++++++++++++++++++ .../InstallerScenario.cs | 6 +- .../WindowsElevatedMachineAdapterTests.cs | 25 +- ...WindowsRetiredUninstallCoordinatorTests.cs | 4 +- .../Machines/WindowsElevatedMachineAdapter.cs | 24 +- 16 files changed, 1132 insertions(+), 26 deletions(-) create mode 100644 ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerClearReceipt.cs create mode 100644 ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerDirectoryCleanupReport.cs create mode 100644 ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerDirectoryCleanupPresentationTests.cs create mode 100644 ClashSharp/ClashSharp.Installer.Tests/InstallerDirectoryCleanupReportTests.cs create mode 100644 ClashSharp/ClashSharp.Installer.Tests/InstallerMachineHelperAuthorityLoopCleanupTests.cs diff --git a/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerClearReceipt.cs b/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerClearReceipt.cs new file mode 100644 index 0000000..1bbf06a --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerClearReceipt.cs @@ -0,0 +1,10 @@ +using ClashSharp.Installer.Transactions; + +namespace ClashSharp.Installer.Contracts; + +/// Binds a cleared journal receipt to optional verified directory cleanup observations. +/// The exact immutable Verified journal that was cleared. +/// Directory observations for a completed ordinary uninstall. +public sealed record InstallerClearReceipt( + InstallerTransactionSnapshot State, + InstallerDirectoryCleanupReport? DirectoryCleanupReport = null); diff --git a/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerDirectoryCleanupReport.cs b/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerDirectoryCleanupReport.cs new file mode 100644 index 0000000..793573d --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerDirectoryCleanupReport.cs @@ -0,0 +1,110 @@ +namespace ClashSharp.Installer.Contracts; + +/// Fixed product directories; never accepts a caller-supplied filesystem path. +public enum InstallerDirectoryRole +{ + /// The product directory beneath Program Files. + ProgramFilesProduct, + /// The product directory beneath ProgramData. + ProgramDataProduct, + /// The Installer parent directory. + InstallerRoot, + /// The versioned ordinary transaction directory. + InstallerVersion, + /// The InstallerAuthority parent directory. + AuthorityRoot, + /// The versioned private authority directory. + AuthorityVersion, +} + +/// Independently observed final disposition of one fixed directory. +public enum InstallerDirectoryCleanupDisposition +{ + /// The directory was already absent. + Missing, + /// The owned empty directory was deleted and its absence confirmed. + Deleted, + /// The directory remains because durable creation ownership was not established. + RetainedUnprovenOwnership, + /// The owned directory remains because it contains other entries. + RetainedNonEmpty, +} + +/// One path-free terminal directory observation. +/// Fixed directory role. +/// Verified deletion, absence, or explicit preservation. +public sealed record InstallerDirectoryCleanupEntry( + InstallerDirectoryRole Role, + InstallerDirectoryCleanupDisposition Disposition); + +/// Immutable, complete observations of the six product directories after uninstall. +public sealed class InstallerDirectoryCleanupReport : IEquatable +{ + /// The exact number of fixed directory roles in this protocol. + public const int DirectoryCount = 6; + + private readonly InstallerDirectoryCleanupEntry[] _entries; + + /// Copies and validates a bounded, complete set of observations. + public InstallerDirectoryCleanupReport(IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + var snapshot = new List(DirectoryCount); + foreach (InstallerDirectoryCleanupEntry entry in entries) + { + if (snapshot.Count == DirectoryCount || entry is null) + { + throw Invalid(); + } + snapshot.Add(entry); + } + + _entries = snapshot.OrderBy(static entry => entry.Role).ToArray(); + Entries = Array.AsReadOnly(_entries); + Validate(); + } + + /// Gets the copied observations in canonical directory-role order. + public IReadOnlyList Entries { get; } + + /// Gets whether any directory was explicitly preserved. + public bool HasRetained => _entries.Any(static entry => entry.Disposition is + InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership + or InstallerDirectoryCleanupDisposition.RetainedNonEmpty); + + /// Rejects missing, duplicated, or unknown roles and unknown dispositions. + public void Validate() + { + if (_entries.Length != DirectoryCount) + { + throw Invalid(); + } + for (int index = 0; index < DirectoryCount; index++) + { + if ((int)_entries[index].Role != index || !Enum.IsDefined(_entries[index].Disposition)) + { + throw Invalid(); + } + } + } + + /// + public bool Equals(InstallerDirectoryCleanupReport? other) => + other is not null && _entries.SequenceEqual(other._entries); + + /// + public override bool Equals(object? obj) => obj is InstallerDirectoryCleanupReport other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (InstallerDirectoryCleanupEntry entry in _entries) + { + hash.Add(entry); + } + return hash.ToHashCode(); + } + + private static InstallerProtocolException Invalid() => new("installer.directory_cleanup.report_invalid"); +} diff --git a/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerExecutionResult.cs b/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerExecutionResult.cs index 23b78b6..0c26184 100644 --- a/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerExecutionResult.cs +++ b/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerExecutionResult.cs @@ -30,4 +30,8 @@ public sealed record InstallerExecutionResult( InstallerExecutionOutcome Outcome, string DiagnosticCode, InstallerTransactionPhase? LastDurablePhase, - bool RecoveryPending); + bool RecoveryPending) +{ + /// Gets verified ordinary-uninstall cleanup observations, when available. + public InstallerDirectoryCleanupReport? DirectoryCleanupReport { get; init; } +} diff --git a/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerPorts.cs b/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerPorts.cs index 9536fda..c7fbd71 100644 --- a/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerPorts.cs +++ b/ClashSharp/ClashSharp.Installer.Core/Contracts/InstallerPorts.cs @@ -104,7 +104,7 @@ Task VerifyAsync( /// The elevated helper owns this mutation. The unelevated parent must subsequently prove /// absence through its read-only transaction view. /// - Task ClearVerifiedAsync( + Task ClearVerifiedAsync( InstallerRequest request, IInstallerReleaseLease release, InstallerTransactionSnapshot verifiedState, diff --git a/ClashSharp/ClashSharp.Installer.Core/Execution/InstallerCoordinator.cs b/ClashSharp/ClashSharp.Installer.Core/Execution/InstallerCoordinator.cs index 4742628..0f6dcc6 100644 --- a/ClashSharp/ClashSharp.Installer.Core/Execution/InstallerCoordinator.cs +++ b/ClashSharp/ClashSharp.Installer.Core/Execution/InstallerCoordinator.cs @@ -162,7 +162,7 @@ await ReverifyReleaseAsync(request, releaseLease, cancellationToken) InstallerTransactionPhase.Verified); durable = await ConfirmHelperStateAsync(durable, cancellationToken) .ConfigureAwait(false); - InstallerTransactionSnapshot clearReceipt = await _finalVerifier + InstallerClearReceipt clearReceipt = await _finalVerifier .ClearVerifiedAsync(request, releaseLease, durable, cancellationToken) .ConfigureAwait(false); ValidateClearReceipt(durable, clearReceipt); @@ -172,7 +172,7 @@ await ReverifyReleaseAsync(request, releaseLease, cancellationToken) InstallerExecutionOutcome.Succeeded, "installer.completed", InstallerTransactionPhase.Verified, - recoveryPending: false); + recoveryPending: false) with { DirectoryCleanupReport = clearReceipt.DirectoryCleanupReport }; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -475,21 +475,30 @@ private async Task ConfirmHelperStateAsync( private static void ValidateClearReceipt( InstallerTransactionSnapshot verifiedState, - InstallerTransactionSnapshot? clearReceipt) + InstallerClearReceipt? clearReceipt) { verifiedState.Validate(); - if (clearReceipt is null) + if (clearReceipt?.State is null) { throw new InstallerProtocolException( "installer.machine_helper.clear_receipt_missing"); } - clearReceipt.Validate(); - if (clearReceipt != verifiedState) + clearReceipt.State.Validate(); + if (clearReceipt.State != verifiedState) { throw new InstallerProtocolException( "installer.machine_helper.clear_receipt_mismatch"); } + + if (clearReceipt.DirectoryCleanupReport is { } cleanup) + { + cleanup.Validate(); + if (verifiedState.Journal.Operation != InstallerOperation.Uninstall) + { + throw new InstallerProtocolException("installer.directory_cleanup.result_binding_invalid"); + } + } } private async Task ConfirmHelperClearAsync( diff --git a/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperAuthorityLoop.cs b/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperAuthorityLoop.cs index 7406e0a..22157b1 100644 --- a/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperAuthorityLoop.cs +++ b/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperAuthorityLoop.cs @@ -29,14 +29,32 @@ await RunAsync( /// /// Processes an already-authenticated first command, then reads any remaining commands from the stream. /// + public static Task RunAsync( + Stream authenticatedStream, + InstallerMachineHelperAuthoritySession authority, + InstallerMachineHelperCommand firstCommand, + CancellationToken cancellationToken) => + RunAsync( + authenticatedStream, + authority, + firstCommand, + static (_, _, _) => Task.FromResult(null), + cancellationToken); + + /// + /// Awaits directory cleanup after a successful uninstall clear and before sending its receipt. + /// public static async Task RunAsync( Stream authenticatedStream, InstallerMachineHelperAuthoritySession authority, InstallerMachineHelperCommand firstCommand, + Func> beforeClearReply, CancellationToken cancellationToken) { ValidateArguments(authenticatedStream, authority); ArgumentNullException.ThrowIfNull(firstCommand); + ArgumentNullException.ThrowIfNull(beforeClearReply); InstallerMachineHelperCommand command = firstCommand; for (int commandCount = 0; @@ -46,6 +64,17 @@ public static async Task RunAsync( InstallerMachineHelperResult result = await authority .ExecuteAsync(command, cancellationToken) .ConfigureAwait(false); + if (command.Verb == InstallerMachineHelperVerb.Clear + && result.Outcome == InstallerMachineHelperOutcome.Succeeded + && command.ToDurableState().Journal.Operation == InstallerOperation.Uninstall) + { + InstallerDirectoryCleanupReport? report = await beforeClearReply( + command, result, cancellationToken) + .ConfigureAwait(false); + result = result with { DirectoryCleanupReport = report }; + _ = result.ValidateAgainst(command); + } + await InstallerMachineHelperFraming .WriteResultAsync(authenticatedStream, result, cancellationToken) .ConfigureAwait(false); diff --git a/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperResult.cs b/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperResult.cs index 9af4e70..805d77a 100644 --- a/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperResult.cs +++ b/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperResult.cs @@ -40,6 +40,9 @@ public sealed record InstallerMachineHelperResult( /// The only currently supported helper response schema. public const int CurrentSchema = 1; + /// Gets path-free directory observations only for a successful uninstall Clear. + public InstallerDirectoryCleanupReport? DirectoryCleanupReport { get; init; } + /// Creates a successful response for the exact state the helper durably committed. public static InstallerMachineHelperResult Succeeded( InstallerMachineHelperCommand command, @@ -181,6 +184,18 @@ public void Validate() throw new InstallerProtocolException( "installer.machine_helper.result_invalid"); } + + if (DirectoryCleanupReport is { } cleanup) + { + cleanup.Validate(); + if (Outcome != InstallerMachineHelperOutcome.Succeeded + || Verb != InstallerMachineHelperVerb.Clear + || resultState.Journal.Operation != InstallerOperation.Uninstall + || resultState.Journal.Phase != InstallerTransactionPhase.Verified) + { + throw new InstallerProtocolException("installer.directory_cleanup.result_binding_invalid"); + } + } } /// diff --git a/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperResultCodec.cs b/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperResultCodec.cs index 9f8b251..1b6283e 100644 --- a/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperResultCodec.cs +++ b/ClashSharp/ClashSharp.Installer.Core/Machines/InstallerMachineHelperResultCodec.cs @@ -41,6 +41,18 @@ public static byte[] Serialize(InstallerMachineHelperResult result) writer.WriteString("outcome", OutcomeText(result.Outcome)); writer.WriteBoolean("postconditionVerified", result.PostconditionVerified); writer.WriteString("diagnosticCode", result.DiagnosticCode); + if (result.DirectoryCleanupReport is { } cleanup) + { + writer.WriteStartArray("directoryCleanup"); + foreach (InstallerDirectoryCleanupEntry entry in cleanup.Entries) + { + writer.WriteStartObject(); + writer.WriteString("role", RoleText(entry.Role)); + writer.WriteString("disposition", DispositionText(entry.Disposition)); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } writer.WriteEndObject(); } @@ -59,7 +71,7 @@ public static InstallerMachineHelperResult Parse(ReadOnlySpan bytes) { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow, - MaxDepth = 2, + MaxDepth = 3, }); JsonElement root = document.RootElement; if (root.ValueKind != JsonValueKind.Object) @@ -70,7 +82,7 @@ public static InstallerMachineHelperResult Parse(ReadOnlySpan bytes) var observed = new HashSet(StringComparer.Ordinal); foreach (JsonProperty property in root.EnumerateObject()) { - if (!RequiredProperties.Contains(property.Name) + if ((!RequiredProperties.Contains(property.Name) && property.Name != "directoryCleanup") || !observed.Add(property.Name)) { throw new JsonException( @@ -86,6 +98,7 @@ public static InstallerMachineHelperResult Parse(ReadOnlySpan bytes) or "diagnosticCode" => property.Value.ValueKind == JsonValueKind.String, "postconditionVerified" => property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False, + "directoryCleanup" => property.Value.ValueKind == JsonValueKind.Array, _ => false, }; if (!validType) @@ -94,7 +107,7 @@ public static InstallerMachineHelperResult Parse(ReadOnlySpan bytes) } } - if (!observed.SetEquals(RequiredProperties)) + if (!RequiredProperties.IsSubsetOf(observed)) { throw new JsonException("The helper result property set is incomplete."); } @@ -113,7 +126,11 @@ public static InstallerMachineHelperResult Parse(ReadOnlySpan bytes) ParseOutcome(root.GetProperty("outcome").GetString()), root.GetProperty("postconditionVerified").GetBoolean(), root.GetProperty("diagnosticCode").GetString() - ?? throw new JsonException("The helper result diagnostic is null.")); + ?? throw new JsonException("The helper result diagnostic is null.")) + { + DirectoryCleanupReport = root.TryGetProperty("directoryCleanup", out JsonElement cleanup) + ? ParseCleanup(cleanup) : null, + }; result.Validate(); byte[] canonical = Serialize(result); if (!CryptographicOperations.FixedTimeEquals(bytes, canonical)) @@ -140,6 +157,79 @@ private static void ValidateSize(ReadOnlySpan bytes) } } + private static InstallerDirectoryCleanupReport ParseCleanup(JsonElement array) + { + if (array.GetArrayLength() != InstallerDirectoryCleanupReport.DirectoryCount) + { + throw new JsonException("The directory cleanup observations are incomplete."); + } + var entries = new List(InstallerDirectoryCleanupReport.DirectoryCount); + foreach (JsonElement item in array.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + { + throw new JsonException("A directory cleanup observation must be an object."); + } + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in item.EnumerateObject()) + { + if (property.Name is not ("role" or "disposition") + || !names.Add(property.Name) || property.Value.ValueKind != JsonValueKind.String) + { + throw new JsonException("A directory cleanup observation property is invalid."); + } + } + if (names.Count != 2) + { + throw new JsonException("A directory cleanup observation is incomplete."); + } + entries.Add(new InstallerDirectoryCleanupEntry( + ParseRole(item.GetProperty("role").GetString()), + ParseDisposition(item.GetProperty("disposition").GetString()))); + } + return new InstallerDirectoryCleanupReport(entries); + } + + private static string RoleText(InstallerDirectoryRole role) => role switch + { + InstallerDirectoryRole.ProgramFilesProduct => "program-files-product", + InstallerDirectoryRole.ProgramDataProduct => "program-data-product", + InstallerDirectoryRole.InstallerRoot => "installer-root", + InstallerDirectoryRole.InstallerVersion => "installer-version", + InstallerDirectoryRole.AuthorityRoot => "authority-root", + InstallerDirectoryRole.AuthorityVersion => "authority-version", + _ => throw new InstallerProtocolException("installer.directory_cleanup.report_invalid"), + }; + + private static InstallerDirectoryRole ParseRole(string? value) => value switch + { + "program-files-product" => InstallerDirectoryRole.ProgramFilesProduct, + "program-data-product" => InstallerDirectoryRole.ProgramDataProduct, + "installer-root" => InstallerDirectoryRole.InstallerRoot, + "installer-version" => InstallerDirectoryRole.InstallerVersion, + "authority-root" => InstallerDirectoryRole.AuthorityRoot, + "authority-version" => InstallerDirectoryRole.AuthorityVersion, + _ => throw new JsonException("The directory role is invalid."), + }; + + private static string DispositionText(InstallerDirectoryCleanupDisposition disposition) => disposition switch + { + InstallerDirectoryCleanupDisposition.Missing => "missing", + InstallerDirectoryCleanupDisposition.Deleted => "deleted", + InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership => "retained-unproven-ownership", + InstallerDirectoryCleanupDisposition.RetainedNonEmpty => "retained-non-empty", + _ => throw new InstallerProtocolException("installer.directory_cleanup.report_invalid"), + }; + + private static InstallerDirectoryCleanupDisposition ParseDisposition(string? value) => value switch + { + "missing" => InstallerDirectoryCleanupDisposition.Missing, + "deleted" => InstallerDirectoryCleanupDisposition.Deleted, + "retained-unproven-ownership" => InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership, + "retained-non-empty" => InstallerDirectoryCleanupDisposition.RetainedNonEmpty, + _ => throw new JsonException("The directory disposition is invalid."), + }; + private static string VerbText(InstallerMachineHelperVerb verb) => verb switch { InstallerMachineHelperVerb.Prepare => "prepare", diff --git a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerDirectoryCleanupPresentationTests.cs b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerDirectoryCleanupPresentationTests.cs new file mode 100644 index 0000000..3b541d1 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerDirectoryCleanupPresentationTests.cs @@ -0,0 +1,222 @@ +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Presentation; +using ClashSharp.Installer.Runtime; + +namespace ClashSharp.Installer.Presentation.Tests; + +public sealed class InstallerDirectoryCleanupPresentationTests +{ + [Fact] + public async Task SuccessfulUninstallExplainsRetainedDirectoriesWithoutClaimingFailureOrCompleteCleanup() + { + InstallerDirectoryCleanupReport report = CreateReport(role => role switch + { + InstallerDirectoryRole.ProgramFilesProduct => InstallerDirectoryCleanupDisposition.RetainedNonEmpty, + InstallerDirectoryRole.ProgramDataProduct => InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership, + _ => InstallerDirectoryCleanupDisposition.Deleted, + }); + ScriptedInstallerRuntime runtime = CreateRuntime(InstallerOperation.Uninstall, + InstallerPresentationTestData.Result() with { DirectoryCleanupReport = report }); + using var viewModel = new InstallerShellViewModel(runtime); + await ExecuteAsync(viewModel, InstallerOperation.Uninstall); + + Assert.Equal("卸载已完成,部分目录已保留", viewModel.StatusTitle); + Assert.Equal("已完成", viewModel.StatusBadge); + Assert.Equal(100, viewModel.ProgressValue); + Assert.Contains("程序目录(Program Files\\ClashSharp):包含其他内容。", viewModel.StatusDetail, StringComparison.Ordinal); + Assert.Contains("共享数据目录(ProgramData\\ClashSharp):无法确认由安装器创建。", viewModel.StatusDetail, StringComparison.Ordinal); + Assert.DoesNotContain("安装记录目录", viewModel.StatusDetail, StringComparison.Ordinal); + Assert.DoesNotContain("自有空目录已清理", viewModel.StatusDetail, StringComparison.Ordinal); + Assert.DoesNotContain("ACL", viewModel.StatusDetail, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("nonce", viewModel.StatusDetail, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("账本", viewModel.StatusDetail, StringComparison.Ordinal); + Assert.False(viewModel.CanExecuteMutations); + Assert.False(viewModel.IsBusy); + Assert.Equal([InstallerOperation.Uninstall], runtime.Operations); + } + + [Fact] + public async Task EveryRetainedRoleIsListedOnceWithItsFixedDirectoryLabel() + { + InstallerDirectoryCleanupReport report = CreateReport(_ => InstallerDirectoryCleanupDisposition.RetainedNonEmpty); + using var viewModel = new InstallerShellViewModel(CreateRuntime(InstallerOperation.Uninstall, + InstallerPresentationTestData.Result() with { DirectoryCleanupReport = report })); + await ExecuteAsync(viewModel, InstallerOperation.Uninstall); + + string[] lines = viewModel.StatusDetail.Split(Environment.NewLine); + string[] directories = lines.Where(line => line.EndsWith(":包含其他内容。", StringComparison.Ordinal)).ToArray(); + Assert.Equal(InstallerDirectoryCleanupReport.DirectoryCount, directories.Length); + Assert.Equal(directories.Length, directories.Distinct(StringComparer.Ordinal).Count()); + Assert.Contains(directories, line => line.Contains("Installer\\v2)", StringComparison.Ordinal)); + Assert.Contains(directories, line => line.Contains("InstallerAuthority\\v1)", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(InstallerDirectoryCleanupDisposition.Deleted)] + [InlineData(InstallerDirectoryCleanupDisposition.Missing)] + public async Task FullyObservedCleanDirectoriesAreDistinguishedFromAnUnreportedUninstall( + InstallerDirectoryCleanupDisposition disposition) + { + using var viewModel = new InstallerShellViewModel(CreateRuntime(InstallerOperation.Uninstall, + InstallerPresentationTestData.Result() with { DirectoryCleanupReport = CreateReport(_ => disposition) })); + await ExecuteAsync(viewModel, InstallerOperation.Uninstall); + + Assert.Equal("卸载已完成", viewModel.StatusTitle); + Assert.Contains("自有空目录已清理或已不存在", viewModel.StatusDetail, StringComparison.Ordinal); + Assert.DoesNotContain("已保留", viewModel.StatusDetail, StringComparison.Ordinal); + Assert.Equal("已完成", viewModel.StatusBadge); + Assert.Equal("卸载完成。", viewModel.ProgressStatus); + } + + [Fact] + public async Task UninstallWithoutAReportKeepsExistingTextAndDoesNotClaimDirectoryCleanup() + { + using var viewModel = new InstallerShellViewModel(CreateRuntime(InstallerOperation.Uninstall, + InstallerPresentationTestData.Result())); + await ExecuteAsync(viewModel, InstallerOperation.Uninstall); + + Assert.Equal("操作已完成", viewModel.StatusTitle); + Assert.Equal("可以关闭安装器,或重新检查以管理此应用。", viewModel.StatusDetail); + Assert.Equal("操作完成。", viewModel.ProgressStatus); + } + + [Theory] + [InlineData(InstallerExecutionOutcome.Blocked)] + [InlineData(InstallerExecutionOutcome.Cancelled)] + [InlineData(InstallerExecutionOutcome.Failed)] + [InlineData(InstallerExecutionOutcome.Uncertain)] + public async Task AReportCannotTurnANonSuccessResultIntoSuccessfulCleanup(InstallerExecutionOutcome outcome) + { + using var viewModel = new InstallerShellViewModel(CreateRuntime(InstallerOperation.Uninstall, + InstallerPresentationTestData.Result(outcome) with + { + DirectoryCleanupReport = CreateReport(_ => InstallerDirectoryCleanupDisposition.Deleted), + })); + await ExecuteAsync(viewModel, InstallerOperation.Uninstall); + + AssertRejectedReport(viewModel); + } + + [Theory] + [InlineData(InstallerOperation.Install)] + [InlineData(InstallerOperation.Repair)] + public async Task ASuccessfulDifferentOperationCannotDisplayAnUninstallCleanupReport(InstallerOperation operation) + { + using var viewModel = new InstallerShellViewModel(CreateRuntime(operation, + InstallerPresentationTestData.Result() with + { + DirectoryCleanupReport = CreateReport(_ => InstallerDirectoryCleanupDisposition.Deleted), + })); + await ExecuteAsync(viewModel, operation); + + AssertRejectedReport(viewModel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AccountSpecificActionsCannotClaimOrdinaryUninstallCleanup(bool retiredUninstall) + { + var runtime = new AccountActionRuntime(); + using var viewModel = new InstallerShellViewModel(runtime); + var displayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + viewModel.PropertyChanged += (_, _) => + { + if (retiredUninstall ? viewModel.IsRetiredUninstallConfirmationVisible : viewModel.IsOwnerTransferConfirmationVisible) + { + displayed.TrySetResult(); + } + }; + Task execution = (retiredUninstall ? viewModel.RetiredUninstallCommand : viewModel.OwnerTransferCommand).ExecuteAsync(); + try + { + await displayed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + (retiredUninstall ? viewModel.ConfirmRetiredUninstallCommand : viewModel.ConfirmOwnerTransferCommand).Execute(null); + await execution.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + viewModel.RequestCancellation(); + await execution; + } + + AssertRejectedReport(viewModel); + } + + [Fact] + public async Task ANewInspectionReplacesThePreviousRetainedDirectoryExplanation() + { + ScriptedInstallerRuntime runtime = CreateRuntime(InstallerOperation.Uninstall, + InstallerPresentationTestData.Result() with + { + DirectoryCleanupReport = CreateReport(_ => InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership), + }); + using var viewModel = new InstallerShellViewModel(runtime); + await ExecuteAsync(viewModel, InstallerOperation.Uninstall); + Assert.Contains("已保留", viewModel.StatusTitle, StringComparison.Ordinal); + + runtime.Inspect = _ => Task.FromResult(InstallerPresentationTestData.Readiness()); + await viewModel.RefreshCommand.ExecuteAsync(); + + Assert.Equal("可以安装", viewModel.StatusTitle); + Assert.DoesNotContain("已保留", viewModel.StatusDetail, StringComparison.Ordinal); + Assert.True(viewModel.CanExecuteMutations); + } + + private static void AssertRejectedReport(InstallerShellViewModel viewModel) + { + Assert.Equal("installer.runtime.result_invalid", viewModel.DiagnosticCode); + Assert.Equal("失败", viewModel.StatusBadge); + Assert.Equal("操作未完成", viewModel.StatusTitle); + Assert.DoesNotContain("自有空目录已清理", viewModel.StatusDetail, StringComparison.Ordinal); + Assert.False(viewModel.CanExecuteMutations); + Assert.False(viewModel.IsBusy); + } + + private static InstallerDirectoryCleanupReport CreateReport( + Func disposition) => + new(Enum.GetValues().Select(role => new InstallerDirectoryCleanupEntry(role, disposition(role)))); + + private static ScriptedInstallerRuntime CreateRuntime(InstallerOperation operation, InstallerExecutionResult result) => new() + { + Inspect = _ => Task.FromResult(InstallerPresentationTestData.Readiness(operation == InstallerOperation.Install + ? InstallerProductState.Available : InstallerProductState.Installed)), + Execute = (_, _, _) => Task.FromResult(result), + }; + + private static async Task ExecuteAsync(InstallerShellViewModel viewModel, InstallerOperation operation) + { + await viewModel.InitializeAsync(); + await (operation == InstallerOperation.Uninstall + ? viewModel.SecondaryActionCommand : viewModel.PrimaryActionCommand).ExecuteAsync(); + } + + private sealed class AccountActionRuntime : IInstallerRuntime, IInstallerOwnerTransferRuntime, IInstallerRetiredUninstallRuntime + { + public bool SupportsOwnerTransfer => true; + public bool SupportsRetiredUninstall => true; + + public Task InspectReadinessAsync(CancellationToken cancellationToken) => + Task.FromResult(InstallerPresentationTestData.Readiness()); + + public Task ExecuteAsync(InstallerOperation operation, + IProgress progress, CancellationToken cancellationToken) => throw new NotSupportedException(); + + public async Task TransferAndExecuteAsync( + Func> confirm, + IProgress progress, CancellationToken cancellationToken) + { + bool accepted = await confirm(new InstallerOwnerTransferConfirmation(false), cancellationToken); + Assert.True(accepted); + return ResultWithReport(); + } + + public Task UninstallRetiredAccountAsync( + IProgress progress, CancellationToken cancellationToken) => Task.FromResult(ResultWithReport()); + + private static InstallerExecutionResult ResultWithReport() => InstallerPresentationTestData.Result() with + { + DirectoryCleanupReport = CreateReport(_ => InstallerDirectoryCleanupDisposition.Deleted), + }; + } +} diff --git a/ClashSharp/ClashSharp.Installer.Presentation/Presentation/InstallerShellViewModel.cs b/ClashSharp/ClashSharp.Installer.Presentation/Presentation/InstallerShellViewModel.cs index 885ab77..84603e9 100644 --- a/ClashSharp/ClashSharp.Installer.Presentation/Presentation/InstallerShellViewModel.cs +++ b/ClashSharp/ClashSharp.Installer.Presentation/Presentation/InstallerShellViewModel.cs @@ -473,7 +473,7 @@ private async Task ExecuteOperationAsync(InstallerOperation? requestedOperation, return; } - ValidateExecutionResult(result); + ValidateExecutionResult(result, ownerTransfer || retiredUninstall ? null : requestedOperation); ApplyExecutionResult(result); if (retiredUninstall) { @@ -667,9 +667,47 @@ private void ApplyExecutionResult(InstallerExecutionResult result) if (result.Outcome == InstallerExecutionOutcome.Succeeded) { ProgressValue = 100; + if (result.DirectoryCleanupReport is { } cleanup) + { + ApplyDirectoryCleanupReport(cleanup); + } } } + private void ApplyDirectoryCleanupReport(InstallerDirectoryCleanupReport cleanup) + { + ProgressStatus = "卸载完成。"; + if (!cleanup.HasRetained) + { + StatusTitle = "卸载已完成"; + StatusDetail = "自有空目录已清理或已不存在。可以关闭安装器,或重新检查以管理此应用。"; + return; + } + + StatusTitle = "卸载已完成,部分目录已保留"; + StatusDetail = "以下目录含有其他内容,或无法确认由安装器创建,因此已保留:" + + Environment.NewLine + + string.Join(Environment.NewLine, cleanup.Entries + .Where(static entry => entry.Disposition is + InstallerDirectoryCleanupDisposition.RetainedNonEmpty + or InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership) + .Select(static entry => GetDirectoryLabel(entry.Role) + (entry.Disposition == + InstallerDirectoryCleanupDisposition.RetainedNonEmpty + ? ":包含其他内容。" : ":无法确认由安装器创建。"))) + + Environment.NewLine + "可以关闭安装器。"; + } + + private static string GetDirectoryLabel(InstallerDirectoryRole role) => role switch + { + InstallerDirectoryRole.ProgramFilesProduct => "程序目录(Program Files\\ClashSharp)", + InstallerDirectoryRole.ProgramDataProduct => "共享数据目录(ProgramData\\ClashSharp)", + InstallerDirectoryRole.InstallerRoot => "安装记录目录(ProgramData\\ClashSharp\\Installer)", + InstallerDirectoryRole.InstallerVersion => "安装记录子目录(ProgramData\\ClashSharp\\Installer\\v2)", + InstallerDirectoryRole.AuthorityRoot => "安装管理目录(ProgramData\\ClashSharp\\InstallerAuthority)", + InstallerDirectoryRole.AuthorityVersion => "安装管理子目录(ProgramData\\ClashSharp\\InstallerAuthority\\v1)", + _ => throw new InstallerProtocolException("installer.runtime.result_invalid"), + }; + private static string GetRecoveryDetail(InstallerExecutionResult result) => result.RecoveryPending ? "进度已保留。请使用本次操作所用的安装器重新检查并继续。" : "操作尚未完成,请重新检查安装状态。"; @@ -809,7 +847,7 @@ private static bool HasValidAllowedOperations(InstallerRuntimeReadiness readines }; } - private static void ValidateExecutionResult(InstallerExecutionResult result) + private static void ValidateExecutionResult(InstallerExecutionResult result, InstallerOperation? ordinaryOperation) { if (result is null || !Enum.IsDefined(result.Outcome) @@ -821,6 +859,16 @@ private static void ValidateExecutionResult(InstallerExecutionResult result) { throw new InstallerProtocolException("installer.runtime.result_invalid"); } + + if (result.DirectoryCleanupReport is { } cleanup) + { + if (result.Outcome != InstallerExecutionOutcome.Succeeded + || ordinaryOperation != InstallerOperation.Uninstall) + { + throw new InstallerProtocolException("installer.runtime.result_invalid"); + } + cleanup.Validate(); + } } private static bool IsValidDiagnosticCode(string value) => diff --git a/ClashSharp/ClashSharp.Installer.Tests/InstallerDirectoryCleanupReportTests.cs b/ClashSharp/ClashSharp.Installer.Tests/InstallerDirectoryCleanupReportTests.cs new file mode 100644 index 0000000..ee71262 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Tests/InstallerDirectoryCleanupReportTests.cs @@ -0,0 +1,243 @@ +using System.Text; +using System.Text.Json.Nodes; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Execution; +using ClashSharp.Installer.Machines; +using ClashSharp.Installer.Transactions; + +namespace ClashSharp.Installer.Tests; + +public sealed class InstallerDirectoryCleanupReportTests +{ + [Fact] + public void ReportCopiesOrdersAndProtectsItsCompleteObservations() + { + InstallerDirectoryCleanupEntry[] input = Entries().Reverse().ToArray(); + var report = new InstallerDirectoryCleanupReport(input); + input[0] = new(InstallerDirectoryRole.ProgramFilesProduct, InstallerDirectoryCleanupDisposition.RetainedNonEmpty); + + Assert.Equal(Entries(), report.Entries); + Assert.Equal(new InstallerDirectoryCleanupReport(Entries()), report); + Assert.False(report.HasRetained); + Assert.Throws(() => + ((IList)report.Entries)[0] = input[0]); + } + + [Theory] + [InlineData("missing")] + [InlineData("extra")] + [InlineData("duplicate")] + [InlineData("role")] + [InlineData("disposition")] + [InlineData("null")] + public void InvalidOrIncompleteObservationsCannotBecomeReports(string mutation) + { + List entries = Entries().ToList(); + switch (mutation) + { + case "missing": entries.RemoveAt(0); break; + case "extra": entries.Add(entries[0]); break; + case "duplicate": entries[1] = entries[0]; break; + case "role": entries[0] = entries[0] with { Role = (InstallerDirectoryRole)6 }; break; + case "disposition": entries[0] = entries[0] with { Disposition = (InstallerDirectoryCleanupDisposition)4 }; break; + case "null": entries[0] = null!; break; + } + Assert.Equal("installer.directory_cleanup.report_invalid", Assert.Throws( + () => new InstallerDirectoryCleanupReport(entries)).DiagnosticCode); + } + + [Fact] + public void UnboundedInputIsRejectedAfterTheSeventhObservation() + { + int yielded = 0; + IEnumerable Input() + { + while (true) + { + yielded++; + yield return new(InstallerDirectoryRole.ProgramFilesProduct, InstallerDirectoryCleanupDisposition.Missing); + } + } + Assert.Throws(() => new InstallerDirectoryCleanupReport(Input())); + Assert.Equal(7, yielded); + } + + [Theory] + [InlineData(InstallerDirectoryCleanupDisposition.Missing, false)] + [InlineData(InstallerDirectoryCleanupDisposition.Deleted, false)] + [InlineData(InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership, true)] + [InlineData(InstallerDirectoryCleanupDisposition.RetainedNonEmpty, true)] + public void CanonicalHelperReceiptPreservesEveryObservationAndItsJournalBinding( + InstallerDirectoryCleanupDisposition disposition, bool retained) + { + InstallerMachineHelperCommand command = Command(InstallerOperation.Uninstall); + InstallerMachineHelperResult expected = InstallerMachineHelperResult.Succeeded(command, command.ToDurableState()) + with + { DirectoryCleanupReport = new InstallerDirectoryCleanupReport(Entries(disposition)) }; + + byte[] bytes = InstallerMachineHelperResultCodec.Serialize(expected); + InstallerMachineHelperResult actual = InstallerMachineHelperResultCodec.Parse(bytes); + + Assert.Equal(expected, actual); + Assert.Equal(command.ToDurableState(), actual.ValidateAgainst(command)); + Assert.Equal(retained, actual.DirectoryCleanupReport!.HasRetained); + Assert.True(bytes.Length <= InstallerMachineHelperResultCodec.MaximumResultBytes); + } + + [Theory] + [InlineData(InstallerOperation.Install)] + [InlineData(InstallerOperation.Repair)] + public void OtherOperationsCannotCarryDirectoryCleanup(InstallerOperation operation) + { + InstallerMachineHelperCommand command = Command(operation); + InstallerMachineHelperResult result = InstallerMachineHelperResult.Succeeded(command, command.ToDurableState()) + with + { DirectoryCleanupReport = new InstallerDirectoryCleanupReport(Entries()) }; + Assert.Equal("installer.directory_cleanup.result_binding_invalid", + Assert.Throws(() => result.ValidateAgainst(command)).DiagnosticCode); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FailedAndNonClearRepliesCannotCarryDirectoryCleanup(bool failure) + { + InstallerMachineHelperCommand command = Command(InstallerOperation.Uninstall, + failure ? InstallerMachineHelperVerb.Clear : InstallerMachineHelperVerb.Verify); + InstallerMachineHelperResult result = (failure + ? InstallerMachineHelperResult.Failed(command, "installer.test.failed") + : InstallerMachineHelperResult.Succeeded(command, command.GetExpectedSuccessfulState())) + with + { DirectoryCleanupReport = new InstallerDirectoryCleanupReport(Entries()) }; + Assert.Equal("installer.directory_cleanup.result_binding_invalid", + Assert.Throws(() => result.Validate()).DiagnosticCode); + } + + [Theory] + [InlineData("missing")] + [InlineData("extra")] + [InlineData("duplicate-role")] + [InlineData("unknown-role")] + [InlineData("unknown-disposition")] + [InlineData("unknown-property")] + [InlineData("wrong-type")] + [InlineData("nested")] + [InlineData("null")] + [InlineData("reordered")] + public void NoncanonicalOrMalformedCleanupFramesAreRejected(string mutation) + { + JsonObject root = JsonNode.Parse(CanonicalJson())!.AsObject(); + JsonArray entries = root["directoryCleanup"]!.AsArray(); + switch (mutation) + { + case "missing": entries.RemoveAt(0); break; + case "extra": entries.Add(entries[0]!.DeepClone()); break; + case "duplicate-role": entries[1]!["role"] = entries[0]!["role"]!.GetValue(); break; + case "unknown-role": entries[0]!["role"] = "user-chosen-path"; break; + case "unknown-disposition": entries[0]!["disposition"] = "ignored-error"; break; + case "unknown-property": entries[0]!["path"] = "C:\\foreign"; break; + case "wrong-type": entries[0]!["role"] = 0; break; + case "nested": entries[0]!["role"] = new JsonObject { ["path"] = "foreign" }; break; + case "null": root["directoryCleanup"] = null; break; + case "reordered": + JsonNode first = entries[0]!.DeepClone(); + entries[0] = entries[1]!.DeepClone(); + entries[1] = first; + break; + } + Assert.Throws(() => + InstallerMachineHelperResultCodec.Parse(Encoding.UTF8.GetBytes(root.ToJsonString(new() { WriteIndented = false })))); + } + + [Fact] + public void DuplicateNestedOrRootPropertiesAreRejected() + { + string canonical = CanonicalJson(); + Assert.Throws(() => InstallerMachineHelperResultCodec.Parse( + Encoding.UTF8.GetBytes(canonical.Replace("\"role\":", "\"role\":\"program-files-product\",\"role\":", StringComparison.Ordinal)))); + Assert.Throws(() => InstallerMachineHelperResultCodec.Parse( + Encoding.UTF8.GetBytes(canonical.Replace("\"directoryCleanup\":", "\"directoryCleanup\":[],\"directoryCleanup\":", StringComparison.Ordinal)))); + } + + [Fact] + public void ExistingRepliesKeepTheirCanonicalBytesWithoutAnOptionalReport() + { + InstallerMachineHelperCommand command = Command(InstallerOperation.Uninstall); + InstallerMachineHelperResult result = InstallerMachineHelperResult.Succeeded(command, command.ToDurableState()); + byte[] bytes = InstallerMachineHelperResultCodec.Serialize(result); + Assert.DoesNotContain("directoryCleanup", Encoding.UTF8.GetString(bytes), StringComparison.Ordinal); + Assert.Equal(result, InstallerMachineHelperResultCodec.Parse(bytes)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CoordinatorPublishesCleanupOnlyAfterSuccessfulClearConfirmation(bool failReload) + { + var report = new InstallerDirectoryCleanupReport(Entries(InstallerDirectoryCleanupDisposition.RetainedNonEmpty)); + var scenario = new InstallerScenario { DirectoryCleanupReport = report }; + if (failReload) + { + scenario.FinalClearResponseAction = _ => + { + scenario.Store.LoadAction = _ => throw new IOException("Cannot observe the cleared state."); + return Task.CompletedTask; + }; + } + using InstallerCoordinator coordinator = scenario.CreateCoordinator(); + + InstallerExecutionResult result = await coordinator.ExecuteAsync( + InstallerTestData.Request(InstallerOperation.Uninstall), null, CancellationToken.None); + + Assert.Equal(failReload ? InstallerExecutionOutcome.Uncertain : InstallerExecutionOutcome.Succeeded, result.Outcome); + Assert.Equal(failReload ? null : report, result.DirectoryCleanupReport); + } + + [Theory] + [InlineData(InstallerOperation.Install)] + [InlineData(InstallerOperation.Repair)] + public async Task CoordinatorRejectsCleanupReceiptsForOtherOperations(InstallerOperation operation) + { + var scenario = new InstallerScenario + { + DirectoryCleanupReport = new InstallerDirectoryCleanupReport(Entries()), + Environment = new(true, "1.0.0.0", false, null), + }; + using InstallerCoordinator coordinator = scenario.CreateCoordinator(); + InstallerExecutionResult result = await coordinator.ExecuteAsync(InstallerTestData.Request(operation), null, CancellationToken.None); + Assert.NotEqual(InstallerExecutionOutcome.Succeeded, result.Outcome); + Assert.Equal("installer.directory_cleanup.result_binding_invalid", result.DiagnosticCode); + Assert.Null(result.DirectoryCleanupReport); + } + + private static IEnumerable Entries( + InstallerDirectoryCleanupDisposition disposition = InstallerDirectoryCleanupDisposition.Deleted) => + Enum.GetValues().Select(role => new InstallerDirectoryCleanupEntry(role, disposition)); + + private static InstallerMachineHelperCommand Command(InstallerOperation operation, + InstallerMachineHelperVerb verb = InstallerMachineHelperVerb.Clear) + { + InstallerTransactionJournal journal = InstallerTestData.Journal(operation); + InstallerTransactionPhase[] phases = operation == InstallerOperation.Uninstall + ? [InstallerTransactionPhase.MachineRemovalAuthorized, InstallerTransactionPhase.MachineCommitted, + InstallerTransactionPhase.PackageCommitted, InstallerTransactionPhase.Verified] + : [InstallerTransactionPhase.MachineReserved, InstallerTransactionPhase.PackageCommitted, + InstallerTransactionPhase.MachineCommitted, InstallerTransactionPhase.Verified]; + foreach (InstallerTransactionPhase phase in phases) + { + journal = journal.TransitionTo(phase); + } + InstallerTransactionSnapshot state = InstallerTransactionSnapshot.Create(journal); + return InstallerMachineHelperCommand.Create(InstallerMachineHelperInvocation.Create(verb, state), state); + } + + private static string CanonicalJson() + { + InstallerMachineHelperCommand command = Command(InstallerOperation.Uninstall); + return Encoding.UTF8.GetString(InstallerMachineHelperResultCodec.Serialize( + InstallerMachineHelperResult.Succeeded(command, command.ToDurableState()) with + { + DirectoryCleanupReport = new InstallerDirectoryCleanupReport(Entries()), + })); + } +} diff --git a/ClashSharp/ClashSharp.Installer.Tests/InstallerMachineHelperAuthorityLoopCleanupTests.cs b/ClashSharp/ClashSharp.Installer.Tests/InstallerMachineHelperAuthorityLoopCleanupTests.cs new file mode 100644 index 0000000..c37050a --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Tests/InstallerMachineHelperAuthorityLoopCleanupTests.cs @@ -0,0 +1,291 @@ +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Machines; +using ClashSharp.Installer.Transactions; + +namespace ClashSharp.Installer.Tests; + +public sealed class InstallerMachineHelperAuthorityLoopCleanupTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UninstallClearAwaitsCleanupAfterAuthorityFinishesAndBeforeAnyReply(bool committedReplay) + { + List events = []; + InstallerTransactionSnapshot verified = VerifiedState(InstallerOperation.Uninstall); + InstallerMachineHelperCommand clear = Command(InstallerMachineHelperVerb.Clear, verified); + var store = new MemoryInstallerTransactionStore(events, committedReplay ? null : verified.Journal); + var operations = new RecordingOperations(events); + InstallerMachineHelperAuthoritySession authority = await CreateAuthorityAsync(clear, store, operations); + using var stream = new DuplexStream(); + using var cancellation = new CancellationTokenSource(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + InstallerDirectoryCleanupReport report = DeletedReport(); + int calls = 0; + + Task running = InstallerMachineHelperAuthorityLoop.RunAsync( + stream, authority, clear, async (command, result, token) => + { + Assert.Equal(clear, command); + Assert.Equal(verified, result.ValidateAgainst(command)); + Assert.Equal(cancellation.Token, token); + Assert.Null(store.Current); + Assert.Equal("journal.load", events[^1]); + Assert.Equal(committedReplay, !events.Contains("journal.clear")); + Assert.Contains(committedReplay ? "operation:VerifyCommittedReplay" : "operation:Execute", events); + Assert.Equal(0, stream.Output.Length); + calls++; + // The real finalizer releases these resources. Any subsequent authority read fails. + store.LoadAction = static _ => throw new ObjectDisposedException("terminal resources"); + entered.SetResult(); + await release.Task.WaitAsync(token); + return report; + }, cancellation.Token); + + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(running.IsCompleted); + Assert.Equal(0, stream.Output.Length); + } + finally + { + release.TrySetResult(); + } + await running.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(1, calls); + Assert.Null(store.Current); + Assert.DoesNotContain(events, static value => value.StartsWith("journal.save:", StringComparison.Ordinal)); + InstallerMachineHelperResult reply = await ReadReplyAsync(stream); + Assert.Equal(verified, reply.ValidateAgainst(clear)); + Assert.Equal(report, reply.DirectoryCleanupReport); + Assert.Equal(stream.Output.Length, stream.Output.Position); + } + + [Fact] + public async Task CleanupFailureAfterClearPropagatesWithoutSendingSuccess() + { + List events = []; + InstallerTransactionSnapshot verified = VerifiedState(InstallerOperation.Uninstall); + InstallerMachineHelperCommand clear = Command(InstallerMachineHelperVerb.Clear, verified); + var store = new MemoryInstallerTransactionStore(events, verified.Journal); + InstallerMachineHelperAuthoritySession authority = await CreateAuthorityAsync(clear, store, new RecordingOperations(events)); + using var stream = new DuplexStream(); + var failure = new IOException("Injected terminal ledger deletion failure."); + + IOException actual = await Assert.ThrowsAsync(() => InstallerMachineHelperAuthorityLoop.RunAsync( + stream, authority, clear, (_, _, _) => throw failure, CancellationToken.None)); + + Assert.Same(failure, actual); + Assert.Null(store.Current); + Assert.Contains("journal.clear", events); + Assert.Equal(0, stream.Output.Length); + } + + [Fact] + public async Task InvalidCleanupReportConstructionDoesNotSendAReply() + { + List events = []; + InstallerTransactionSnapshot verified = VerifiedState(InstallerOperation.Uninstall); + InstallerMachineHelperCommand clear = Command(InstallerMachineHelperVerb.Clear, verified); + var store = new MemoryInstallerTransactionStore(events, verified.Journal); + InstallerMachineHelperAuthoritySession authority = await CreateAuthorityAsync(clear, store, new RecordingOperations(events)); + using var stream = new DuplexStream(); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => + InstallerMachineHelperAuthorityLoop.RunAsync(stream, authority, clear, + static (_, _, _) => Task.FromResult(new([])), CancellationToken.None)); + + Assert.Equal("installer.directory_cleanup.report_invalid", failure.DiagnosticCode); + Assert.Null(store.Current); + Assert.Equal(0, stream.Output.Length); + } + + [Fact] + public async Task CancellationDuringCleanupDoesNotSendAReply() + { + List events = []; + InstallerTransactionSnapshot verified = VerifiedState(InstallerOperation.Uninstall); + InstallerMachineHelperCommand clear = Command(InstallerMachineHelperVerb.Clear, verified); + var store = new MemoryInstallerTransactionStore(events, verified.Journal); + InstallerMachineHelperAuthoritySession authority = await CreateAuthorityAsync(clear, store, new RecordingOperations(events)); + using var stream = new DuplexStream(); + using var cancellation = new CancellationTokenSource(); + + await Assert.ThrowsAnyAsync(() => InstallerMachineHelperAuthorityLoop.RunAsync( + stream, authority, clear, (_, _, token) => + { + cancellation.Cancel(); + token.ThrowIfCancellationRequested(); + return Task.FromResult(null); + }, cancellation.Token)); + + Assert.Null(store.Current); + Assert.Equal(0, stream.Output.Length); + } + + [Theory] + [InlineData(InstallerOperation.Install)] + [InlineData(InstallerOperation.Repair)] + public async Task InstallAndRepairClearDoNotInvokeDirectoryCleanup(InstallerOperation operation) + { + List events = []; + InstallerTransactionSnapshot verified = VerifiedState(operation); + InstallerMachineHelperCommand clear = Command(InstallerMachineHelperVerb.Clear, verified); + var store = new MemoryInstallerTransactionStore(events, verified.Journal); + InstallerMachineHelperAuthoritySession authority = await CreateAuthorityAsync(clear, store, new RecordingOperations(events)); + using var stream = new DuplexStream(); + + await InstallerMachineHelperAuthorityLoop.RunAsync(stream, authority, clear, + static (_, _, _) => throw new InvalidOperationException("Unexpected uninstall cleanup."), CancellationToken.None); + + InstallerMachineHelperResult reply = await ReadReplyAsync(stream); + Assert.Equal(verified, reply.ValidateAgainst(clear)); + Assert.Equal(InstallerMachineHelperOutcome.Succeeded, reply.Outcome); + Assert.Null(reply.DirectoryCleanupReport); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedClearDoesNotInvokeDirectoryCleanupOrClaimSuccess(bool committedReplay) + { + List events = []; + InstallerTransactionSnapshot verified = VerifiedState(InstallerOperation.Uninstall); + InstallerMachineHelperCommand clear = Command(InstallerMachineHelperVerb.Clear, verified); + var store = new MemoryInstallerTransactionStore(events, committedReplay ? null : verified.Journal); + var operations = new RecordingOperations(events, fail: true); + InstallerMachineHelperAuthoritySession authority = await CreateAuthorityAsync(clear, store, operations); + using var stream = new DuplexStream(); + + // A failed response does not terminate the protocol; this input ends after that response. + await Assert.ThrowsAsync(() => InstallerMachineHelperAuthorityLoop.RunAsync( + stream, authority, clear, + static (_, _, _) => throw new InvalidOperationException("Unexpected cleanup after failed verification."), + CancellationToken.None)); + + InstallerMachineHelperResult reply = await ReadReplyAsync(stream); + Assert.Equal(committedReplay ? InstallerMachineHelperOutcome.PostconditionFailed : InstallerMachineHelperOutcome.Failed, + reply.Outcome); + Assert.Null(reply.DirectoryCleanupReport); + Assert.Equal(verified, reply.ValidateAgainst(clear)); + Assert.DoesNotContain("journal.clear", events); + } + + [Fact] + public async Task UninstallVerifySkipsCleanupUntilItsFollowingClear() + { + List events = []; + InstallerTransactionSnapshot verified = VerifiedState(InstallerOperation.Uninstall); + InstallerTransactionSnapshot package = InstallerTransactionSnapshot.Create(verified.Journal with + { + Phase = InstallerTransactionPhase.PackageCommitted, + Generation = verified.Journal.Generation - 1, + }); + InstallerMachineHelperCommand verify = Command(InstallerMachineHelperVerb.Verify, package); + InstallerMachineHelperCommand clear = Command(InstallerMachineHelperVerb.Clear, verified); + var store = new MemoryInstallerTransactionStore(events, package.Journal); + InstallerMachineHelperAuthoritySession authority = await CreateAuthorityAsync(verify, store, new RecordingOperations(events)); + using var stream = new DuplexStream(); + await InstallerMachineHelperFraming.WriteCommandAsync(stream.Input, clear, CancellationToken.None); + stream.Input.Position = 0; + int calls = 0; + + await InstallerMachineHelperAuthorityLoop.RunAsync(stream, authority, verify, (command, _, _) => + { + Assert.Equal(clear, command); + Assert.True(stream.Output.Length > 0); + calls++; + return Task.FromResult(DeletedReport()); + }, CancellationToken.None); + + Assert.Equal(1, calls); + InstallerMachineHelperResult verifyReply = await ReadReplyAsync(stream); + Assert.Equal(verified, verifyReply.ValidateAgainst(verify)); + Assert.Null(verifyReply.DirectoryCleanupReport); + InstallerMachineHelperResult clearReply = await InstallerMachineHelperFraming.ReadResultAsync(stream.Output, CancellationToken.None); + Assert.Equal(verified, clearReply.ValidateAgainst(clear)); + Assert.Equal(DeletedReport(), clearReply.DirectoryCleanupReport); + } + + [Fact] + public async Task OptionalNullCleanupReportKeepsTheExistingClearReceipt() + { + List events = []; + InstallerTransactionSnapshot verified = VerifiedState(InstallerOperation.Uninstall); + InstallerMachineHelperCommand clear = Command(InstallerMachineHelperVerb.Clear, verified); + var store = new MemoryInstallerTransactionStore(events, verified.Journal); + InstallerMachineHelperAuthoritySession authority = await CreateAuthorityAsync(clear, store, new RecordingOperations(events)); + using var stream = new DuplexStream(); + + await InstallerMachineHelperAuthorityLoop.RunAsync(stream, authority, clear, + static (_, _, _) => Task.FromResult(null), CancellationToken.None); + + InstallerMachineHelperResult reply = await ReadReplyAsync(stream); + Assert.Equal(InstallerMachineHelperResult.Succeeded(clear, verified), reply); + } + + private static Task CreateAuthorityAsync( + InstallerMachineHelperCommand command, MemoryInstallerTransactionStore store, RecordingOperations operations) => + InstallerMachineHelperAuthoritySession.CreateAsync(command.ToInvocation(), store, operations, CancellationToken.None); + + private static InstallerDirectoryCleanupReport DeletedReport() => + new(Enum.GetValues().Select(static role => + new InstallerDirectoryCleanupEntry(role, InstallerDirectoryCleanupDisposition.Deleted))); + + private static InstallerMachineHelperCommand Command(InstallerMachineHelperVerb verb, InstallerTransactionSnapshot state) => + InstallerMachineHelperCommand.Create(InstallerMachineHelperInvocation.Create(verb, state), state); + + private static InstallerTransactionSnapshot VerifiedState(InstallerOperation operation) => + InstallerTransactionSnapshot.Create(InstallerTestData.Journal(operation, InstallerTransactionPhase.Verified, generation: 5)); + + private static Task ReadReplyAsync(DuplexStream stream) + { + stream.Output.Position = 0; + return InstallerMachineHelperFraming.ReadResultAsync(stream.Output, CancellationToken.None); + } + + private sealed class RecordingOperations(List events, bool fail = false) : IInstallerMachineHelperOperationExecutor + { + public Task ExecuteAsync(InstallerMachineHelperCommand command, + InstallerMachineHelperSessionDisposition disposition, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + events.Add($"operation:{disposition}"); + if (fail) { throw new InstallerProtocolException("installer.test.native_residue_present"); } + return Task.CompletedTask; + } + } + + private sealed class DuplexStream : Stream + { + internal MemoryStream Input { get; } = new(); + internal MemoryStream Output { get; } = new(); + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => Output.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => Output.FlushAsync(cancellationToken); + public override int Read(byte[] buffer, int offset, int count) => Input.Read(buffer, offset, count); + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + Input.ReadAsync(buffer, cancellationToken); + public override void Write(byte[] buffer, int offset, int count) => Output.Write(buffer, offset, count); + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => + Output.WriteAsync(buffer, cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + protected override void Dispose(bool disposing) + { + if (disposing) + { + Input.Dispose(); + Output.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/ClashSharp/ClashSharp.Installer.Tests/InstallerScenario.cs b/ClashSharp/ClashSharp.Installer.Tests/InstallerScenario.cs index 87be387..a26d96a 100644 --- a/ClashSharp/ClashSharp.Installer.Tests/InstallerScenario.cs +++ b/ClashSharp/ClashSharp.Installer.Tests/InstallerScenario.cs @@ -232,7 +232,9 @@ public async Task VerifyAsync( return FinalResultFactory?.Invoke(durableState) ?? committed; } - public async Task ClearVerifiedAsync( + internal InstallerDirectoryCleanupReport? DirectoryCleanupReport { get; set; } + + public async Task ClearVerifiedAsync( InstallerRequest request, IInstallerReleaseLease release, InstallerTransactionSnapshot verifiedState, @@ -250,7 +252,7 @@ await Store.ClearVerifiedAsync( await FinalClearResponseAction(cancellationToken); } - return verifiedState; + return new InstallerClearReceipt(verifiedState, DirectoryCleanupReport); } } diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsElevatedMachineAdapterTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsElevatedMachineAdapterTests.cs index 32acd62..866767d 100644 --- a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsElevatedMachineAdapterTests.cs +++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsElevatedMachineAdapterTests.cs @@ -172,7 +172,7 @@ public async Task FinalClearUsesExactVerifiedJournalAsTheHelperReceipt( request, InstallerTransactionPhase.Verified); - InstallerTransactionSnapshot receipt = await adapter.ClearVerifiedAsync( + InstallerClearReceipt receipt = await adapter.ClearVerifiedAsync( request, lease, verified, @@ -182,7 +182,28 @@ public async Task FinalClearUsesExactVerifiedJournalAsTheHelperReceipt( Assert.Equal(InstallerMachineHelperVerb.Clear, invocation.Verb); invocation.ValidateAgainst(verified); Assert.Equal(verified, Assert.Single(broker.Commands).ToDurableState()); - Assert.Equal(verified, receipt); + Assert.Equal(verified, receipt.State); + Assert.Null(receipt.DirectoryCleanupReport); + } + + [Fact] + public async Task SuccessfulClearPreservesTheAuthenticatedDirectoryObservations() + { + WindowsPayloadFixture.AssertWindows11X64(); + using var fixture = new WindowsPayloadFixture(); + InstallerRequest request = fixture.Request(InstallerOperation.Uninstall); + await using WindowsInstallerReleaseLease lease = fixture.Lock(request); + var report = new InstallerDirectoryCleanupReport(Enum.GetValues() + .Select(role => new InstallerDirectoryCleanupEntry(role, InstallerDirectoryCleanupDisposition.RetainedNonEmpty))); + var broker = new RecordingBroker(command => Task.FromResult( + InstallerMachineHelperResult.Succeeded(command, SuccessfulState(command)) with { DirectoryCleanupReport = report })); + var adapter = new WindowsElevatedMachineAdapter(broker, () => request.TargetSid); + InstallerTransactionSnapshot verified = Snapshot(request, InstallerTransactionPhase.Verified); + + InstallerClearReceipt receipt = await adapter.ClearVerifiedAsync(request, lease, verified, CancellationToken.None); + + Assert.Equal(verified, receipt.State); + Assert.Equal(report, receipt.DirectoryCleanupReport); } [Fact] diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsRetiredUninstallCoordinatorTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsRetiredUninstallCoordinatorTests.cs index a96bbc4..1ed5f02 100644 --- a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsRetiredUninstallCoordinatorTests.cs +++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsRetiredUninstallCoordinatorTests.cs @@ -109,7 +109,7 @@ public Task CommitPackageAsync(InstallerRequest re Send(InstallerMachineHelperVerb.CommitPackage, state, token); public Task VerifyAsync(InstallerRequest request, IInstallerReleaseLease release, InstallerTransactionSnapshot state, CancellationToken token) => Send(InstallerMachineHelperVerb.Verify, state, token); - public Task ClearVerifiedAsync(InstallerRequest request, IInstallerReleaseLease release, InstallerTransactionSnapshot state, CancellationToken token) => - Send(InstallerMachineHelperVerb.Clear, state, token); + public async Task ClearVerifiedAsync(InstallerRequest request, IInstallerReleaseLease release, InstallerTransactionSnapshot state, CancellationToken token) => + new(await Send(InstallerMachineHelperVerb.Clear, state, token)); } } diff --git a/ClashSharp/ClashSharp.Installer.Windows/Machines/WindowsElevatedMachineAdapter.cs b/ClashSharp/ClashSharp.Installer.Windows/Machines/WindowsElevatedMachineAdapter.cs index d63ed5a..7a7ff27 100644 --- a/ClashSharp/ClashSharp.Installer.Windows/Machines/WindowsElevatedMachineAdapter.cs +++ b/ClashSharp/ClashSharp.Installer.Windows/Machines/WindowsElevatedMachineAdapter.cs @@ -90,19 +90,31 @@ public Task VerifyAsync( cancellationToken); /// - public Task ClearVerifiedAsync( + public async Task ClearVerifiedAsync( InstallerRequest request, IInstallerReleaseLease release, InstallerTransactionSnapshot verifiedState, - CancellationToken cancellationToken) => - ExecuteAsync( + CancellationToken cancellationToken) + { + InstallerMachineHelperResult result = await ExecuteResultAsync( InstallerMachineHelperVerb.Clear, request, release, verifiedState, - cancellationToken); + cancellationToken).ConfigureAwait(false); + return new InstallerClearReceipt(result.ToResultDurableState(), result.DirectoryCleanupReport); + } private async Task ExecuteAsync( + InstallerMachineHelperVerb verb, + InstallerRequest request, + IInstallerReleaseLease release, + InstallerTransactionSnapshot durableState, + CancellationToken cancellationToken) => + (await ExecuteResultAsync(verb, request, release, durableState, cancellationToken) + .ConfigureAwait(false)).ToResultDurableState(); + + private async Task ExecuteResultAsync( InstallerMachineHelperVerb verb, InstallerRequest request, IInstallerReleaseLease release, @@ -153,13 +165,13 @@ private async Task ExecuteAsync( exception); } - InstallerTransactionSnapshot helperState = result.ValidateAgainst(command); + _ = result.ValidateAgainst(command); if (result.Outcome != InstallerMachineHelperOutcome.Succeeded) { throw new InstallerProtocolException(result.DiagnosticCode); } - return helperState; + return result; } private WindowsInstallerReleaseLease ValidateBoundary( From 3b95e93456918d58eb8a437a2d8905f642b2a3d6 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Sat, 12 Sep 2026 18:41:03 +0800 Subject: [PATCH 19/22] wip(installer): preserve unfinished directory cleanup at pause --- ...wsInstallerCleanupTransactionStoreTests.cs | 408 +++++++++++++++ .../WindowsInstallerDirectoryLedgerTests.cs | 466 ++++++++++++++++++ ...WindowsInstallerCleanupTransactionStore.cs | 131 +++++ .../WindowsInstallerDirectoryCleanupLayout.cs | 54 ++ .../WindowsInstallerDirectoryLedger.cs | 179 +++++++ ...dowsInstallerDirectoryLedgerPersistence.cs | 355 +++++++++++++ .../WindowsInstallerDirectoryNative.cs | 2 +- ...WindowsInstallerEmptyDirectoryFinalizer.cs | 256 ++++++++++ .../WindowsInstallerOwnedDirectoryCreation.cs | 126 +++++ ...09-12-installer-empty-directory-cleanup.md | 57 +++ 10 files changed, 2033 insertions(+), 1 deletion(-) create mode 100644 ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerCleanupTransactionStoreTests.cs create mode 100644 ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerDirectoryLedgerTests.cs create mode 100644 ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerCleanupTransactionStore.cs create mode 100644 ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryCleanupLayout.cs create mode 100644 ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedger.cs create mode 100644 ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedgerPersistence.cs create mode 100644 ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerEmptyDirectoryFinalizer.cs create mode 100644 ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerOwnedDirectoryCreation.cs create mode 100644 docs/design/2026-09-12-installer-empty-directory-cleanup.md diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerCleanupTransactionStoreTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerCleanupTransactionStoreTests.cs new file mode 100644 index 0000000..4ac9dff --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerCleanupTransactionStoreTests.cs @@ -0,0 +1,408 @@ +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; +using ClashSharp.Installer.Windows.Files; +using ClashSharp.Installer.Windows.Transactions; + +namespace ClashSharp.Installer.Windows.Tests; + +public sealed class WindowsInstallerCleanupTransactionStoreTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LoadRecoversOnlyTheExactVerifiedUninstall(bool activePresent) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, activePresent ? verified : null); + var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified)); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + Assert.Equal(verified, await store.LoadAsync(CancellationToken.None)); + Assert.Equal(0, inner.SaveCalls); + Assert.Equal(0, ledger.SaveCalls); + } + + [Theory] + [InlineData("transaction")] + [InlineData("target")] + [InlineData("version")] + [InlineData("payload")] + public async Task LoadRejectsConflictingActiveAndTerminalIdentity(string field) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, ChangeIdentity(verified, field)); + var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified)); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => store.LoadAsync(CancellationToken.None)); + + Assert.Equal("installer.directory_ledger.terminal_identity_mismatch", failure.DiagnosticCode); + Assert.Equal(0, inner.ClearCalls); + Assert.Equal(0, ledger.SaveCalls); + } + + [Fact] + public async Task ClearPersistsAndReobservesTerminalBeforeDeletingJournalAndOnlySuppressesThisSession() + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + WindowsInstallerDirectoryLedger owned = OwnedLedger(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, owned); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None); + + Assert.Equal(new[] { "inner.load", "ledger.load", "ledger.save", "ledger.load", "inner.clear", "inner.load", "ledger.load" }, events); + Assert.Null(inner.Current); + Assert.Equal(verified, ledger.Current!.Terminal); + Assert.Equal(owned.Directories, ledger.Current.Directories); + Assert.Equal(owned.Generation + 1, ledger.Current.Generation); + Assert.Null(await store.LoadAsync(CancellationToken.None)); + var reopened = new WindowsInstallerCleanupTransactionStore(inner, ledger); + Assert.Equal(verified, await reopened.LoadAsync(CancellationToken.None)); + Assert.Equal(0, inner.SaveCalls); + Assert.Equal(0, ledger.DeleteCalls); + } + + [Fact] + public async Task MissingLedgerBecomesTerminalWithoutInventingDirectoryOwnership() + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, null); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None); + + Assert.NotNull(ledger.Current); + Assert.Empty(ledger.Current.Directories); + Assert.Equal(verified, ledger.Current.Terminal); + Assert.Equal(1, ledger.Current.Generation); + } + + [Fact] + public async Task ReopenedTerminalCanReverifyAndClearWithoutRecreatingAnOrdinaryJournal() + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, null); + var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified)); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + Assert.Equal(verified, await store.SaveAsync(verified.Journal, verified.ContentHash, CancellationToken.None)); + await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None); + + Assert.Null(await store.LoadAsync(CancellationToken.None)); + Assert.Equal(verified, await new WindowsInstallerCleanupTransactionStore(inner, ledger).LoadAsync(CancellationToken.None)); + Assert.Equal(0, inner.SaveCalls); + Assert.Equal(0, inner.ClearCalls); + Assert.Equal(0, ledger.DeleteCalls); + } + + [Theory] + [InlineData("missing-hash")] + [InlineData("wrong-hash")] + [InlineData("transaction")] + [InlineData("target")] + [InlineData("version")] + [InlineData("payload")] + [InlineData("phase")] + [InlineData("operation")] + public async Task TerminalOnlySaveCannotAdoptAnotherTransactionOrRegress(string mutation) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + InstallerTransactionJournal requested = mutation switch + { + "missing-hash" or "wrong-hash" => verified.Journal, + "phase" => verified.Journal with { Phase = InstallerTransactionPhase.PackageCommitted, Generation = 4 }, + "operation" => verified.Journal with { Operation = InstallerOperation.Install }, + _ => ChangeIdentity(verified, mutation).Journal, + }; + string? expectedHash = mutation switch { "missing-hash" => null, "wrong-hash" => new string('d', 64), _ => verified.ContentHash }; + var inner = new MemoryStore(events, null); + var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified)); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => + store.SaveAsync(requested, expectedHash, CancellationToken.None)); + + Assert.Equal("installer.transaction.write_conflict", failure.DiagnosticCode); + Assert.Equal(0, inner.SaveCalls); + Assert.Equal(0, ledger.SaveCalls); + Assert.Equal(verified, ledger.Current!.Terminal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ClearRejectsWrongIdentityWithoutChangingEitherStore(bool wrongHash) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, null); + var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified)); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => store.ClearVerifiedAsync( + wrongHash ? verified.Journal.TransactionId : new string('d', 64), + wrongHash ? new string('d', 64) : verified.ContentHash, CancellationToken.None)); + + Assert.Equal("installer.transaction.clear_conflict", failure.DiagnosticCode); + Assert.Equal(0, inner.ClearCalls); + Assert.Equal(0, ledger.SaveCalls); + } + + [Theory] + [InlineData("before-terminal", false)] + [InlineData("after-terminal", false)] + [InlineData("before-clear", false)] + [InlineData("after-clear", true)] + [InlineData("after-clear-observation", true)] + public async Task IoFailurePreservesTheActualDurableCutPointForReopen(string cut, bool journalDeleted) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, OwnedLedger()); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + var injected = new IOException("Injected cleanup checkpoint failure."); + Action fail = () => throw injected; + Inject(cut, inner, ledger, fail); + + IOException actual = await Assert.ThrowsAsync(() => + store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None)); + + Assert.Same(injected, actual); + Assert.Equal(journalDeleted ? null : verified, inner.Current); + Assert.Equal(cut == "before-terminal" ? null : verified, ledger.Current!.Terminal); + inner.ResetFaults(); + ledger.ResetFaults(); + Assert.Equal(verified, await store.LoadAsync(CancellationToken.None)); + Assert.Equal(verified, await new WindowsInstallerCleanupTransactionStore(inner, ledger).LoadAsync(CancellationToken.None)); + Assert.Equal(0, ledger.DeleteCalls); + } + + [Theory] + [InlineData("before-terminal", false)] + [InlineData("after-terminal", false)] + [InlineData("before-clear", false)] + [InlineData("after-clear", true)] + [InlineData("after-clear-observation", true)] + public async Task CancellationNeverDiscardsTerminalRecovery(string cut, bool journalDeleted) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, OwnedLedger()); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + using var cancellation = new CancellationTokenSource(); + Inject(cut, inner, ledger, () => + { + cancellation.Cancel(); + cancellation.Token.ThrowIfCancellationRequested(); + }); + + await Assert.ThrowsAnyAsync(() => + store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, cancellation.Token)); + + Assert.Equal(journalDeleted ? null : verified, inner.Current); + Assert.Equal(cut == "before-terminal" ? null : verified, ledger.Current!.Terminal); + inner.ResetFaults(); + ledger.ResetFaults(); + Assert.Equal(verified, await new WindowsInstallerCleanupTransactionStore(inner, ledger).LoadAsync(CancellationToken.None)); + Assert.Equal(0, ledger.DeleteCalls); + } + + [Fact] + public async Task UnobservedTerminalWriteCannotDeleteTheActiveJournal() + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, OwnedLedger()); + ledger.AfterSave = () => ledger.Current = null; + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => + store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None)); + + Assert.Equal("installer.directory_ledger.terminal_not_observed", failure.DiagnosticCode); + Assert.Equal(verified, inner.Current); + Assert.Equal(0, inner.ClearCalls); + } + + [Theory] + [InlineData(InstallerOperation.Install)] + [InlineData(InstallerOperation.Repair)] + public async Task OtherOperationsUseTheInnerClearWithoutCreatingTerminalState(InstallerOperation operation) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(operation); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, OwnedLedger()); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None); + + Assert.Null(await store.LoadAsync(CancellationToken.None)); + Assert.Null(ledger.Current!.Terminal); + Assert.Equal(0, ledger.SaveCalls); + Assert.Equal(1, inner.ClearCalls); + } + + [Fact] + public async Task OrdinarySavePreservesTheOriginalJournalCas() + { + List events = []; + InstallerTransactionSnapshot prepared = InstallerTransactionSnapshot.Create(Verified(InstallerOperation.Install).Journal with + { + Phase = InstallerTransactionPhase.Prepared, + Generation = 1, + }); + var inner = new MemoryStore(events, null); + var ledger = new MemoryLedger(events, OwnedLedger()); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + Assert.Equal(prepared, await store.SaveAsync(prepared.Journal, null, CancellationToken.None)); + Assert.Equal(prepared, await store.LoadAsync(CancellationToken.None)); + await Assert.ThrowsAsync(() => store.SaveAsync(prepared.Journal, null, CancellationToken.None)); + Assert.Equal(0, ledger.SaveCalls); + } + + [Fact] + public async Task ClearedSessionDoesNotSuppressAReplacementTerminal() + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, OwnedLedger()); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None); + ledger.Current = OwnedLedger().BeginTerminal(ChangeIdentity(verified, "transaction")); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => store.LoadAsync(CancellationToken.None)); + + Assert.Equal("installer.directory_ledger.terminal_identity_mismatch", failure.DiagnosticCode); + } + + private static void Inject(string cut, MemoryStore inner, MemoryLedger ledger, Action failure) + { + switch (cut) + { + case "before-terminal": ledger.BeforeSave = failure; break; + case "after-terminal": ledger.AfterSave = failure; break; + case "before-clear": inner.BeforeClear = failure; break; + case "after-clear": inner.AfterClear = failure; break; + case "after-clear-observation": inner.AfterClear = () => ledger.BeforeLoad = failure; break; + default: throw new ArgumentOutOfRangeException(nameof(cut)); + } + } + + private static InstallerTransactionSnapshot Verified(InstallerOperation operation = InstallerOperation.Uninstall) => + InstallerTransactionSnapshot.Create(new InstallerTransactionJournal(InstallerTransactionJournal.CurrentSchema, + new string('a', 64), operation, "S-1-5-21-100-200-300-1001", false, "1.0.0.0", new string('b', 64), + InstallerTransactionPhase.Verified, 5)); + + private static InstallerTransactionSnapshot ChangeIdentity(InstallerTransactionSnapshot state, string field) => + InstallerTransactionSnapshot.Create(field switch + { + "transaction" => state.Journal with { TransactionId = new string('c', 64) }, + "target" => state.Journal with { TargetSid = "S-1-5-21-100-200-300-1002" }, + "version" => state.Journal with { ExpectedPackageVersion = "1.0.1.0" }, + "payload" => state.Journal with { InstallerPayloadSha256 = new string('c', 64) }, + _ => throw new ArgumentOutOfRangeException(nameof(field)), + }); + + private static WindowsInstallerDirectoryLedger OwnedLedger() => WindowsInstallerDirectoryLedger.Empty.RecordCreated( + InstallerDirectoryRole.InstallerVersion, new WindowsFileIdentity(11, 22)); + + private sealed class MemoryStore(List events, InstallerTransactionSnapshot? initial) : IInstallerTransactionStore + { + internal InstallerTransactionSnapshot? Current { get; set; } = initial; + internal int SaveCalls { get; private set; } + internal int ClearCalls { get; private set; } + internal Action? BeforeClear { get; set; } + internal Action? AfterClear { get; set; } + + public Task LoadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + events.Add("inner.load"); + return Task.FromResult(Current); + } + + public Task SaveAsync(InstallerTransactionJournal journal, string? expectedCurrentHash, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Current?.ContentHash != expectedCurrentHash) + { + throw new InstallerProtocolException("installer.transaction.write_conflict"); + } + events.Add("inner.save"); + SaveCalls++; + Current = InstallerTransactionSnapshot.Create(journal); + return Task.FromResult(Current); + } + + public Task ClearVerifiedAsync(string transactionId, string expectedCurrentHash, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.NotNull(Current); + Assert.Equal(Current.Journal.TransactionId, transactionId); + Assert.Equal(Current.ContentHash, expectedCurrentHash); + events.Add("inner.clear"); + ClearCalls++; + BeforeClear?.Invoke(); + Current = null; + AfterClear?.Invoke(); + return Task.CompletedTask; + } + + internal void ResetFaults() { BeforeClear = null; AfterClear = null; } + } + + private sealed class MemoryLedger(List events, WindowsInstallerDirectoryLedger? initial) : IWindowsInstallerDirectoryLedgerPersistence + { + internal WindowsInstallerDirectoryLedger? Current { get; set; } = initial; + internal int SaveCalls { get; private set; } + internal int DeleteCalls { get; private set; } + internal Action? BeforeLoad { get; set; } + internal Action? BeforeSave { get; set; } + internal Action? AfterSave { get; set; } + + public Task LoadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + events.Add("ledger.load"); + BeforeLoad?.Invoke(); + return Task.FromResult(Current); + } + + public Task SaveAsync(WindowsInstallerDirectoryLedger? expected, WindowsInstallerDirectoryLedger desired, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.Same(expected, Current); + events.Add("ledger.save"); + SaveCalls++; + BeforeSave?.Invoke(); + Current = desired; + AfterSave?.Invoke(); + return Task.CompletedTask; + } + + public Task DeleteAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken) + { + DeleteCalls++; + throw new InvalidOperationException("The transaction store must not delete the terminal ledger."); + } + + internal void ResetFaults() { BeforeLoad = null; BeforeSave = null; AfterSave = null; } + } +} diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerDirectoryLedgerTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerDirectoryLedgerTests.cs new file mode 100644 index 0000000..3acbb93 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerDirectoryLedgerTests.cs @@ -0,0 +1,466 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; +using ClashSharp.Installer.Windows.Files; +using ClashSharp.Installer.Windows.Transactions; + +namespace ClashSharp.Installer.Windows.Tests; + +public sealed class WindowsInstallerDirectoryLedgerTests +{ + private const string ProgramFiles = @"C:\Program Files"; + private const string ProgramData = @"C:\ProgramData"; + private const string TargetSid = "S-1-5-21-100-200-300-1001"; + + [Fact] + public void RecordCreatedIsImmutableOrderedAndIdempotentForTheExactObject() + { + WindowsInstallerDirectoryLedger empty = WindowsInstallerDirectoryLedger.Empty; + WindowsInstallerDirectoryLedger first = empty.RecordCreated(InstallerDirectoryRole.AuthorityVersion, new(12, 22)); + WindowsInstallerDirectoryLedger second = first.RecordCreated(InstallerDirectoryRole.ProgramFilesProduct, new(12, 11)); + + Assert.Empty(empty.Directories); + Assert.Single(first.Directories); + Assert.Equal(2, second.Generation); + Assert.Equal([InstallerDirectoryRole.ProgramFilesProduct, InstallerDirectoryRole.AuthorityVersion], + second.Directories.Select(entry => entry.Role)); + Assert.Same(second, second.RecordCreated(InstallerDirectoryRole.AuthorityVersion, new(12, 22))); + + WindowsInstallerDirectoryLedger replaced = second.RecordCreated(InstallerDirectoryRole.AuthorityVersion, new(99, 33)); + Assert.Equal(3, replaced.Generation); + Assert.Equal(new WindowsFileIdentity(99, 33), replaced.Directories[1].Identity); + Assert.Equal(new WindowsFileIdentity(12, 22), second.Directories[1].Identity); + Assert.Equal(2, replaced.Directories.Count); + } + + [Fact] + public void ConstructorCopiesCallerEntriesAndDoesNotSortAnInvalidSequence() + { + WindowsInstallerOwnedDirectory[] entries = [new(InstallerDirectoryRole.InstallerRoot, new(1, 1))]; + var ledger = new WindowsInstallerDirectoryLedger(1, entries, null); + entries[0] = new(InstallerDirectoryRole.AuthorityRoot, new(2, 2)); + Assert.Equal(InstallerDirectoryRole.InstallerRoot, ledger.Directories[0].Role); + Assert.Throws(() => new WindowsInstallerDirectoryLedger(2, + [entries[0], new(InstallerDirectoryRole.ProgramFilesProduct, new(1, 1))], null)); + Assert.Throws(() => new WindowsInstallerDirectoryLedger(2, [entries[0], entries[0]], null)); + } + + [Fact] + public void InvalidIdentitiesAndGenerationAreRejectedBeforeProducingANewLedger() + { + WindowsInstallerDirectoryLedger empty = WindowsInstallerDirectoryLedger.Empty; + Assert.Throws(() => empty.RecordCreated((InstallerDirectoryRole)99, new(1, 1))); + Assert.Throws(() => empty.RecordCreated(InstallerDirectoryRole.InstallerRoot, new(1, 0))); + Assert.Throws(() => new WindowsInstallerDirectoryLedger(-1, [], null)); + Assert.Throws(() => new WindowsInstallerDirectoryLedger(7, + Enumerable.Repeat(new WindowsInstallerOwnedDirectory(InstallerDirectoryRole.InstallerRoot, new(1, 1)), 7), null)); + var maximum = new WindowsInstallerDirectoryLedger(long.MaxValue, [], null); + Assert.Throws(() => maximum.RecordCreated(InstallerDirectoryRole.InstallerRoot, new(1, 1))); + Assert.Throws(() => maximum.BeginTerminal(Verified())); + Assert.Empty(maximum.Directories); + Assert.Null(maximum.Terminal); + } + + [Fact] + public void BeginTerminalIsIdempotentOnlyForTheSameCanonicalVerifiedUninstall() + { + WindowsInstallerDirectoryLedger created = WindowsInstallerDirectoryLedger.Empty.RecordCreated( + InstallerDirectoryRole.InstallerRoot, new(10, 20)); + InstallerTransactionSnapshot state = Verified(); + WindowsInstallerDirectoryLedger terminal = created.BeginTerminal(state); + Assert.Null(created.Terminal); + Assert.Equal(created.Generation + 1, terminal.Generation); + Assert.Equal(created.Directories, terminal.Directories); + Assert.Same(terminal, terminal.BeginTerminal(InstallerTransactionSnapshot.Create(state.Journal))); + + foreach (InstallerTransactionJournal changed in new[] + { + state.Journal with { TransactionId = new string('b', 64) }, + state.Journal with { TargetSid = "S-1-5-21-100-200-300-1002" }, + state.Journal with { InstallerPayloadSha256 = new string('c', 64) }, + state.Journal with { ExpectedPackageVersion = "1.0.1.0" }, + }) + { + InstallerProtocolException failure = Assert.Throws(() => + terminal.BeginTerminal(InstallerTransactionSnapshot.Create(changed))); + Assert.Equal("installer.directory_ledger.terminal_identity_mismatch", failure.DiagnosticCode); + } + } + + [Theory] + [InlineData(InstallerOperation.Install, InstallerTransactionPhase.Verified, 5)] + [InlineData(InstallerOperation.Repair, InstallerTransactionPhase.Verified, 5)] + [InlineData(InstallerOperation.Uninstall, InstallerTransactionPhase.Prepared, 1)] + public void TerminalRequiresAnIndependentlyValidVerifiedUninstall( + InstallerOperation operation, InstallerTransactionPhase phase, int generation) + { + InstallerTransactionJournal journal = Verified().Journal with { Operation = operation, Phase = phase, Generation = generation }; + InstallerProtocolException failure = Assert.Throws(() => + WindowsInstallerDirectoryLedger.Empty.BeginTerminal(InstallerTransactionSnapshot.Create(journal))); + Assert.Equal("installer.directory_ledger.terminal_not_verified_uninstall", failure.DiagnosticCode); + } + + [Fact] + public void CodecRoundTripsAllRolesAndTheExactTerminalSnapshot() + { + WindowsInstallerDirectoryLedger ledger = CompleteLedger().BeginTerminal(Verified()); + byte[] serialized = WindowsInstallerDirectoryLedgerCodec.Serialize(ledger); + WindowsInstallerDirectoryLedger parsed = WindowsInstallerDirectoryLedgerCodec.Parse(serialized); + Assert.Equal(ledger.Generation, parsed.Generation); + Assert.Equal(ledger.Directories, parsed.Directories); + Assert.Equal(ledger.Terminal, parsed.Terminal); + Assert.Equal(serialized, WindowsInstallerDirectoryLedgerCodec.Serialize(parsed)); + Assert.True(serialized.Length < WindowsInstallerDirectoryLedgerCodec.MaximumDocumentBytes); + } + + [Theory] + [InlineData("whitespace")] + [InlineData("property-order")] + [InlineData("escaping")] + public void NoncanonicalBytesAreRejectedBeforeTheyCanPoisonCompareAndSwap(string variation) + { + byte[] canonical = WindowsInstallerDirectoryLedgerCodec.Serialize(CompleteLedger()); + string json = Encoding.UTF8.GetString(canonical); + string changed; + if (variation == "property-order") + { + var reordered = new JsonObject(); + foreach (KeyValuePair property in JsonNode.Parse(canonical)!.AsObject().Reverse()) + { + reordered.Add(property.Key, property.Value?.DeepClone()); + } + changed = reordered.ToJsonString(); + } + else + { + changed = variation == "whitespace" ? " " + json + : json.Replace("ProgramFilesProduct", "ProgramFiles\\u0050roduct", StringComparison.Ordinal); + } + + // Save(expected) compares Serialize(expected) to the actual file bytes. Accepting a + // semantically equivalent, different byte sequence here would strand future updates. + Assert.NotEqual(json, changed); + Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(Utf8(changed))); + } + + [Theory] + [InlineData("unknown-root")] + [InlineData("missing-root")] + [InlineData("duplicate-root")] + [InlineData("schema")] + [InlineData("generation-string")] + [InlineData("negative-generation")] + [InlineData("generation-overflow")] + [InlineData("null-entries")] + [InlineData("unknown-entry")] + [InlineData("duplicate-entry-property")] + [InlineData("unknown-role")] + [InlineData("numeric-role")] + [InlineData("wrong-case-role")] + [InlineData("zero-id")] + [InlineData("negative-id")] + [InlineData("id-overflow")] + [InlineData("volume-overflow")] + [InlineData("duplicate-role")] + [InlineData("unsorted-role")] + [InlineData("too-many-roles")] + [InlineData("trailing-comma")] + [InlineData("comment")] + [InlineData("non-object")] + [InlineData("too-deep")] + public void CodecRejectsAmbiguousOrOutOfScopeDocuments(string mutation) + { + JsonObject root = JsonNode.Parse(WindowsInstallerDirectoryLedgerCodec.Serialize(CompleteLedger()))!.AsObject(); + JsonArray entries = root["createdDirectories"]!.AsArray(); + JsonObject first = entries[0]!.AsObject(); + byte[]? raw = null; + switch (mutation) + { + case "unknown-root": root["extra"] = true; break; + case "missing-root": root.Remove("terminal"); break; + case "duplicate-root": raw = Utf8(root.ToJsonString().Replace("\"schema\":1", "\"schema\":1,\"schema\":1", StringComparison.Ordinal)); break; + case "schema": root["schema"] = 2; break; + case "generation-string": root["generation"] = "6"; break; + case "negative-generation": root["generation"] = -1; break; + case "generation-overflow": root["generation"] = ulong.MaxValue; break; + case "null-entries": root["createdDirectories"] = null; break; + case "unknown-entry": first["path"] = @"C:\foreign"; break; + case "duplicate-entry-property": raw = Utf8(root.ToJsonString().Replace("\"fileIndex\":1", "\"fileIndex\":1,\"fileIndex\":1", StringComparison.Ordinal)); break; + case "unknown-role": first["role"] = "ForeignRoot"; break; + case "numeric-role": first["role"] = "0"; break; + case "wrong-case-role": first["role"] = "programFilesProduct"; break; + case "zero-id": first["fileIndex"] = 0; break; + case "negative-id": first["fileIndex"] = -1; break; + case "id-overflow": first["fileIndex"] = JsonNode.Parse("18446744073709551616"); break; + case "volume-overflow": first["volumeSerialNumber"] = ulong.MaxValue; break; + case "duplicate-role": entries[1] = first.DeepClone(); break; + case "unsorted-role": + JsonNode copy = first.DeepClone(); + entries[0] = entries[1]!.DeepClone(); + entries[1] = copy; + break; + case "too-many-roles": entries.Add(first.DeepClone()); break; + case "trailing-comma": raw = Utf8(root.ToJsonString()[..^1] + ",}"); break; + case "comment": raw = Utf8("/*metadata*/" + root.ToJsonString()); break; + case "non-object": raw = Utf8("[]"); break; + case "too-deep": root["terminal"] = JsonNode.Parse("{\"nested\":{\"a\":{\"b\":{\"c\":1}}}}"); break; + default: throw new ArgumentOutOfRangeException(nameof(mutation)); + } + Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(raw ?? Utf8(root.ToJsonString()))); + } + + [Theory] + [InlineData("wrong-hash")] + [InlineData("uppercase-hash")] + [InlineData("invalid-base64")] + [InlineData("noncanonical-base64")] + [InlineData("noncanonical-journal")] + [InlineData("invalid-nonce")] + [InlineData("uppercase-nonce")] + [InlineData("wrong-phase")] + [InlineData("wrong-operation")] + [InlineData("extra-terminal-property")] + [InlineData("duplicate-terminal-property")] + public void TerminalBytesMustMatchTheirCanonicalNoncePhaseAndHash(string mutation) + { + JsonObject root = JsonNode.Parse(WindowsInstallerDirectoryLedgerCodec.Serialize( + CompleteLedger().BeginTerminal(Verified())))!.AsObject(); + JsonObject terminal = root["terminal"]!.AsObject(); + byte[]? raw = null; + switch (mutation) + { + case "wrong-hash": terminal["contentHash"] = new string('0', 64); break; + case "uppercase-hash": terminal["contentHash"] = terminal["contentHash"]!.GetValue().ToUpperInvariant(); break; + case "invalid-base64": terminal["journalBase64"] = "%%%"; break; + case "noncanonical-base64": terminal["journalBase64"] = " " + terminal["journalBase64"]!.GetValue(); break; + case "extra-terminal-property": terminal["path"] = @"C:\foreign"; break; + case "duplicate-terminal-property": + raw = Utf8(root.ToJsonString().Replace("\"contentHash\":", "\"contentHash\":\"ignored\",\"contentHash\":", StringComparison.Ordinal)); + break; + default: + JsonObject journal = JsonNode.Parse(Convert.FromBase64String(terminal["journalBase64"]!.GetValue()))!.AsObject(); + switch (mutation) + { + case "invalid-nonce": journal["transactionId"] = new string('a', 63); break; + case "uppercase-nonce": journal["transactionId"] = new string('A', 64); break; + case "wrong-phase": journal["phase"] = "prepared"; journal["generation"] = 1; break; + case "wrong-operation": journal["operation"] = "install"; break; + case "noncanonical-journal": break; + default: throw new ArgumentOutOfRangeException(nameof(mutation)); + } + byte[] bytes = Utf8((mutation == "noncanonical-journal" ? " " : string.Empty) + journal.ToJsonString()); + terminal["journalBase64"] = Convert.ToBase64String(bytes); + terminal["contentHash"] = Convert.ToHexStringLower(SHA256.HashData(bytes)); + break; + } + Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(raw ?? Utf8(root.ToJsonString()))); + } + + [Fact] + public void DocumentSizeBoundaryIsCheckedBeforeJsonParsing() + { + int maximum = WindowsInstallerDirectoryLedgerCodec.MaximumDocumentBytes; + byte[] padded = Enumerable.Repeat((byte)' ', maximum).ToArray(); + WindowsInstallerDirectoryLedgerCodec.Serialize(WindowsInstallerDirectoryLedger.Empty).CopyTo(padded, 0); + InstallerProtocolException boundaryFailure = Assert.Throws(() => + WindowsInstallerDirectoryLedgerCodec.Parse(padded)); + Assert.NotEqual("installer.directory_ledger.document_size_invalid", boundaryFailure.DiagnosticCode); + foreach (byte[] invalid in new[] { Array.Empty(), new byte[maximum + 1] }) + { + InstallerProtocolException failure = Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(invalid)); + Assert.Equal("installer.directory_ledger.document_size_invalid", failure.DiagnosticCode); + } + } + + [Fact] + public void FixedLayoutKeepsItsLedgerOutsideAllDeletionTargetsAndOrdersChildrenFirst() + { + var layout = new WindowsInstallerDirectoryCleanupLayout(ProgramFiles, ProgramData); + Assert.Equal(Path.Combine(ProgramData, WindowsInstallerDirectoryCleanupLayout.LedgerFileName), layout.LedgerPath); + InstallerDirectoryRole[] order = WindowsInstallerDirectoryCleanupLayout.DeletionOrder; + Assert.Equal(6, order.Distinct().Count()); + foreach (InstallerDirectoryRole role in Enum.GetValues()) + { + string path = layout.GetPath(role); + Assert.True(layout.TryGetRole(path.ToUpperInvariant(), out InstallerDirectoryRole observed)); + Assert.Equal(role, observed); + Assert.False(layout.LedgerPath.StartsWith(path + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); + foreach (InstallerDirectoryRole parent in order.Where(parent => + path.StartsWith(layout.GetPath(parent) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))) + { + Assert.True(Array.IndexOf(order, role) < Array.IndexOf(order, parent)); + } + } + Assert.Throws(() => layout.GetPath((InstallerDirectoryRole)99)); + } + + [Theory] + [InlineData(@"C:\ProgramData")] + [InlineData(@"C:\ProgramData\ClashSharp-other")] + [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2\child")] + [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2\..")] + [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2:stream")] + [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2\")] + [InlineData(@"\\?\C:\ProgramData\ClashSharp\Installer\v2")] + public void FixedLayoutDoesNotAuthorizeAncestorSiblingChildOrAliasPaths(string path) + { + var layout = new WindowsInstallerDirectoryCleanupLayout(ProgramFiles, ProgramData); + Assert.False(layout.TryGetRole(path, out _)); + } + + [Fact] + public async Task PersistenceUsesOnlyTheFixedLedgerAndConfirmsSaveAndDeleteWhileAnchored() + { + var fixture = new PersistenceFixture(); + WindowsInstallerDirectoryLedger desired = CompleteLedger(); + Assert.Null(await fixture.Persistence.LoadAsync(CancellationToken.None)); + await fixture.Persistence.SaveAsync(null, desired, CancellationToken.None); + WindowsInstallerDirectoryLedger? loaded = await fixture.Persistence.LoadAsync(CancellationToken.None); + Assert.NotNull(loaded); + Assert.Equal(WindowsInstallerDirectoryLedgerCodec.Serialize(desired), WindowsInstallerDirectoryLedgerCodec.Serialize(loaded)); + await fixture.Persistence.DeleteAsync(desired, CancellationToken.None); + Assert.Null(fixture.Files.Bytes); + Assert.Equal(0, fixture.ActiveAnchors); + Assert.All(fixture.Files.Paths, path => Assert.Equal(Path.Combine(ProgramData, WindowsInstallerDirectoryCleanupLayout.LedgerFileName), path)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NativeAcknowledgementWithoutItsPostconditionCannotSucceed(bool deleting) + { + var fixture = new PersistenceFixture(); + WindowsInstallerDirectoryLedger desired = CompleteLedger(); + fixture.Files.IgnoreMutation = true; + if (deleting) { fixture.Files.Bytes = WindowsInstallerDirectoryLedgerCodec.Serialize(desired); } + InstallerProtocolException failure = await Assert.ThrowsAsync(() => deleting + ? fixture.Persistence.DeleteAsync(desired, CancellationToken.None) + : fixture.Persistence.SaveAsync(null, desired, CancellationToken.None)); + Assert.Equal(deleting ? "installer.directory_ledger.delete_not_observed" : "installer.directory_ledger.write_not_observed", failure.DiagnosticCode); + Assert.Equal(0, fixture.ActiveAnchors); + } + + [Theory] + [InlineData("load")] + [InlineData("save")] + [InlineData("delete")] + public async Task StorageFailuresPropagateWithoutLosingTheAnchorLease(string operation) + { + var fixture = new PersistenceFixture(); + var failure = new IOException("Injected metadata I/O failure."); + fixture.Files.Failure = failure; + IOException observed = await Assert.ThrowsAsync(() => RunPersistenceAsync(fixture, operation)); + Assert.Same(failure, observed); + Assert.Equal(0, fixture.ActiveAnchors); + } + + [Fact] + public async Task CorruptPersistedBytesAreNotTreatedAsAMissingLedger() + { + var fixture = new PersistenceFixture(); + fixture.Files.Bytes = Utf8("{}"); + await Assert.ThrowsAsync(() => fixture.Persistence.LoadAsync(CancellationToken.None)); + Assert.Equal(0, fixture.ActiveAnchors); + Assert.Equal(Utf8("{}"), fixture.Files.Bytes); + } + + [Fact] + public async Task PreCancelledOperationsAcquireNoAnchorAndInvokeNoNativeFileWork() + { + var fixture = new PersistenceFixture(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => fixture.Persistence.LoadAsync(cancellation.Token)); + await Assert.ThrowsAnyAsync(() => fixture.Persistence.SaveAsync(null, CompleteLedger(), cancellation.Token)); + await Assert.ThrowsAnyAsync(() => fixture.Persistence.DeleteAsync(CompleteLedger(), cancellation.Token)); + Assert.Equal(0, fixture.AnchorAcquisitions); + Assert.Empty(fixture.Files.Paths); + } + + private static Task RunPersistenceAsync(PersistenceFixture fixture, string operation) => operation switch + { + "load" => fixture.Persistence.LoadAsync(CancellationToken.None), + "save" => fixture.Persistence.SaveAsync(null, CompleteLedger(), CancellationToken.None), + "delete" => fixture.Persistence.DeleteAsync(CompleteLedger(), CancellationToken.None), + _ => throw new ArgumentOutOfRangeException(nameof(operation)), + }; + + private static byte[] Utf8(string value) => Encoding.UTF8.GetBytes(value); + + private static InstallerTransactionSnapshot Verified() => InstallerTransactionSnapshot.Create(new( + InstallerTransactionJournal.CurrentSchema, new string('a', 64), InstallerOperation.Uninstall, + TargetSid, false, "1.0.0.0", new string('d', 64), InstallerTransactionPhase.Verified, 5)); + + private static WindowsInstallerDirectoryLedger CompleteLedger() => new(6, + Enum.GetValues().Select(role => new WindowsInstallerOwnedDirectory(role, new(12, (ulong)role + 1))), null); + + private sealed class PersistenceFixture + { + internal PersistenceFixture() + { + Files = new Files(this); + Persistence = new(new WindowsInstallerDirectoryCleanupLayout(ProgramFiles, ProgramData), Acquire, Files); + } + + internal WindowsInstallerDirectoryLedgerPersistence Persistence { get; } + internal Files Files { get; } + internal int ActiveAnchors { get; private set; } + internal int AnchorAcquisitions { get; private set; } + + private IDisposable Acquire() + { + ActiveAnchors++; + AnchorAcquisitions++; + return new Anchor(this); + } + + private sealed class Anchor(PersistenceFixture owner) : IDisposable + { + private bool _disposed; + public void Dispose() + { + if (!_disposed) { _disposed = true; owner.ActiveAnchors--; } + } + } + } + + private sealed class Files(PersistenceFixture fixture) : IWindowsInstallerDirectoryLedgerFileNative + { + internal byte[]? Bytes { get; set; } + internal bool IgnoreMutation { get; set; } + internal Exception? Failure { get; set; } + internal List Paths { get; } = []; + + public Task ReadAsync(string path, CancellationToken cancellationToken) + { + Observe(path, cancellationToken); + return Task.FromResult(Bytes?.ToArray()); + } + + public Task PublishAsync(string path, byte[]? expected, byte[] desired, CancellationToken cancellationToken) + { + Observe(path, cancellationToken); + Assert.Equal(Bytes, expected); + if (!IgnoreMutation) { Bytes = desired.ToArray(); } + return Task.CompletedTask; + } + + public Task DeleteAsync(string path, byte[] expected, CancellationToken cancellationToken) + { + Observe(path, cancellationToken); + Assert.Equal(Bytes, expected); + if (!IgnoreMutation) { Bytes = null; } + return Task.CompletedTask; + } + + private void Observe(string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.Equal(1, fixture.ActiveAnchors); + Paths.Add(path); + if (Failure is { } failure) { throw failure; } + } + } +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerCleanupTransactionStore.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerCleanupTransactionStore.cs new file mode 100644 index 0000000..725c51c --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerCleanupTransactionStore.cs @@ -0,0 +1,131 @@ +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; + +namespace ClashSharp.Installer.Windows.Transactions; + +/// +/// Keeps the exact verified uninstall recoverable while its ordinary journal and owned directories +/// are finalized. The helper's existing authority lease serializes this session; neither store is owned. +/// +internal sealed class WindowsInstallerCleanupTransactionStore : IInstallerTransactionStore +{ + private readonly IInstallerTransactionStore _inner; + private readonly IWindowsInstallerDirectoryLedgerPersistence _ledger; + private InstallerTransactionSnapshot? _clearedTerminal; + + internal WindowsInstallerCleanupTransactionStore(IInstallerTransactionStore inner, + IWindowsInstallerDirectoryLedgerPersistence ledger) + { + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(ledger); + _inner = inner; + _ledger = ledger; + } + + public async Task LoadAsync(CancellationToken cancellationToken) + { + (InstallerTransactionSnapshot? active, WindowsInstallerDirectoryLedger? ledger) = + await ReadAsync(cancellationToken).ConfigureAwait(false); + if (active is not null) { return active; } + InstallerTransactionSnapshot? terminal = ledger?.Terminal; + // Only this successfully cleared helper session suppresses its exact terminal checkpoint. + // A new parent or helper wrapper still sees it until the directory finalizer deletes it. + return terminal == _clearedTerminal ? null : terminal; + } + + public async Task SaveAsync(InstallerTransactionJournal journal, + string? expectedCurrentHash, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(journal); + InstallerTransactionSnapshot desired = InstallerTransactionSnapshot.Create(journal); + (InstallerTransactionSnapshot? _, WindowsInstallerDirectoryLedger? ledger) = + await ReadAsync(cancellationToken).ConfigureAwait(false); + if (ledger?.Terminal is { } terminal) + { + if (desired != terminal + || !string.Equals(expectedCurrentHash, terminal.ContentHash, StringComparison.Ordinal) + || _clearedTerminal is not null) + { + throw new InstallerProtocolException("installer.transaction.write_conflict"); + } + // Final verification may acknowledge the same Verified checkpoint. Never resurrect + // an ordinary journal or manufacture a Prepared transaction from terminal-only state. + return terminal; + } + if (_clearedTerminal is not null) + { + throw new InstallerProtocolException("installer.transaction.write_conflict"); + } + return await _inner.SaveAsync(journal, expectedCurrentHash, cancellationToken).ConfigureAwait(false); + } + + public async Task ClearVerifiedAsync(string transactionId, string expectedCurrentHash, + CancellationToken cancellationToken) + { + InstallerProtocolValidation.ValidateLowerHex256(transactionId, "installer.transaction.id_invalid"); + InstallerProtocolValidation.ValidateLowerHex256(expectedCurrentHash, "installer.transaction.content_hash_invalid"); + (InstallerTransactionSnapshot? active, WindowsInstallerDirectoryLedger? ledger) = + await ReadAsync(cancellationToken).ConfigureAwait(false); + InstallerTransactionSnapshot? current = active ?? ledger?.Terminal; + if (current is null || current.Journal.Phase != InstallerTransactionPhase.Verified + || !string.Equals(current.Journal.TransactionId, transactionId, StringComparison.Ordinal) + || !string.Equals(current.ContentHash, expectedCurrentHash, StringComparison.Ordinal)) + { + throw new InstallerProtocolException("installer.transaction.clear_conflict"); + } + + if (current.Journal.Operation != InstallerOperation.Uninstall) + { + await _inner.ClearVerifiedAsync(transactionId, expectedCurrentHash, cancellationToken).ConfigureAwait(false); + return; + } + + WindowsInstallerDirectoryLedger pending = (ledger ?? WindowsInstallerDirectoryLedger.Empty).BeginTerminal(current); + await _ledger.SaveAsync(ledger, pending, cancellationToken).ConfigureAwait(false); + await RequireTerminalAsync(pending, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (active is not null) + { + await _inner.ClearVerifiedAsync(transactionId, expectedCurrentHash, cancellationToken).ConfigureAwait(false); + } + + InstallerTransactionSnapshot? remaining = await _inner.LoadAsync(cancellationToken).ConfigureAwait(false); + remaining?.Validate(); + if (remaining is not null) + { + throw new InstallerProtocolException("installer.transaction.clear_not_observed"); + } + await RequireTerminalAsync(pending, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + _clearedTerminal = current; + } + + private async Task<(InstallerTransactionSnapshot? Active, WindowsInstallerDirectoryLedger? Ledger)> ReadAsync( + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + InstallerTransactionSnapshot? active = await _inner.LoadAsync(cancellationToken).ConfigureAwait(false); + active?.Validate(); + WindowsInstallerDirectoryLedger? ledger = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false); + ledger?.Validate(); + if (ledger?.Terminal is { } terminal + && ((active is not null && active != terminal) + || (_clearedTerminal is not null && _clearedTerminal != terminal))) + { + throw WindowsInstallerDirectoryLedger.Failure("terminal_identity_mismatch"); + } + cancellationToken.ThrowIfCancellationRequested(); + return (active, ledger); + } + + private async Task RequireTerminalAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken) + { + WindowsInstallerDirectoryLedger? observed = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false); + observed?.Validate(); + if (observed is null || observed.Generation != expected.Generation || observed.Terminal != expected.Terminal + || !observed.Directories.SequenceEqual(expected.Directories)) + { + throw WindowsInstallerDirectoryLedger.Failure("terminal_not_observed"); + } + } +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryCleanupLayout.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryCleanupLayout.cs new file mode 100644 index 0000000..aa8b4ad --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryCleanupLayout.cs @@ -0,0 +1,54 @@ +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Windows.Machines; + +namespace ClashSharp.Installer.Windows.Transactions; + +internal sealed class WindowsInstallerDirectoryCleanupLayout +{ + internal const string LedgerFileName = "ClashSharp.InstallerDirectories.v1.json"; + private readonly IReadOnlyDictionary _paths; + + internal WindowsInstallerDirectoryCleanupLayout(string programFiles, string programData) + { + WindowsMachineDeploymentRoots roots = WindowsMachineDeploymentRoots.Create(programFiles, programData); + ProgramData = roots.CommonApplicationDataRoot; + LedgerPath = Path.Combine(ProgramData, LedgerFileName); + string productData = Path.Combine(ProgramData, "ClashSharp"); + _paths = new Dictionary + { + [InstallerDirectoryRole.ProgramFilesProduct] = Path.Combine(roots.ProgramFilesRoot, "ClashSharp"), + [InstallerDirectoryRole.ProgramDataProduct] = productData, + [InstallerDirectoryRole.InstallerRoot] = Path.Combine(productData, "Installer"), + [InstallerDirectoryRole.InstallerVersion] = Path.Combine(productData, "Installer", "v2"), + [InstallerDirectoryRole.AuthorityRoot] = Path.Combine(productData, "InstallerAuthority"), + [InstallerDirectoryRole.AuthorityVersion] = Path.Combine(productData, "InstallerAuthority", "v1"), + }; + } + + internal string ProgramData { get; } + internal string LedgerPath { get; } + internal string GetPath(InstallerDirectoryRole role) => _paths.TryGetValue(role, out string? path) + ? path : throw WindowsInstallerDirectoryLedger.Failure("role_invalid"); + + internal bool TryGetRole(string path, out InstallerDirectoryRole role) + { + foreach (KeyValuePair entry in _paths) + { + if (string.Equals(path, entry.Value, StringComparison.OrdinalIgnoreCase)) { role = entry.Key; return true; } + } + role = default; + return false; + } + + internal static WindowsInstallerDirectoryCleanupLayout CreateDefault() => new( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles, Environment.SpecialFolderOption.DoNotVerify), + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData, Environment.SpecialFolderOption.DoNotVerify)); + + internal static InstallerDirectoryRole[] DeletionOrder => + [ + InstallerDirectoryRole.ProgramFilesProduct, + InstallerDirectoryRole.InstallerVersion, InstallerDirectoryRole.InstallerRoot, + InstallerDirectoryRole.AuthorityVersion, InstallerDirectoryRole.AuthorityRoot, + InstallerDirectoryRole.ProgramDataProduct, + ]; +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedger.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedger.cs new file mode 100644 index 0000000..8ea4e46 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedger.cs @@ -0,0 +1,179 @@ +using System.Buffers; +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Text.Json; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; +using ClashSharp.Installer.Windows.Files; + +namespace ClashSharp.Installer.Windows.Transactions; + +internal sealed record WindowsInstallerOwnedDirectory(InstallerDirectoryRole Role, WindowsFileIdentity Identity); + +/// Only a positively observed directory creation can add an object to this ledger. +internal sealed class WindowsInstallerDirectoryLedger +{ + internal WindowsInstallerDirectoryLedger(long generation, + IEnumerable directories, InstallerTransactionSnapshot? terminal) + { + ArgumentNullException.ThrowIfNull(directories); + Generation = generation; + Directories = Array.AsReadOnly(directories.ToArray()); + Terminal = terminal; + Validate(); + } + + internal long Generation { get; } + internal ReadOnlyCollection Directories { get; } + internal InstallerTransactionSnapshot? Terminal { get; } + internal static WindowsInstallerDirectoryLedger Empty => new(0, [], null); + + internal WindowsInstallerDirectoryLedger RecordCreated(InstallerDirectoryRole role, WindowsFileIdentity identity) + { + var entry = new WindowsInstallerOwnedDirectory(role, identity); + if (Directories.Contains(entry)) { return this; } + return new(checked(Generation + 1), Directories.Where(item => item.Role != role) + .Append(entry).OrderBy(item => item.Role), Terminal); + } + + internal WindowsInstallerDirectoryLedger BeginTerminal(InstallerTransactionSnapshot state) + { + ArgumentNullException.ThrowIfNull(state); + if (Terminal is not null) + { + if (Terminal != state) { throw Failure("terminal_identity_mismatch"); } + return this; + } + return new(checked(Generation + 1), Directories, state); + } + + internal void Validate() + { + if (Generation < 0 || Directories.Count > 6) { throw Failure("shape_invalid"); } + InstallerDirectoryRole? previous = null; + foreach (WindowsInstallerOwnedDirectory entry in Directories) + { + if (entry is null || !Enum.IsDefined(entry.Role) || entry.Identity.FileIndex == 0 + || previous is { } last && entry.Role <= last) { throw Failure("directory_identity_invalid"); } + previous = entry.Role; + } + if (Terminal is { } state) + { + state.Validate(); + if (state.Journal.Operation != InstallerOperation.Uninstall + || state.Journal.Phase != InstallerTransactionPhase.Verified) + { + throw Failure("terminal_not_verified_uninstall"); + } + } + } + + internal static InstallerProtocolException Failure(string suffix) => new("installer.directory_ledger." + suffix); +} + +internal static class WindowsInstallerDirectoryLedgerCodec +{ + internal const int MaximumDocumentBytes = 4096; + + internal static byte[] Serialize(WindowsInstallerDirectoryLedger ledger) + { + ArgumentNullException.ThrowIfNull(ledger); + ledger.Validate(); + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + writer.WriteNumber("schema", 1); + writer.WriteNumber("generation", ledger.Generation); + writer.WriteStartArray("createdDirectories"); + foreach (WindowsInstallerOwnedDirectory directory in ledger.Directories) + { + writer.WriteStartObject(); + writer.WriteString("role", directory.Role.ToString()); + writer.WriteNumber("volumeSerialNumber", directory.Identity.VolumeSerialNumber); + writer.WriteNumber("fileIndex", directory.Identity.FileIndex); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + if (ledger.Terminal is not { } terminal) { writer.WriteNull("terminal"); } + else + { + writer.WriteStartObject("terminal"); + writer.WriteString("journalBase64", Convert.ToBase64String(InstallerTransactionCodec.Serialize(terminal.Journal))); + writer.WriteString("contentHash", terminal.ContentHash); + writer.WriteEndObject(); + } + writer.WriteEndObject(); + } + byte[] bytes = buffer.WrittenSpan.ToArray(); + ValidateSize(bytes); + return bytes; + } + + internal static WindowsInstallerDirectoryLedger Parse(ReadOnlyMemory bytes) + { + ValidateSize(bytes.Span); + try + { + using JsonDocument document = JsonDocument.Parse(bytes, new JsonDocumentOptions + { + MaxDepth = 4, AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow, + }); + JsonElement root = document.RootElement; + ExactObject(root, "schema", "generation", "createdDirectories", "terminal"); + if (root.GetProperty("schema").GetInt32() != 1) { throw new JsonException(); } + var directories = new List(); + foreach (JsonElement entry in root.GetProperty("createdDirectories").EnumerateArray()) + { + ExactObject(entry, "role", "volumeSerialNumber", "fileIndex"); + string roleText = entry.GetProperty("role").GetString() ?? throw new JsonException(); + if (!Enum.TryParse(roleText, ignoreCase: false, out InstallerDirectoryRole role) + || !Enum.IsDefined(role) || role.ToString() != roleText) { throw new JsonException(); } + directories.Add(new(role, new(entry.GetProperty("volumeSerialNumber").GetUInt32(), + entry.GetProperty("fileIndex").GetUInt64()))); + if (directories.Count > 6) { throw new JsonException(); } + } + InstallerTransactionSnapshot? terminal = null; + JsonElement terminalElement = root.GetProperty("terminal"); + if (terminalElement.ValueKind != JsonValueKind.Null) + { + ExactObject(terminalElement, "journalBase64", "contentHash"); + string encoded = terminalElement.GetProperty("journalBase64").GetString() ?? throw new JsonException(); + byte[] journalBytes = Convert.FromBase64String(encoded); + if (Convert.ToBase64String(journalBytes) != encoded) { throw new JsonException(); } + InstallerTransactionJournal journal = InstallerTransactionCodec.Parse(journalBytes); + byte[] canonical = InstallerTransactionCodec.Serialize(journal); + string contentHash = terminalElement.GetProperty("contentHash").GetString() ?? throw new JsonException(); + if (!journalBytes.AsSpan().SequenceEqual(canonical) + || Convert.ToHexStringLower(SHA256.HashData(canonical)) != contentHash) { throw new JsonException(); } + terminal = new(journal, contentHash); + } + var ledger = new WindowsInstallerDirectoryLedger(root.GetProperty("generation").GetInt64(), directories, terminal); + if (!bytes.Span.SequenceEqual(Serialize(ledger))) + { + throw WindowsInstallerDirectoryLedger.Failure("document_noncanonical"); + } + return ledger; + } + catch (Exception exception) when (exception is JsonException or InvalidOperationException or FormatException or OverflowException) + { + throw new InstallerProtocolException("installer.directory_ledger.document_invalid", exception); + } + } + + private static void ExactObject(JsonElement element, params string[] properties) + { + if (element.ValueKind != JsonValueKind.Object) { throw new JsonException(); } + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name) || !properties.Contains(property.Name, StringComparer.Ordinal)) { throw new JsonException(); } + } + if (names.Count != properties.Length) { throw new JsonException(); } + } + + private static void ValidateSize(ReadOnlySpan bytes) + { + if (bytes.Length is < 1 or > MaximumDocumentBytes) { throw WindowsInstallerDirectoryLedger.Failure("document_size_invalid"); } + } +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedgerPersistence.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedgerPersistence.cs new file mode 100644 index 0000000..f46a9db --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedgerPersistence.cs @@ -0,0 +1,355 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Windows.Files; +using ClashSharp.Windows.FileSecurity; +using Microsoft.Win32.SafeHandles; + +namespace ClashSharp.Installer.Windows.Transactions; + +internal interface IWindowsInstallerDirectoryLedgerPersistence +{ + Task LoadAsync(CancellationToken cancellationToken); + Task SaveAsync(WindowsInstallerDirectoryLedger? expected, WindowsInstallerDirectoryLedger desired, CancellationToken cancellationToken); + Task DeleteAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken); +} + +internal interface IWindowsInstallerDirectoryLedgerFileNative +{ + Task ReadAsync(string path, CancellationToken cancellationToken); + Task PublishAsync(string path, byte[]? expected, byte[] desired, CancellationToken cancellationToken); + Task DeleteAsync(string path, byte[] expected, CancellationToken cancellationToken); +} + +internal sealed class WindowsInstallerDirectoryLedgerPersistence : IWindowsInstallerDirectoryLedgerPersistence +{ + private readonly string _path; + private readonly Func _acquireAnchor; + private readonly IWindowsInstallerDirectoryLedgerFileNative _files; + + internal WindowsInstallerDirectoryLedgerPersistence(WindowsInstallerDirectoryCleanupLayout layout, + Func acquireAnchor, IWindowsInstallerDirectoryLedgerFileNative files) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(acquireAnchor); + ArgumentNullException.ThrowIfNull(files); + _path = layout.LedgerPath; + _acquireAnchor = acquireAnchor; + _files = files; + } + + internal static WindowsInstallerDirectoryLedgerPersistence CreateDefault() + { + WindowsInstallerDirectoryCleanupLayout layout = WindowsInstallerDirectoryCleanupLayout.CreateDefault(); + return new(layout, () => WindowsInstallerDirectoryAnchor.Acquire(layout.ProgramData), + WindowsInstallerDirectoryLedgerFileNative.Instance); + } + + public async Task LoadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using IDisposable anchor = _acquireAnchor(); + byte[]? bytes = await _files.ReadAsync(_path, cancellationToken).ConfigureAwait(false); + return bytes is null ? null : WindowsInstallerDirectoryLedgerCodec.Parse(bytes); + } + + public async Task SaveAsync(WindowsInstallerDirectoryLedger? expected, WindowsInstallerDirectoryLedger desired, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(desired); + cancellationToken.ThrowIfCancellationRequested(); + using IDisposable anchor = _acquireAnchor(); + byte[] bytes = WindowsInstallerDirectoryLedgerCodec.Serialize(desired); + await _files.PublishAsync(_path, expected is null ? null : WindowsInstallerDirectoryLedgerCodec.Serialize(expected), + bytes, cancellationToken).ConfigureAwait(false); + byte[]? observed = await _files.ReadAsync(_path, cancellationToken).ConfigureAwait(false); + if (observed is null || !CryptographicOperations.FixedTimeEquals(bytes, observed)) + { + throw WindowsInstallerDirectoryLedger.Failure("write_not_observed"); + } + } + + public async Task DeleteAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(expected); + cancellationToken.ThrowIfCancellationRequested(); + using IDisposable anchor = _acquireAnchor(); + await _files.DeleteAsync(_path, WindowsInstallerDirectoryLedgerCodec.Serialize(expected), cancellationToken).ConfigureAwait(false); + if (await _files.ReadAsync(_path, cancellationToken).ConfigureAwait(false) is not null) + { + throw WindowsInstallerDirectoryLedger.Failure("delete_not_observed"); + } + } +} + +/// Only the already-existing trusted known-folder chain is pinned by this lease. +internal sealed class WindowsInstallerDirectoryAnchor : IDisposable +{ + private readonly List _leases = []; + + internal static WindowsInstallerDirectoryAnchor Acquire(string directory) + { + if (!Path.IsPathFullyQualified(directory) + || !string.Equals(directory, Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)), StringComparison.OrdinalIgnoreCase)) + { + throw WindowsInstallerDirectoryLedger.Failure("anchor_path_invalid"); + } + var owner = new WindowsInstallerDirectoryAnchor(); + try + { + string current = Path.GetPathRoot(directory) ?? throw WindowsInstallerDirectoryLedger.Failure("anchor_path_invalid"); + owner.Add(current); + foreach (string segment in Path.GetRelativePath(current, directory).Split(Path.DirectorySeparatorChar)) + { + if (segment is "." or ".." or "") { throw WindowsInstallerDirectoryLedger.Failure("anchor_path_invalid"); } + current = Path.Combine(current, segment); + owner.Add(current); + } + return owner; + } + catch { owner.Dispose(); throw; } + } + + private void Add(string path) + { + WindowsDirectoryReadLease lease = WindowsDirectoryReadLease.Open(path); + _leases.Add(lease); + WindowsDirectoryObservation observation = lease.Observe(); + if (!observation.IsDirectory || observation.IsReparsePoint + || !WindowsDirectoryAccessPolicy.IsTrustedRenameAnchor(observation.Security)) + { + throw WindowsInstallerDirectoryLedger.Failure("anchor_unsafe"); + } + } + + public void Dispose() + { + for (int index = _leases.Count - 1; index >= 0; index--) { _leases[index].Dispose(); } + _leases.Clear(); + } +} + +internal static class WindowsInstallerDirectoryLedgerSecurity +{ + internal const string UsersSid = "S-1-5-32-545"; + private const FileSystemRights ReadRights = FileSystemRights.Read | FileSystemRights.Synchronize; + + internal static FileSecurity Create() + { + var security = new FileSecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.SetOwner(new SecurityIdentifier(WindowsDirectoryAccessPolicy.AdministratorsSid)); + foreach ((string sid, FileSystemRights rights) in Rules()) + { + security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(sid), rights, AccessControlType.Allow)); + } + return security; + } + + internal static void Validate(WindowsDirectorySecuritySnapshot security) + { + if (!security.HasDacl || !security.DaclProtected || security.OwnerSid != WindowsDirectoryAccessPolicy.AdministratorsSid + || security.AccessEntries.Count != 3) { throw WindowsInstallerDirectoryLedger.Failure("file_acl_invalid"); } + foreach ((string sid, FileSystemRights rights) in Rules()) + { + WindowsDirectoryAce[] entries = security.AccessEntries.Where(entry => entry.Sid == sid).ToArray(); + if (entries.Length != 1 || entries[0].Kind != WindowsDirectoryAceKind.Allow + || entries[0].AccessMask != (int)rights || entries[0].Flags != AceFlags.None || entries[0].IsObjectSpecific) + { + throw WindowsInstallerDirectoryLedger.Failure("file_acl_invalid"); + } + } + } + + private static (string Sid, FileSystemRights Rights)[] Rules() => + [ + (WindowsDirectoryAccessPolicy.LocalSystemSid, FileSystemRights.FullControl), + (WindowsDirectoryAccessPolicy.AdministratorsSid, FileSystemRights.FullControl), + (UsersSid, ReadRights), + ]; +} + +/// Fixed ordinary file with a separately validated read-only public metadata policy. +internal sealed class WindowsInstallerDirectoryLedgerFileNative : IWindowsInstallerDirectoryLedgerFileNative +{ + internal static WindowsInstallerDirectoryLedgerFileNative Instance { get; } = new(); + + public async Task ReadAsync(string path, CancellationToken cancellationToken) + { + ValidatePath(path); + cancellationToken.ThrowIfCancellationRequested(); + using SafeFileHandle? handle = OpenIfPresent(path, deletion: false); + return handle is null ? null : await ReadHandleAsync(handle, cancellationToken).ConfigureAwait(false); + } + + public async Task PublishAsync(string path, byte[]? expected, byte[] desired, CancellationToken cancellationToken) + { + ValidatePath(path); + _ = WindowsInstallerDirectoryLedgerCodec.Parse(desired); + cancellationToken.ThrowIfCancellationRequested(); + // A protected existing object is held open throughout publication. Delete sharing permits + // this authority's atomic replacement; its exact DACL and the pinned parent prohibit user replacement. + using SafeFileHandle? original = OpenIfPresent(path, deletion: false, allowDeleteSharing: true); + byte[]? current = original is null ? null : await ReadHandleAsync(original, cancellationToken).ConfigureAwait(false); + RequireExpected(expected, current); + WindowsFileIdentity? originalIdentity = original is null ? null : WindowsFileSystemNative.GetOrdinaryFileIdentity(original); + string temporary = Path.Combine(Path.GetDirectoryName(path)!, "." + WindowsInstallerDirectoryCleanupLayout.LedgerFileName + + "." + Guid.NewGuid().ToString("N") + ".tmp"); + WindowsFileIdentity? temporaryIdentity = null; + bool published = false; + try + { + await using (FileStream stream = new FileInfo(temporary).Create(FileMode.CreateNew, FileSystemRights.FullControl, + FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.WriteThrough, WindowsInstallerDirectoryLedgerSecurity.Create())) + { + ValidateFile(stream.SafeFileHandle); + temporaryIdentity = WindowsFileSystemNative.GetOrdinaryFileIdentity(stream.SafeFileHandle); + await stream.WriteAsync(desired, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + cancellationToken.ThrowIfCancellationRequested(); + using (SafeFileHandle? final = OpenIfPresent(path, deletion: false, allowDeleteSharing: true)) + { + if ((final is null) != (originalIdentity is null) + || final is not null && WindowsFileSystemNative.GetOrdinaryFileIdentity(final) != originalIdentity) + { + throw WindowsInstallerDirectoryLedger.Failure("publication_target_changed"); + } + if (final is not null) + { + RequireExpected(expected, await ReadHandleAsync(final, cancellationToken).ConfigureAwait(false)); + } + } + // Crucially, a missing target is published WITHOUT REPLACE_EXISTING. ProgramData can + // permit unrelated users to create names; a name won in that race must never be overwritten. + if (!MoveFileEx(temporary, path, 8U | (originalIdentity is null ? 0U : 1U))) + { + throw new InstallerStateUncertainException("installer.directory_ledger.publication_uncertain"); + } + published = true; + using SafeFileHandle? persisted = OpenIfPresent(path, deletion: false); + if (persisted is null || WindowsFileSystemNative.GetOrdinaryFileIdentity(persisted) != temporaryIdentity) + { + throw WindowsInstallerDirectoryLedger.Failure("publication_identity_changed"); + } + } + finally + { + if (!published && temporaryIdentity is { } identity) { TryDeleteTemporary(temporary, identity); } + } + } + + public async Task DeleteAsync(string path, byte[] expected, CancellationToken cancellationToken) + { + ValidatePath(path); + cancellationToken.ThrowIfCancellationRequested(); + using SafeFileHandle? file = OpenIfPresent(path, deletion: true); + if (file is null) { return; } + RequireExpected(expected, await ReadHandleAsync(file, cancellationToken).ConfigureAwait(false)); + cancellationToken.ThrowIfCancellationRequested(); + DeleteHandle(file); + } + + private static void RequireExpected(byte[]? expected, byte[]? actual) + { + if (actual is not null) { _ = WindowsInstallerDirectoryLedgerCodec.Parse(actual); } + if ((expected is null) != (actual is null) + || expected is not null && !CryptographicOperations.FixedTimeEquals(expected, actual!)) + { + throw WindowsInstallerDirectoryLedger.Failure("compare_exchange_failed"); + } + } + + private static async Task ReadHandleAsync(SafeFileHandle file, CancellationToken cancellationToken) + { + long size = RandomAccess.GetLength(file); + if (size is < 1 or > WindowsInstallerDirectoryLedgerCodec.MaximumDocumentBytes) + { + throw WindowsInstallerDirectoryLedger.Failure("document_size_invalid"); + } + byte[] bytes = new byte[(int)size]; + int offset = 0; + while (offset < bytes.Length) + { + int count = await RandomAccess.ReadAsync(file, bytes.AsMemory(offset), offset, cancellationToken).ConfigureAwait(false); + if (count == 0) { throw WindowsInstallerDirectoryLedger.Failure("document_changed"); } + offset += count; + } + if (RandomAccess.GetLength(file) != size) { throw WindowsInstallerDirectoryLedger.Failure("document_changed"); } + return bytes; + } + + private static SafeFileHandle? OpenIfPresent(string path, bool deletion, bool allowDeleteSharing = false) + { + SafeFileHandle handle = CreateFile(path, 0x80000000U | (deletion ? 0x00010000U : 0U), + 1U | (allowDeleteSharing ? 4U : 0U), 0, 3, 0x00200000U, 0); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + handle.Dispose(); + if (error == 2) { return null; } + throw new Win32Exception(error); + } + try { ValidateFile(handle); return handle; } + catch { handle.Dispose(); throw; } + } + + private static void ValidateFile(SafeFileHandle file) + { + _ = WindowsFileSystemNative.GetOrdinaryFileIdentity(file); + if (WindowsFileSystemNative.GetLinkCount(file) != 1) { throw WindowsInstallerDirectoryLedger.Failure("file_links_invalid"); } + WindowsInstallerDirectoryLedgerSecurity.Validate(WindowsDirectoryReadLease.ReadSecuritySnapshot(file)); + } + + private static void TryDeleteTemporary(string path, WindowsFileIdentity identity) + { + try + { + using SafeFileHandle? temporary = OpenIfPresent(path, deletion: true); + if (temporary is not null && WindowsFileSystemNative.GetOrdinaryFileIdentity(temporary) == identity) + { + DeleteHandle(temporary); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or Win32Exception or InstallerProtocolException) + { + // Never adopt or delete a temporary from a different call, or one whose identity changed. + } + } + + internal static void DeleteHandle(SafeFileHandle handle) + { + var disposition = new FileDisposition { Delete = 1 }; + if (!SetFileInformationByHandle(handle, 4, in disposition, 1)) { throw new Win32Exception(Marshal.GetLastPInvokeError()); } + } + + private static void ValidatePath(string path) + { + if (!Path.IsPathFullyQualified(path) || Path.GetFileName(path) != WindowsInstallerDirectoryCleanupLayout.LedgerFileName + || !string.Equals(path, Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase)) + { + throw WindowsInstallerDirectoryLedger.Failure("path_invalid"); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct FileDisposition { internal byte Delete; } + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern SafeFileHandle CreateFile(string name, uint access, uint share, nint security, uint creation, uint flags, nint template); + + [DllImport("kernel32.dll", EntryPoint = "MoveFileExW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool MoveFileEx(string oldPath, string newPath, uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle(SafeFileHandle file, int informationClass, in FileDisposition disposition, uint size); +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs index 88a15b2..47dd730 100644 --- a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs @@ -9,7 +9,7 @@ public void CreateDirectory(string path, DirectorySecurity security) { ArgumentException.ThrowIfNullOrWhiteSpace(path); ArgumentNullException.ThrowIfNull(security); - new DirectoryInfo(path).Create(security); + WindowsInstallerOwnedDirectoryCreation.CreateDefault().Create(path, security); } /// diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerEmptyDirectoryFinalizer.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerEmptyDirectoryFinalizer.cs new file mode 100644 index 0000000..11c6eb8 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerEmptyDirectoryFinalizer.cs @@ -0,0 +1,256 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; +using ClashSharp.Installer.Windows.Files; +using ClashSharp.Windows.FileSecurity; +using Microsoft.Win32.SafeHandles; + +namespace ClashSharp.Installer.Windows.Transactions; + +internal interface IWindowsInstallerDirectoryDeletionLease : IDisposable +{ + WindowsFileIdentity Identity { get; } + bool IsEmpty { get; } + void VerifyOwnedSecurity(); + void DeleteEmpty(); +} + +internal interface IWindowsInstallerEmptyDirectoryNative +{ + IWindowsInstallerDirectoryDeletionLease? Open(string path, CancellationToken cancellationToken); +} + +/// Runs after Clear reconciliation, before a success frame, under both session-wide leases. +internal sealed class WindowsInstallerEmptyDirectoryFinalizer +{ + private readonly WindowsInstallerDirectoryCleanupLayout _layout; + private readonly IWindowsInstallerDirectoryLedgerPersistence _ledger; + private readonly IWindowsInstallerEmptyDirectoryNative _native; + + internal WindowsInstallerEmptyDirectoryFinalizer(WindowsInstallerDirectoryCleanupLayout layout, + IWindowsInstallerDirectoryLedgerPersistence ledger, IWindowsInstallerEmptyDirectoryNative native) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(ledger); + ArgumentNullException.ThrowIfNull(native); + _layout = layout; + _ledger = ledger; + _native = native; + } + + internal static WindowsInstallerEmptyDirectoryFinalizer CreateDefault() => new( + WindowsInstallerDirectoryCleanupLayout.CreateDefault(), WindowsInstallerDirectoryLedgerPersistence.CreateDefault(), + WindowsInstallerEmptyDirectoryNative.Instance); + + internal async Task CompleteAsync(InstallerTransactionSnapshot verified, + Func releaseResources, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(verified); + ArgumentNullException.ThrowIfNull(releaseResources); + verified.Validate(); + if (verified.Journal.Operation != InstallerOperation.Uninstall || verified.Journal.Phase != InstallerTransactionPhase.Verified) + { + throw WindowsInstallerDirectoryLedger.Failure("terminal_not_verified_uninstall"); + } + WindowsInstallerDirectoryLedger? before = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false); + WindowsInstallerDirectoryLedger prepared = (before ?? WindowsInstallerDirectoryLedger.Empty).BeginTerminal(verified); + if (before?.Terminal is null) + { + // A committed Clear replay may have no ledger, or only newly recreated directory IDs. + // Its authoritative native verification already succeeded; arm the same checkpoint again. + await _ledger.SaveAsync(before, prepared, cancellationToken).ConfigureAwait(false); + } + WindowsInstallerDirectoryLedger current = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false) + ?? throw WindowsInstallerDirectoryLedger.Failure("terminal_missing"); + RequireExact(prepared, current); + + // This releases machine/user certificate persistence and transaction guards. It does not + // release exclusive installer authority or the application lease owned by the outer host. + await releaseResources().ConfigureAwait(false); + var entries = new List(6); + foreach (InstallerDirectoryRole role in WindowsInstallerDirectoryCleanupLayout.DeletionOrder) + { + cancellationToken.ThrowIfCancellationRequested(); + WindowsInstallerOwnedDirectory? owned = current.Directories.SingleOrDefault(entry => entry.Role == role); + InstallerDirectoryCleanupDisposition disposition = RemoveOne(_layout.GetPath(role), owned, cancellationToken); + entries.Add(new(role, disposition)); + } + var report = new InstallerDirectoryCleanupReport(entries.OrderBy(entry => entry.Role)); + report.Validate(); + // This is the final durable mutation. Any preceding cancellation, exception or process exit + // retains the verified checkpoint outside the directories being removed. + await _ledger.DeleteAsync(current, cancellationToken).ConfigureAwait(false); + return report; + } + + private InstallerDirectoryCleanupDisposition RemoveOne(string path, WindowsInstallerOwnedDirectory? owned, + CancellationToken cancellationToken) + { + Exception? deletionFailure = null; + using (IWindowsInstallerDirectoryDeletionLease? directory = _native.Open(path, cancellationToken)) + { + if (directory is null) { return InstallerDirectoryCleanupDisposition.Missing; } + if (owned is null || owned.Identity != directory.Identity) + { + return InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership; + } + directory.VerifyOwnedSecurity(); + if (!directory.IsEmpty) { return InstallerDirectoryCleanupDisposition.RetainedNonEmpty; } + cancellationToken.ThrowIfCancellationRequested(); + try { directory.DeleteEmpty(); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or Win32Exception) + { + deletionFailure = exception; + } + } + using IWindowsInstallerDirectoryDeletionLease? after = _native.Open(path, cancellationToken); + if (after is null) { return InstallerDirectoryCleanupDisposition.Deleted; } + if (owned.Identity != after.Identity) { return InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership; } + after.VerifyOwnedSecurity(); + if (!after.IsEmpty) { return InstallerDirectoryCleanupDisposition.RetainedNonEmpty; } + if (deletionFailure is not null) + { + throw new InstallerProtocolException("installer.directory_ledger.directory_delete_failed", deletionFailure); + } + throw new InstallerStateUncertainException("installer.directory_ledger.directory_delete_not_observed"); + } + + private static void RequireExact(WindowsInstallerDirectoryLedger expected, WindowsInstallerDirectoryLedger actual) + { + if (!WindowsInstallerDirectoryLedgerCodec.Serialize(expected).AsSpan() + .SequenceEqual(WindowsInstallerDirectoryLedgerCodec.Serialize(actual))) + { + throw WindowsInstallerDirectoryLedger.Failure("terminal_changed"); + } + } +} + +internal sealed class WindowsInstallerEmptyDirectoryNative : IWindowsInstallerEmptyDirectoryNative +{ + internal static WindowsInstallerEmptyDirectoryNative Instance { get; } = new(); + + public IWindowsInstallerDirectoryDeletionLease? Open(string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + WindowsInstallerDirectoryAnchor? anchor = null; + SafeFileHandle? handle = null; + try + { + anchor = WindowsInstallerDirectoryAnchor.Acquire(Path.GetDirectoryName(path) + ?? throw WindowsInstallerDirectoryLedger.Failure("directory_path_invalid")); + // Read-only observation first. DELETE access is acquired only after ownership is proven. + handle = CreateFile(path, 0x80000000U, 3, 0, 3, 0x02200000U, 0); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + handle.Dispose(); + handle = null; + throw new Win32Exception(error); + } + WindowsFileIdentity identity = WindowsFileSystemNative.GetOrdinaryDirectoryIdentity(handle); + return new Lease(path, handle, anchor, identity); + } + catch (Win32Exception exception) when (exception.NativeErrorCode is 2 or 3) + { + handle?.Dispose(); + anchor?.Dispose(); + return null; + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + handle?.Dispose(); + anchor?.Dispose(); + return null; + } + catch { handle?.Dispose(); anchor?.Dispose(); throw; } + } + + private sealed class Lease(string path, SafeFileHandle handle, WindowsInstallerDirectoryAnchor anchor, + WindowsFileIdentity identity) : IWindowsInstallerDirectoryDeletionLease + { + private SafeFileHandle? _handle = handle; + public WindowsFileIdentity Identity => identity; + + public bool IsEmpty + { + get + { + ObjectDisposedException.ThrowIf(_handle is null, this); + using IEnumerator entries = Directory.EnumerateFileSystemEntries(path).GetEnumerator(); + return !entries.MoveNext() && !HasNamedDataStream(path); + } + } + + public void VerifyOwnedSecurity() + { + ObjectDisposedException.ThrowIf(_handle is null, this); + if (WindowsFileSystemNative.GetOrdinaryDirectoryIdentity(_handle) != identity + || !WindowsDirectoryAccessPolicy.IsTrustedRenameAnchor(WindowsDirectoryReadLease.ReadSecuritySnapshot(_handle))) + { + throw WindowsInstallerDirectoryLedger.Failure("owned_directory_unsafe"); + } + } + + public void DeleteEmpty() + { + VerifyOwnedSecurity(); + if (!IsEmpty) { throw new IOException("The owned directory is no longer empty."); } + // Reopen DELETE while the parent chain remains pinned. The current read handle must be + // released because its list access intentionally withholds delete sharing. + _handle!.Dispose(); + _handle = null; + SafeFileHandle deleting = CreateFile(path, 0x80010000U, 3, 0, 3, 0x02200000U, 0); + if (deleting.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + deleting.Dispose(); + throw new Win32Exception(error); + } + _handle = deleting; + VerifyOwnedSecurity(); + if (!IsEmpty) { throw new IOException("The owned directory is no longer empty."); } + WindowsInstallerDirectoryLedgerFileNative.DeleteHandle(deleting); + } + + public void Dispose() + { + _handle?.Dispose(); + _handle = null; + anchor.Dispose(); + } + } + + private static bool HasNamedDataStream(string path) + { + nint search = FindFirstStream(path, 0, out _, 0); + if (search != new nint(-1)) + { + _ = FindClose(search); + return true; + } + int error = Marshal.GetLastPInvokeError(); + if (error == 38) { return false; } + throw new Win32Exception(error); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StreamData + { + internal long StreamSize; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 296)] internal string StreamName; + } + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern SafeFileHandle CreateFile(string name, uint access, uint share, nint security, uint creation, uint flags, nint template); + + [DllImport("kernel32.dll", EntryPoint = "FindFirstStreamW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern nint FindFirstStream(string name, int informationLevel, out StreamData data, uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FindClose(nint search); +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerOwnedDirectoryCreation.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerOwnedDirectoryCreation.cs new file mode 100644 index 0000000..53a2ba3 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerOwnedDirectoryCreation.cs @@ -0,0 +1,126 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Windows.Files; +using ClashSharp.Windows.FileSecurity; +using Microsoft.Win32.SafeHandles; + +namespace ClashSharp.Installer.Windows.Transactions; + +internal interface IWindowsInstallerOwnedDirectoryCreationNative +{ + bool CreateDirectory(string path, DirectorySecurity security); + IWindowsInstallerCreatedDirectoryLease OpenCreatedDirectory(string path); +} + +internal interface IWindowsInstallerCreatedDirectoryLease : IDisposable +{ + WindowsFileIdentity Identity { get; } +} + +/// Never adopts an existing directory, including an unrecorded creation from an interrupted call. +internal sealed class WindowsInstallerOwnedDirectoryCreation +{ + private readonly WindowsInstallerDirectoryCleanupLayout _layout; + private readonly IWindowsInstallerDirectoryLedgerPersistence _ledger; + private readonly IWindowsInstallerOwnedDirectoryCreationNative _native; + + internal WindowsInstallerOwnedDirectoryCreation(WindowsInstallerDirectoryCleanupLayout layout, + IWindowsInstallerDirectoryLedgerPersistence ledger, IWindowsInstallerOwnedDirectoryCreationNative native) + { + _layout = layout; + _ledger = ledger; + _native = native; + } + + internal static WindowsInstallerOwnedDirectoryCreation CreateDefault() => new( + WindowsInstallerDirectoryCleanupLayout.CreateDefault(), WindowsInstallerDirectoryLedgerPersistence.CreateDefault(), + WindowsInstallerOwnedDirectoryCreationNative.Instance); + + internal void Create(string path, DirectorySecurity security) + { + ArgumentNullException.ThrowIfNull(security); + if (!_layout.TryGetRole(path, out InstallerDirectoryRole role)) + { + // Existing Service/MihomoService and isolated testing roots retain their original behavior. + _ = _native.CreateDirectory(path, security); + return; + } + // Reject foreign ledger state before creating anything. This executes only in the elevated + // helper's synchronous native directory operation, while it owns exclusive installer authority. + WindowsInstallerDirectoryLedger? before = _ledger.LoadAsync(CancellationToken.None).GetAwaiter().GetResult(); + if (!_native.CreateDirectory(path, security)) { return; } + using IWindowsInstallerCreatedDirectoryLease directory = _native.OpenCreatedDirectory(path); + WindowsInstallerDirectoryLedger desired = (before ?? WindowsInstallerDirectoryLedger.Empty) + .RecordCreated(role, directory.Identity); + _ledger.SaveAsync(before, desired, CancellationToken.None).GetAwaiter().GetResult(); + } +} + +internal sealed class WindowsInstallerOwnedDirectoryCreationNative : IWindowsInstallerOwnedDirectoryCreationNative +{ + internal static WindowsInstallerOwnedDirectoryCreationNative Instance { get; } = new(); + + public bool CreateDirectory(string path, DirectorySecurity security) + { + byte[] descriptor = security.GetSecurityDescriptorBinaryForm(); + GCHandle pinned = GCHandle.Alloc(descriptor, GCHandleType.Pinned); + try + { + var attributes = new SecurityAttributes + { + Length = Marshal.SizeOf(), Descriptor = pinned.AddrOfPinnedObject(), InheritHandle = 0, + }; + if (CreateDirectoryNative(path, ref attributes)) { return true; } + int error = Marshal.GetLastPInvokeError(); + if (error == 183) { return false; } + throw new Win32Exception(error); + } + finally { pinned.Free(); } + } + + public IWindowsInstallerCreatedDirectoryLease OpenCreatedDirectory(string path) + { + SafeFileHandle handle = CreateFile(path, 0x80000000U, 3, 0, 3, 0x02200000U, 0); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + handle.Dispose(); + throw new Win32Exception(error); + } + try + { + WindowsFileIdentity identity = WindowsFileSystemNative.GetOrdinaryDirectoryIdentity(handle); + if (!WindowsDirectoryAccessPolicy.IsTrustedRenameAnchor(WindowsDirectoryReadLease.ReadSecuritySnapshot(handle))) + { + throw WindowsInstallerDirectoryLedger.Failure("created_directory_unsafe"); + } + return new Lease(handle, identity); + } + catch { handle.Dispose(); throw; } + } + + private sealed class Lease(SafeFileHandle handle, WindowsFileIdentity identity) : IWindowsInstallerCreatedDirectoryLease + { + public WindowsFileIdentity Identity => identity; + public void Dispose() => handle.Dispose(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + internal int Length; + internal nint Descriptor; + internal int InheritHandle; + } + + [DllImport("kernel32.dll", EntryPoint = "CreateDirectoryW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateDirectoryNative(string path, ref SecurityAttributes attributes); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern SafeFileHandle CreateFile(string name, uint access, uint share, nint security, uint creation, uint flags, nint template); +} diff --git a/docs/design/2026-09-12-installer-empty-directory-cleanup.md b/docs/design/2026-09-12-installer-empty-directory-cleanup.md new file mode 100644 index 0000000..6d1fbf2 --- /dev/null +++ b/docs/design/2026-09-12-installer-empty-directory-cleanup.md @@ -0,0 +1,57 @@ +# 安装器空目录终结协议 + +**暂停检查点(2026-09-12)**:用户要求收尾并暂停。本文件及同分支的目录归属、原生删除、terminal store 与相关新测试属于未完成工作,单独保存,不包含在本次 main `2c0266b` 源码节点中。完整 Windows 装配、统一编译测试、崩溃恢复矩阵及服务器真实自动清理均未完成,不能将下述设计描述视为已验收功能。已经完成的 reader 句柄寿命、结果协议、Clear 回复前扩展点和 WPF 展示分别在 `96e9cf7`、`2c0266b`。 + +恢复时先核对本分支未验证源码,接通 protected stores、普通 helper 的 terminal finalizer、parent reader fallback,以及机器根创建归属;再统一执行 Windows 测试和隔离 native 探针。只有实际安装/修复/卸载及清理中断恢复通过后,才能把自动空目录清理计为产品能力。旧账户独立卸载仍须单独评估。 + +本批次仅清理安装器可证明创建、身份仍相同且为空的固定目录。既有版本只有目录安全验证,没有公共父目录创建记录;旧目录不补猜归属。 + +## 固定能力 + +六个角色由本机 ProgramFiles/CommonApplicationData known-folder 派生,不从 journal、命令行或 JSON 接受路径:ProgramFilesProduct、ProgramDataProduct、InstallerRoot、InstallerVersion、AuthorityRoot、AuthorityVersion。删除顺序为程序文件 product、InstallerVersion→InstallerRoot、AuthorityVersion→AuthorityRoot、ProgramDataProduct。Service/MihomoService 仍沿用原有机器资源删除和最终验证。 + +唯一归属/终结 ledger 是 CommonApplicationData 根下的 `ClashSharp.InstallerDirectories.v1.json`,位于六个待删目录外。不引入新目录、ADS、注册表存储或产品公开测试入口。新文件使用保护 DACL:Administrators/SYSTEM 全权、BUILTIN Users 只读,owner 为 Administrators;内容仅目录角色、volume/file ID 和既有非秘密事务字段。 + +每次读写都 pin known-folder 路径链,验证 ordinary/no-reparse/trusted rename-anchor;文件必须 ordinary、单 hardlink、owner/exact DACL 符合新协议。外来同名文件、未知 JSON 字段、重复角色、无效大小、损坏或不一致的事务证据均拒绝,不修 ACL、不接管、不把错误折成 missing。 + +## 创建归属 + +目录创建必须使用可区分“本调用新建成功”和“已存在”的原生创建结果。只有前者在持有新目录 guard 时取得 volume/file ID 并持久记录;已有目录绝不添加归属。记录中的角色必须来自固定派生路径。 + +创建成功后、归属 ledger 持久提交前发生崩溃,该目录之后属于无持久证据:保留并返回 RetainedUnprovenOwnership。预写的路径、目录名称、符合安全 ACL、同一目标 SID 都不能替代创建后记录的对象身份。此安全边界不承诺在该断点仍无目录残留。 + +记录使用有界 canonical 序列化、写穿透并 flush 的同目录原子提交模式,沿用现有 private journal 的保护文件创建方式;首次发布禁止覆盖,只有已认证现有文件才能原子替换。Load 拒绝非 canonical 字节,保持后续 bytes CAS 一致。发生未知写入结果时停止当前操作并重新读取真实 ledger;不重复外围安装事务。只清理由当前调用确知新建且身份匹配的临时文件;不扫描删除名称相似的外来文件。 + +在随机临时 ledger 创建后、原子 rename 前发生进程崩溃,可能保留该临时文件。当前调用的普通异常会尝试核对创建时 file ID 后回收,但重启不会根据名字认领或删除。这一极端边界明确保留给后续恢复协议;本批次不声称任意崩溃都绝对零文件残留。 + +## 终结 checkpoint + +ledger 的 nullable terminal 存储完整 canonical `Verified`、`Uninstall` journal 与 ContentHash。它保留 TransactionId nonce、TargetSid、package version/hash、phase/generation;不重建或改写身份。 + +1. `ClearVerifiedAsync` 在删除 active journal 前,验证当前 authoritative journal 与命令完全一致,将同一 Verified snapshot 写入 terminal 并重新读回核对。 +2. Active journal 清除后,helper 仍须完成原有 null reload 和 SessionGuard.Complete。helper 内本次 clear 的后续 reload 不用 terminal 冒充仍未清除的 active journal。 +3. Parent 的每次只读 Load 在 active journal 缺失时读取 terminal;有有效 terminal 就返回该 Verified snapshot,使 UI 保持准确 Uninstall recovery。若同时存在 active journal 和 terminal,必须完全一致;不接受更换目标/事务/包的请求。 +4. 新 helper 对 terminal-only recovery 可重验 Verified state,不能凭缺失 active journal判成功;保存同一 Verified snapshot是可验证的幂等操作,其他相位写入拒绝。 +5. 成功 Clear Uninstall 的公共 before-reply 回调先释放 operations(包括证书 persistence guard)和 stores,再执行清理;exclusive authority 和 application lease 一直保留。回调异常不得写成功 frame,不进入已 disposed stores 的 reconcile。 +6. 固定目录逐个核对持久对象身份、当前安全描述符、无 reparse 的完整路径链和为空条件,再通过同一对象 handle 非递归删除并重新观察。陌生对象/缺证据保留;非空目录保留;未知 I/O/权限/身份检查失败为失败而不是 clean。 +7. 六角色完整结果只允许 Missing、Deleted、RetainedUnprovenOwnership、RetainedNonEmpty。有保留项必须随成功 clear receipt 显式传给 parent,不能声称全部清净。清理结束后最后删除 ledger 并确认缺失,之后才能发送成功。 + +## 中断边界 + +| 断点 | 下次可用的事实 | 允许行为 | +| --- | --- | --- | +| 目录创建后、ID 提交前 | 没有该目录的持久 ID | 保留,不补认领 | +| terminal 写入前失败 | active Verified journal 仍存在 | 现有 Uninstall recovery | +| terminal 已提交、active journal 未删 | 两份完全相同的 Verified snapshot | 幂等重验和 clear | +| active journal 已删、目录清理中失败/进程退出 | ledger terminal 仍存在 | parent 暴露同一 Uninstall recovery;新 helper 逐项重验 | +| 部分目录已删 | 终结 snapshot 和剩余角色 ID 仍在 | Missing 幂等通过,其他对象仍需身份/安全/为空核验 | +| ledger 最后删除后、成功回复丢失 | 没有 pending checkpoint;parent 未收到成功 | 保持不确定;不能仅凭 journal/ledger absence 改判成功 | +| 对同一 Clear 的认证重放 | 原 nonce/state + 新 helper 真实最终验证 | 原 SessionGuard 的 VerifyCommittedReplay;仍执行 before-reply 清理 | + +最后的回复丢失窗口不新增永久成功历史。若重试,必须先证明旧 helper 退出、保留原 command/state,重新建立正常认证边界并真实验证包、服务、机器文件、证书与目录;不能复用 faulted broker 或让一般只读 package inspection 代替这些事实。当前结果保持 uncertain 是允许且准确的结果。 + +## 改动边界与验证 + +Windows 新增固定 layout/ledger codec、保护文件 persistence、创建记录、事务 store decorator 与终结清理 native;接入 ProtectedStateStores、DirectoryNative、短期 parent reader 和 Windows helper authority。Core 新增六角色报告、clear receipt、严格 helper response 与 before-reply 回调,由独立协作者实施。 + +测试覆盖:创建/记录顺序、既有目录不认领、对象替换/ACL/reparse、foreign 同名 ledger、异常/取消、terminal-only恢复、清除前后持久边界、释放资源但保留两把 lease、六角色固定顺序、非空与未知归属保留、删除后重观察失败、Clear committed replay、成功回复前异常。所有本地测试使用隔离临时普通路径和注入 native,不修改本机产品/代理/证书。 From 0508a35df7b0755c86461afaf255814e9e49b4d3 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Sun, 13 Sep 2026 00:39:39 +0800 Subject: [PATCH 20/22] feat(installer): align installer branding with the green core logo - Theme accent and hexagon gradient adopt the canonical SVG green stops (#0C7428 / #0B7026 / #0A6822); soft accent tint moves to #E8F5E9 - Regenerate LogoInstaller.ico from the updated vector theme - Rename the shell accent contract test and invert its palette assertions --- .../InstallerExecutableContractTests.cs | 10 +++++----- .../Themes/InstallerTheme.xaml | 14 +++++++------- ClashSharp/Installer/LogoInstaller.ico | Bin 381038 -> 381038 bytes 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs index a6217bd..81e454b 100644 --- a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs +++ b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs @@ -221,7 +221,7 @@ public void WindowCloseWaitsForTheActiveRuntimeGeneration() } [Fact] - public void ShellUsesCSharpPurpleAsItsAccessibleInstallerAccent() + public void ShellUsesBrandGreenAsItsAccessibleInstallerAccent() { XNamespace presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; @@ -234,7 +234,7 @@ public void ShellUsesCSharpPurpleAsItsAccessibleInstallerAccent() XElement accent = Assert.Single( theme.Descendants(presentation + "Color"), color => (string?)color.Attribute(x + "Key") == "InstallerAccentColor"); - Assert.Equal("#7355DD", accent.Value.Trim()); + Assert.Equal("#0C7428", accent.Value.Trim()); Assert.Contains( theme.Descendants(presentation + "DataTrigger"), trigger => (string?)trigger.Attribute("Value") == "True" @@ -249,9 +249,9 @@ public void ShellUsesCSharpPurpleAsItsAccessibleInstallerAccent() "ClashSharp.Installer", "Themes", "InstallerTheme.xaml")); - Assert.DoesNotContain("#0C7428", themeText, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("#0B7026", themeText, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("#0A6822", themeText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("#7355DD", themeText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("#8066E8", themeText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("#6042C4", themeText, StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/ClashSharp/ClashSharp.Installer/Themes/InstallerTheme.xaml b/ClashSharp/ClashSharp.Installer/Themes/InstallerTheme.xaml index 41af726..3b0b2e6 100644 --- a/ClashSharp/ClashSharp.Installer/Themes/InstallerTheme.xaml +++ b/ClashSharp/ClashSharp.Installer/Themes/InstallerTheme.xaml @@ -1,9 +1,9 @@ - #7355DD - #6849D2 - #593BBE - #F0ECFF + #0C7428 + #0B7026 + #0A6822 + #E8F5E9 #F7F6FA #DED9E8 #62606B @@ -30,9 +30,9 @@ - - - + + + diff --git a/ClashSharp/Installer/LogoInstaller.ico b/ClashSharp/Installer/LogoInstaller.ico index 27d2b028568d2e9ca0ed398446a1d669a127bb53..40ea05e9e49a657efb525d08078bee85808134eb 100644 GIT binary patch literal 381038 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 literal 381038 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 From 1db8ab9f4aef172f1cdf8906cc8909958bd8a761 Mon Sep 17 00:00:00 2001 From: Water-Run Date: Tue, 22 Sep 2026 19:39:57 +0800 Subject: [PATCH 21/22] fix(installer): name the maintenance window ClashSharp manager --- ClashSharp/ClashSharp.Installer/MainWindow.xaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ClashSharp/ClashSharp.Installer/MainWindow.xaml b/ClashSharp/ClashSharp.Installer/MainWindow.xaml index fee5645..a911334 100644 --- a/ClashSharp/ClashSharp.Installer/MainWindow.xaml +++ b/ClashSharp/ClashSharp.Installer/MainWindow.xaml @@ -1,13 +1,13 @@ @@ -25,7 +25,7 @@ -