From 7878ee6e783b144c47d22aef70c524ac915f7ee1 Mon Sep 17 00:00:00 2001 From: Oksion Date: Fri, 16 Oct 2026 23:33:25 +0500 Subject: [PATCH 1/7] =?UTF-8?q?fix(perf):=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B0=20=D0=BF=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20Windows=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20(XIC-101=20=D0=BF.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Записанное нами хранится в AppConfig.PowerOverlayWritten (ac/battery), а не в памяти: выключение опции после перезапуска откатывает ровно своё. Проверка «есть ли что откатывать» на смене питания — под замком. Co-Authored-By: Claude Opus 5.5 --- src/Config/AppConfig.cs | 5 +++ src/SystemIntegration/PowerModeSync.cs | 41 +++++++++++++++++---- tests/XiControl.Tests/PowerModeSyncTests.cs | 26 +++++++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/Config/AppConfig.cs b/src/Config/AppConfig.cs index dd7e5d2..8ff21df 100644 --- a/src/Config/AppConfig.cs +++ b/src/Config/AppConfig.cs @@ -432,6 +432,11 @@ public sealed class AppConfig /// работает служба Xiaomi MiDeviceService, не пишем — она делает то же сама. public bool PowerModeSync { get; set; } = false; + /// Что из режима питания Windows записали мы, по источнику (ac/battery → + /// GUID overlay). Выключая , откатываем только это — и после + /// перезапуска тоже (XIC-101). «Сбалансированный» не храним; null — ничего нашего не стоит. + public Dictionary? PowerOverlayWritten { get; set; } + /// Позиция окна «Монитор» (виджет перетаскивается мышью); null — по центру. public int? MonitorX { get; set; } public int? MonitorY { get; set; } diff --git a/src/SystemIntegration/PowerModeSync.cs b/src/SystemIntegration/PowerModeSync.cs index e2f8552..7da6cba 100644 --- a/src/SystemIntegration/PowerModeSync.cs +++ b/src/SystemIntegration/PowerModeSync.cs @@ -45,10 +45,10 @@ internal enum Skip { None, Disabled, ServiceRunning, OtherScheme, NoMode, Alread private readonly IPowerOverlay _overlay; private readonly Func _service; + // Что записали мы, по источнику, — в конфиге (AppConfig.PowerOverlayWritten), а не в памяти: + // выключая опцию, откатываем только своё, и это должно пережить перезапуск (XIC-101). + // Выбор человека в параметрах Windows не трогаем. Читается и пишется только под замком. private readonly object _lock = new(); - // что записали мы, по источнику (true — сеть): выключая опцию, откатываем только своё, - // выбор человека в параметрах Windows не трогаем - private readonly Dictionary _written = []; private Skip _lastSkip = Skip.None; public PowerModeSync(AppConfig cfg, IMifsClient mifs, IPowerEvents power) @@ -121,7 +121,7 @@ internal void Sync() { if (_overlay.SetActive(target)) { - _written[online] = target; + Remember(online, target); Log.Write($"PowerModeSync: {(online ? "сеть" : "батарея")} → {Name(target)}"); } else skip = Skip.Unavailable; @@ -142,10 +142,10 @@ internal void UndoCurrent() try { bool online = _power.IsOnline; - if (!_written.TryGetValue(online, out var w)) return; + if (_cfg.PowerOverlayWritten?.TryGetValue(Key(online), out var w) != true) return; if (ShouldUndo(w, Actual()) && _overlay.SetActive(Balanced)) Log.Write($"PowerModeSync: выключено, {(online ? "сеть" : "батарея")} → сбалансированный"); - _written.Remove(online); + Remember(online, Balanced); // своё откатили (или его уже сменил человек) — забыть } catch (Exception ex) { Log.Ex("PowerModeSync.Undo", ex); } } @@ -156,7 +156,34 @@ private void OnPowerModeChanged(PowerModes mode) if (mode is not (PowerModes.Resume or PowerModes.StatusChange)) return; // включено — сверить новый источник; выключено — доделать откат, если он остался на нём if (_cfg.PowerModeSync) Request(); - else if (_written.Count > 0) Undo(); + else + { + bool pending; + lock (_lock) pending = _cfg.PowerOverlayWritten is { Count: > 0 }; + if (pending) Undo(); + } + } + + private static string Key(bool online) => online ? "ac" : "battery"; + + /// Запомнить, что стоит нашей записью на источнике. «Сбалансированный» откатывать + /// не нужно — его не храним. Звать под замком. Конфиг пишем только при изменении. + private void Remember(bool online, Guid overlay) + { + var written = _cfg.PowerOverlayWritten; + bool known = written?.ContainsKey(Key(online)) == true; + if (overlay == Balanced) + { + if (!known) return; + written!.Remove(Key(online)); + if (written.Count == 0) _cfg.PowerOverlayWritten = null; + } + else + { + if (known && written![Key(online)] == overlay) return; + (_cfg.PowerOverlayWritten ??= [])[Key(online)] = overlay; + } + _cfg.Save(); } private PerfMode? Mode() diff --git a/tests/XiControl.Tests/PowerModeSyncTests.cs b/tests/XiControl.Tests/PowerModeSyncTests.cs index c7a214e..487c1f1 100644 --- a/tests/XiControl.Tests/PowerModeSyncTests.cs +++ b/tests/XiControl.Tests/PowerModeSyncTests.cs @@ -164,6 +164,32 @@ public void Откат_второго_источника_доделываетс _overlay.Overlay[false].Should().Be(Guid.Empty); } + [Fact] + public void Откат_переживает_перезапуск() + { + _sync.Sync(); // сеть ← эффективность + _cfg.PowerOverlayWritten.Should().ContainKey("ac"); + + // «перезапуск»: новый экземпляр с тем же конфигом, памяти прежнего нет + var restarted = new PowerModeSync(_cfg, _mifs, _power, _overlay, () => _service); + _cfg.PowerModeSync = false; + restarted.UndoCurrent(); + + _overlay.Overlay[true].Should().Be(Guid.Empty, "откатываем записанное нами и после перезапуска (XIC-101)"); + _cfg.PowerOverlayWritten.Should().BeNull("откатили — помнить нечего"); + } + + [Fact] + public void Сбалансированный_не_хранится() + { + _mifs.Mode = PerfMode.Turbo; + _sync.Sync(); + _mifs.Mode = PerfMode.Auto; // своя запись «сбалансированного» — откатывать нечего + _sync.Sync(); + + _cfg.PowerOverlayWritten.Should().BeNull(); + } + [Fact] public void Отказ_записи_не_запоминается_как_наше() { From 720bea36d4e18c1460b52934d8bc29268f73144e Mon Sep 17 00:00:00 2001 From: Oksion Date: Fri, 16 Oct 2026 23:34:49 +0500 Subject: [PATCH 2/7] =?UTF-8?q?fix(api):=20=D0=B1=D1=80=D0=BE=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=BE=20=D0=B4=D0=B5=D0=B4?= =?UTF-8?q?=D0=BB=D0=B0=D0=B9=D0=BD=D1=83=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5/=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B2=D1=81=D0=BF=D0=BB=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B5=20(XIC-101=20?= =?UTF-8?q?=D0=BF.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpApi.Bounded: по дедлайну бросаем ожидание, а саму висящую операцию http.sys наблюдаем и глотаем — её падение после закрытия сокета больше не пишется в log.txt «необработанным». И для чтения тела, и для записи ответа. Тест проверен на падение без исправления. Co-Authored-By: Claude Opus 5.5 --- src/SystemIntegration/HttpApi.cs | 28 +++++++++++++++-- tests/XiControl.Tests/HttpApiBodyTests.cs | 38 +++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/SystemIntegration/HttpApi.cs b/src/SystemIntegration/HttpApi.cs index 9c569d5..7d2c05b 100644 --- a/src/SystemIntegration/HttpApi.cs +++ b/src/SystemIntegration/HttpApi.cs @@ -96,10 +96,34 @@ private async Task Serve(HttpListenerContext ctx, CancellationToken ct) ctx.Response.StatusCode = code; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = buf.Length; - await ctx.Response.OutputStream.WriteAsync(buf, ct).AsTask().WaitAsync(ct).ConfigureAwait(false); + await Bounded(ctx.Response.OutputStream.WriteAsync(buf, ct).AsTask(), ct).ConfigureAwait(false); ctx.Response.Close(); } + /// + /// Ждать операцию ввода-вывода не дольше . Поток http.sys отмену не + /// слушает, поэтому по дедлайну мы бросаем ожидание, а сама операция остаётся висеть и потом + /// падает (сокет закрыл вызывающий). Без наблюдателя её исключение всплыло бы в + /// UnobservedTaskException и легло в log.txt «необработанным» — побочный эффект дедлайна, + /// а не сбой (XIC-101). Поэтому брошенную операцию наблюдаем и глотаем. + /// + internal static async Task Bounded(Task io, CancellationToken ct) + { + try { return await io.WaitAsync(ct).ConfigureAwait(false); } + catch (OperationCanceledException) when (!io.IsCompleted) { Observe(io); throw; } + } + + /// + internal static async Task Bounded(Task io, CancellationToken ct) + { + try { await io.WaitAsync(ct).ConfigureAwait(false); } + catch (OperationCanceledException) when (!io.IsCompleted) { Observe(io); throw; } + } + + private static void Observe(Task io) => _ = io.ContinueWith(static t => _ = t.Exception, + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + internal enum BodyError { None, TooLarge, BadUtf8 } /// @@ -115,7 +139,7 @@ internal enum BodyError { None, TooLarge, BadUtf8 } int count = 0; while (count < buf.Length) { - int read = await input.ReadAsync(buf.AsMemory(count), ct).AsTask().WaitAsync(ct).ConfigureAwait(false); + int read = await Bounded(input.ReadAsync(buf.AsMemory(count), ct).AsTask(), ct).ConfigureAwait(false); if (read == 0) break; count += read; } diff --git a/tests/XiControl.Tests/HttpApiBodyTests.cs b/tests/XiControl.Tests/HttpApiBodyTests.cs index 3b47d27..3864ec7 100644 --- a/tests/XiControl.Tests/HttpApiBodyTests.cs +++ b/tests/XiControl.Tests/HttpApiBodyTests.cs @@ -86,6 +86,44 @@ public async Task Зависшее_чтение_обрывается_по_дед await act.Should().ThrowAsync(); } + [Fact] + public async Task Брошенное_по_дедлайну_чтение_не_всплывает_необработанным() + { + // Сокет закрыли после дедлайна — висящая операция падает. Её исключение не должно + // уйти в UnobservedTaskException (оно пишется в log.txt «необработанным», XIC-101). + const string marker = "XIC-101: сокет закрыт после дедлайна"; + bool leaked = false; + void OnUnobserved(object? _, UnobservedTaskExceptionEventArgs e) + { + if (e.Exception.InnerExceptions.Any(x => x.Message == marker)) leaked = true; + } + TaskScheduler.UnobservedTaskException += OnUnobserved; + try + { + await AbandonAndFault(marker); + for (int i = 0; i < 3; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + leaked.Should().BeFalse(); + } + finally { TaskScheduler.UnobservedTaskException -= OnUnobserved; } + } + + // Отдельный метод: после выхода из него на упавшую задачу не остаётся ни одной ссылки, и + // сборщик её финализирует — иначе тест прошёл бы и без исправления. + private static async Task AbandonAndFault(string marker) + { + var pending = new TaskCompletionSource(); + using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50))) + { + Func act = () => HttpApi.Bounded(pending.Task, cts.Token); + await act.Should().ThrowAsync(); + } + pending.SetException(new IOException(marker)); + } + // ---- Потоки-фейки ---- /// Бесконечный поток нулей; считает, сколько отдал. From b1b041bb80974337bcb238799b33a308bc159522 Mon Sep 17 00:00:00 2001 From: Oksion Date: Fri, 16 Oct 2026 23:36:10 +0500 Subject: [PATCH 3/7] =?UTF-8?q?fix(drivers):=20=C2=AB=D0=BD=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=C2=BB=20=D0=BA=20?= =?UTF-8?q?=D1=81=D0=BB=D1=83=D0=B6=D0=B1=D0=B5=20Xiaomi=20=E2=80=94=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D0=BE=20ERROR=5FA?= =?UTF-8?q?CCESS=5FDENIED=20(XIC-101=20=D0=BF.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прочие сбои чтения (SCM/RPC недоступен) — новое состояние Unknown: на вкладке «Драйверы» «Не прочиталось, подробности в log.txt», без кнопок и без совета про права. Строки RU/EN/ZH. StateOnFailure — чистая функция под тестами. Co-Authored-By: Claude Opus 5.5 --- src/Localization/lang/en.json | 4 +++- src/Localization/lang/ru.json | 4 +++- src/Localization/lang/zh.json | 4 +++- src/SystemIntegration/OemService.cs | 13 ++++++++++--- src/Ui/Settings/DriversTab.cs | 6 +++++- tests/XiControl.Tests/OemServiceTests.cs | 12 ++++++++++++ tests/XiControl.Tests/PowerModeSyncTests.cs | 1 + 7 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/Localization/lang/en.json b/src/Localization/lang/en.json index 4d79f82..1e34b84 100644 --- a/src/Localization/lang/en.json +++ b/src/Localization/lang/en.json @@ -324,9 +324,11 @@ "settings.drivers.oem.base.desc": "Xiaomi's base service: Xiaomi account, privacy consents, telemetry, the first-run wizard and update hints for Xiaomi's own apps. It does not get in XiControl's way — disable it only if you want to.", "settings.drivers.oem.base.off": "The service is disabled. XiControl does not need it; turn it back on if you use a Xiaomi account or Xiaomi's own apps.", "settings.drivers.oem.base.missing": "The service was not found — XiControl does not need it, everything works without it.", - "settings.drivers.oem.noaccess": "Could not read the service state — see the log for details.", + "settings.drivers.oem.noaccess": "Windows won't let us read the service state: it is protected. How to disable it by hand — see \"About the service\".", + "settings.drivers.oem.unknown": "Could not read the service state — see the log (log.txt) for details.", "settings.drivers.oem.v.missing": "Not installed", "settings.drivers.oem.v.noaccess": "No access", + "settings.drivers.oem.v.unknown": "Not read", "settings.drivers.oem.disable": "Disable", "settings.drivers.oem.disable.confirm": "Really disable?", "settings.drivers.oem.enable": "Enable", diff --git a/src/Localization/lang/ru.json b/src/Localization/lang/ru.json index bcf5086..662b95b 100644 --- a/src/Localization/lang/ru.json +++ b/src/Localization/lang/ru.json @@ -324,9 +324,11 @@ "settings.drivers.oem.base.desc": "Базовая служба Xiaomi: аккаунт Xiaomi, согласия о приватности, телеметрия, мастер первого запуска и подсказки обновлений родных приложений. XiControl она не мешает — отключайте только по желанию.", "settings.drivers.oem.base.off": "Служба отключена. XiControl она не нужна; включите её, если пользуетесь аккаунтом Xiaomi или родными приложениями Xiaomi.", "settings.drivers.oem.base.missing": "Служба не найдена — XiControl она не нужна, всё работает без неё.", - "settings.drivers.oem.noaccess": "Не удалось прочитать состояние службы — подробности в журнале.", + "settings.drivers.oem.noaccess": "Windows не даёт прочитать состояние службы: она защищена. Как отключить её вручную — по ссылке «Подробнее о службе».", + "settings.drivers.oem.unknown": "Не удалось прочитать состояние службы — подробности в журнале (log.txt).", "settings.drivers.oem.v.missing": "Не установлена", "settings.drivers.oem.v.noaccess": "Нет доступа", + "settings.drivers.oem.v.unknown": "Не прочиталось", "settings.drivers.oem.disable": "Отключить", "settings.drivers.oem.disable.confirm": "Точно отключить?", "settings.drivers.oem.enable": "Включить", diff --git a/src/Localization/lang/zh.json b/src/Localization/lang/zh.json index c45c942..a41028d 100644 --- a/src/Localization/lang/zh.json +++ b/src/Localization/lang/zh.json @@ -324,9 +324,11 @@ "settings.drivers.oem.base.desc": "小米基础服务:小米账号、隐私授权、遥测、首次启动向导以及小米自带应用的更新提示。它不会干扰 XiControl——是否禁用随你。", "settings.drivers.oem.base.off": "该服务已禁用。XiControl 不需要它;如果你使用小米账号或小米自带的应用,可以重新启用。", "settings.drivers.oem.base.missing": "未找到该服务——XiControl 不需要它,没有它一切照常工作。", - "settings.drivers.oem.noaccess": "无法读取服务状态——详情见日志。", + "settings.drivers.oem.noaccess": "Windows 不允许读取该服务的状态:它受保护。手动禁用的方法见“关于该服务”。", + "settings.drivers.oem.unknown": "无法读取服务状态——详情见日志(log.txt)。", "settings.drivers.oem.v.missing": "未安装", "settings.drivers.oem.v.noaccess": "无权访问", + "settings.drivers.oem.v.unknown": "未能读取", "settings.drivers.oem.disable": "禁用", "settings.drivers.oem.disable.confirm": "确定禁用?", "settings.drivers.oem.enable": "启用", diff --git a/src/SystemIntegration/OemService.cs b/src/SystemIntegration/OemService.cs index 932acb3..2fb018c 100644 --- a/src/SystemIntegration/OemService.cs +++ b/src/SystemIntegration/OemService.cs @@ -3,8 +3,9 @@ namespace XiControl.SystemIntegration; -/// Что видно о службе Xiaomi MiDeviceService. -public enum OemServiceState { NotInstalled, Running, Stopped, Disabled, NoAccess } +/// Что видно о службе Xiaomi MiDeviceService. NoAccess — Windows отказала в доступе; +/// Unknown — прочие сбои чтения (SCM недоступен, RPC), их с правами не путаем (XIC-101). +public enum OemServiceState { NotInstalled, Running, Stopped, Disabled, NoAccess, Unknown } /// Тип запуска службы (то, что запоминаем перед отключением и возвращаем потом). public enum OemStartType { Unknown, Auto, DelayedAuto, Manual, Disabled } @@ -59,7 +60,9 @@ public static OemServiceInfo Read(string name) catch (Exception ex) { Log.Ex("OemService.Read " + name, ex); - return new(OemServiceState.NoAccess, OemStartType.Unknown); + // «нет доступа» — только когда Windows так и сказала: иначе человек зря пойдёт + // перезапускать от администратора, хотя причина в недоступном SCM (XIC-101) + return new(StateOnFailure(ex), OemStartType.Unknown); } } @@ -107,6 +110,10 @@ internal static OemStartType RestoreTarget(string? saved) => internal static bool ShouldRemember(OemStartType current) => current is OemStartType.Auto or OemStartType.DelayedAuto or OemStartType.Manual; + /// Чем показать сбой чтения: «нет доступа» — только если Windows так и сказала. + internal static OemServiceState StateOnFailure(Exception ex) => + ex is Win32Exception { NativeErrorCode: ERROR_ACCESS_DENIED } ? OemServiceState.NoAccess : OemServiceState.Unknown; + /// Состояние для показа: работающая — «запущена» при любом типе запуска (отключение /// могло не остановить её), стоящая с Disabled — «отключена». internal static OemServiceState StateOf(uint scmState, OemStartType start) => scmState switch diff --git a/src/Ui/Settings/DriversTab.cs b/src/Ui/Settings/DriversTab.cs index ea2696b..0a70ebc 100644 --- a/src/Ui/Settings/DriversTab.cs +++ b/src/Ui/Settings/DriversTab.cs @@ -130,6 +130,10 @@ private void AddOemServiceRow(SettingsToolkit ui, SettingsActions act, Action re desc = Loc.T("settings.drivers.oem.noaccess"); ctl = ui.ValueLabel(Loc.T("settings.drivers.oem.v.noaccess")); break; + case OemServiceState.Unknown: // не прочиталось по другой причине — без кнопок, причина в журнале + desc = Loc.T("settings.drivers.oem.unknown"); + ctl = ui.ValueLabel(Loc.T("settings.drivers.oem.v.unknown")); + break; case OemServiceState.Disabled: desc = Loc.T(prefix + "off"); ctl = ui.ConfirmButton("settings.drivers.oem.enable", "settings.drivers.oem.enable.confirm", () => act.SetOemService(name, true, rebuild)); @@ -141,7 +145,7 @@ private void AddOemServiceRow(SettingsToolkit ui, SettingsActions act, Action re break; } // операция идёт (служба останавливается до ~15 с) — вместо кнопки неактивная надпись - if (v.Pending is { } on && v.Info.State is not (OemServiceState.NotInstalled or OemServiceState.NoAccess)) + if (v.Pending is { } on && v.Info.State is not (OemServiceState.NotInstalled or OemServiceState.NoAccess or OemServiceState.Unknown)) { var b = Button(ui, on ? "settings.drivers.oem.busy.on" : "settings.drivers.oem.busy.off", () => { }); b.Enabled = false; diff --git a/tests/XiControl.Tests/OemServiceTests.cs b/tests/XiControl.Tests/OemServiceTests.cs index 887cfd3..637dab0 100644 --- a/tests/XiControl.Tests/OemServiceTests.cs +++ b/tests/XiControl.Tests/OemServiceTests.cs @@ -56,4 +56,16 @@ public void Запомненное_проходит_круг_через_стро [InlineData(StopPending, OemStartType.Disabled, OemServiceState.Running)] public void Состояние_для_показа(uint scm, OemStartType start, OemServiceState expected) => OemService.StateOf(scm, start).Should().Be(expected); + + // XIC-101: «нет доступа» — только ERROR_ACCESS_DENIED; прочее — «не прочиталось», без совета про права + [Theory] + [InlineData(5, OemServiceState.NoAccess)] // ERROR_ACCESS_DENIED + [InlineData(1722, OemServiceState.Unknown)] // RPC_S_SERVER_UNAVAILABLE — SCM недоступен + [InlineData(6, OemServiceState.Unknown)] // ERROR_INVALID_HANDLE + public void Сбой_чтения_Win32(int code, OemServiceState expected) => + OemService.StateOnFailure(new System.ComponentModel.Win32Exception(code)).Should().Be(expected); + + [Fact] + public void Сбой_чтения_не_Win32_не_про_права() => + OemService.StateOnFailure(new InvalidOperationException()).Should().Be(OemServiceState.Unknown); } diff --git a/tests/XiControl.Tests/PowerModeSyncTests.cs b/tests/XiControl.Tests/PowerModeSyncTests.cs index 487c1f1..d3da883 100644 --- a/tests/XiControl.Tests/PowerModeSyncTests.cs +++ b/tests/XiControl.Tests/PowerModeSyncTests.cs @@ -76,6 +76,7 @@ public void Решение_по_порядку_причин() [InlineData(OemServiceState.Stopped)] [InlineData(OemServiceState.Disabled)] [InlineData(OemServiceState.NoAccess)] + [InlineData(OemServiceState.Unknown)] public void Служба_не_запущена_пишем(OemServiceState state) => PowerModeSync.Decide(true, state, null, PerfMode.Turbo, null, out _).Should().Be(Skip.None); From fdcfe25aaef4b4232c214aa17e0fe04bb2ad64d4 Mon Sep 17 00:00:00 2001 From: Oksion Date: Fri, 16 Oct 2026 23:36:45 +0500 Subject: [PATCH 4/7] =?UTF-8?q?fix(charge):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=20=D1=81=D0=BD=D0=BE=D0=BC=20=D0=BF=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=20=D0=BD=D0=B5=20=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D0=BC,?= =?UTF-8?q?=20=D0=B0=20=D1=81=D1=80=D0=B0=D0=B7=D1=83=20=D0=BF=D0=B8=D1=88?= =?UTF-8?q?=D0=B5=D0=BC=20(XIC-101=20=D0=BF.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В ветке hurry (сон, завершение сеанса) GET перед записью убран: это был лишний вызов WMI в окне, где времени нет. Запись там без сброса в «выкл», окна без защиты нет. Цена — одна запись в EC на каждом уходе в сон. Co-Authored-By: Claude Opus 5.5 --- src/SystemIntegration/ChargeGuard.cs | 4 +++- tests/XiControl.Tests/ChargeGuardTests.cs | 16 ++++++++++++++++ tests/XiControl.Tests/Fakes.cs | 3 ++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/SystemIntegration/ChargeGuard.cs b/src/SystemIntegration/ChargeGuard.cs index f2c4efe..9eb7644 100644 --- a/src/SystemIntegration/ChargeGuard.cs +++ b/src/SystemIntegration/ChargeGuard.cs @@ -79,7 +79,9 @@ public void Reapply(string reason, bool hurry = false) int pct = _limitWanted(); if (pct >= 100) return; - int? now = _mifs.GetChargeLimit(); + // Перед сном не спрашиваем: времени нет, а чтение — лишний вызов WMI ровно в этом окне + // (XIC-101). Риска нет: в спешке пишем одной командой без сброса в «выкл». + int? now = hurry ? null : _mifs.GetChargeLimit(); if (now == pct) return; // EC уже держит нужный порог — молча и не трогая if (!_mifs.SetChargeLimit(pct, resetFirst: !hurry)) diff --git a/tests/XiControl.Tests/ChargeGuardTests.cs b/tests/XiControl.Tests/ChargeGuardTests.cs index a85a8fa..9c89a5a 100644 --- a/tests/XiControl.Tests/ChargeGuardTests.cs +++ b/tests/XiControl.Tests/ChargeGuardTests.cs @@ -133,6 +133,22 @@ public void Перед_сном_пишем_одной_командой_без_с _mifs.ChargeLimitResets.Should().Equal(false); } + [Fact] + public void Перед_сном_порог_не_спрашиваем_а_сразу_пишем() + { + // XIC-101: времени до засыпания мало — чтение было лишним вызовом WMI в этом окне. + // Пишем даже если порог уже стоит: запись без сброса окна «без защиты» не открывает. + _mifs.ChargeLimit = 80; + using var guard = Create(); + + _power.RaisePower(PowerModes.Suspend); + _power.RaiseSession(); + + _mifs.ChargeLimitReads.Should().Be(0); + _mifs.ChargeLimitCalls.Should().Equal(80, 80); + _mifs.ChargeLimitResets.Should().Equal(false, false); + } + [Fact] public void Обычный_ре_арм_сбрасывает_стейт_машину_как_раньше() { diff --git a/tests/XiControl.Tests/Fakes.cs b/tests/XiControl.Tests/Fakes.cs index 0c449d2..36ae9de 100644 --- a/tests/XiControl.Tests/Fakes.cs +++ b/tests/XiControl.Tests/Fakes.cs @@ -33,7 +33,8 @@ public bool SetPerfMode(PerfMode mode) return !Rejects.Contains(mode) && SetPerfModeResult; } - public int? GetChargeLimit() => ChargeLimit; + public int ChargeLimitReads; // сколько раз спросили порог (XIC-101: перед сном — ни разу) + public int? GetChargeLimit() { ChargeLimitReads++; return ChargeLimit; } public readonly List ChargeLimitResets = []; // с каким resetFirst звали (XIC-64) From d8a21c529e358a11c260f3016db9b959d7267797 Mon Sep 17 00:00:00 2001 From: Oksion Date: Fri, 16 Oct 2026 23:37:35 +0500 Subject: [PATCH 5/7] =?UTF-8?q?fix(monitor):=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D0=B5=20=C2=AB=D0=9C=D0=BE=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=C2=BB=20=D0=B6=D0=B4=D1=91=D1=82=20=D1=87?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BD=D0=B0=20=D0=BF=D1=83?= =?UTF-8?q?=D0=BB=D0=B5=20(XIC-101=20=D0=BF.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LatestValue.Pending — чтение в полёте; MonitorForm.Dispose ждёт его (до 500 мс) перед освобождением источников температуры и ватт — больше нет ложного ObjectDisposedException в журнале при закрытии с открытым виджетом. Co-Authored-By: Claude Opus 5.5 --- src/SystemIntegration/LatestValue.cs | 6 ++++++ src/Ui/MonitorForm.cs | 4 ++++ tests/XiControl.Tests/LatestValueTests.cs | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/src/SystemIntegration/LatestValue.cs b/src/SystemIntegration/LatestValue.cs index 5956e7c..53c00d5 100644 --- a/src/SystemIntegration/LatestValue.cs +++ b/src/SystemIntegration/LatestValue.cs @@ -45,6 +45,12 @@ public T Fresh(long maxAgeMs, T stale) return _at != long.MinValue && _now() - _at <= maxAgeMs ? _latest : stale; } + /// Чтение в полёте (или завершённая задача, если его нет). Владелец источника ждёт + /// его перед тем, как освободить то, что читает read: иначе чтение на пуле падает на + /// уже освобождённом объекте и пишет в журнал ложный ObjectDisposedException (XIC-101). + /// Не бросает — ReadOnce исключения глотает. + public Task Pending { get { lock (_gate) return _pending ?? Task.CompletedTask; } } + /// Хоть одно чтение уже завершилось удачно. public bool HasValue { get { lock (_gate) return _at != long.MinValue; } } diff --git a/src/Ui/MonitorForm.cs b/src/Ui/MonitorForm.cs index c084dc1..38cb16c 100644 --- a/src/Ui/MonitorForm.cs +++ b/src/Ui/MonitorForm.cs @@ -715,6 +715,10 @@ protected override void Dispose(bool disposing) _tip.Dispose(); _compactMenu?.Dispose(); _battery?.Dispose(); + // Чтения на пуле (температура, ватты) могут ещё идти над источниками ниже: дать им + // закончить, не дольше 500 мс — зависший провайдер не держит закрытие (XIC-101). + // Тик уже остановлен — новых чтений не будет. + Task.WhenAll(_tempWmi.Pending, _batteryWmi.Pending, _adapterWmi.Pending).Wait(500); _tempSrc.Dispose(); _powerDraw.Dispose(); _gpuTel.Dispose(); diff --git a/tests/XiControl.Tests/LatestValueTests.cs b/tests/XiControl.Tests/LatestValueTests.cs index 328791d..154185b 100644 --- a/tests/XiControl.Tests/LatestValueTests.cs +++ b/tests/XiControl.Tests/LatestValueTests.cs @@ -102,4 +102,23 @@ public async Task Ошибка_чтения_не_портит_прежнее_з await v.Request(); // после ошибки слот свободен — чтение снова идёт v.Fresh(5000, -2).Should().Be(42); } + + // XIC-101: владелец ждёт чтение в полёте, прежде чем освободить источник + [Fact] + public async Task Pending_это_чтение_в_полёте_и_оно_завершается() + { + using var release = new ManualResetEventSlim(); + var v = Make(() => { release.Wait(); return 7; }); + + v.Pending.IsCompleted.Should().BeTrue("чтения нет — ждать нечего"); + var read = v.Request(); + v.Pending.Should().BeSameAs(read); + await Task.Delay(50); + v.Pending.IsCompleted.Should().BeFalse("чтение ещё идёт"); + + release.Set(); + await v.Pending.WaitAsync(TimeSpan.FromSeconds(5)); + v.Latest.Should().Be(7); + v.Pending.IsCompleted.Should().BeTrue(); + } } From f56b73c10444f72b195adbaf090d38c46f0b8e53 Mon Sep 17 00:00:00 2001 From: Oksion Date: Fri, 16 Oct 2026 23:48:00 +0500 Subject: [PATCH 6/7] =?UTF-8?q?fix(config):=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=84?= =?UTF-8?q?=D0=B8=D0=B3=D0=B0=20=D0=BF=D0=BE=D0=B4=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=20(XIC-101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Конфиг пишут и UI-поток, и пул (PowerModeSync, самолечение порога заряда, RestoreAfterBoot). Две одновременные записи давали IOException, одна правка терялась. Под замком каждая запись сериализует весь текущий конфиг. Co-Authored-By: Claude Opus 5.5 --- src/Config/JsonConfigStore.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Config/JsonConfigStore.cs b/src/Config/JsonConfigStore.cs index ae00ecb..e8fe128 100644 --- a/src/Config/JsonConfigStore.cs +++ b/src/Config/JsonConfigStore.cs @@ -43,14 +43,22 @@ public AppConfig Load() return cfg; } + // Сохраняют и UI-поток, и пул (PowerModeSync, самолечение порога заряда, RestoreAfterBoot): + // две одновременные записи одного файла давали IOException, и одна правка терялась. Под + // замком каждая запись сериализует весь текущий конфиг — последняя несёт обе правки. + private readonly object _saveLock = new(); + public void Save(AppConfig cfg) { - try + lock (_saveLock) { - Directory.CreateDirectory(_dir); - File.WriteAllText(FilePath, JsonSerializer.Serialize(cfg, JsonOpts)); + try + { + Directory.CreateDirectory(_dir); + File.WriteAllText(FilePath, JsonSerializer.Serialize(cfg, JsonOpts)); + } + catch (Exception ex) { Log.Ex("JsonConfigStore.Save", ex); /* не критично */ } } - catch (Exception ex) { Log.Ex("JsonConfigStore.Save", ex); /* не критично */ } } /// Конфиг для первого старта (файла нет / повреждён): язык — по языку ОС. From 0c7df29c8b6959ee42a0911f8e72bf499f598253 Mon Sep 17 00:00:00 2001 From: Oksion Date: Fri, 16 Oct 2026 23:48:11 +0500 Subject: [PATCH 7/7] docs(changelog): XIC-101 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dda868..acba923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,15 @@ аппаратным. Теперь после отказа XiControl спрашивает прошивку напрямую, есть ли у неё команда порога, и делает вывод только по ответу «команды нет». Ошибочно выученный признак снимается сам при следующем запуске (XIC-100). +- **Мелочи с код-ревью** (XIC-101): + - выключение «Режима питания Windows вместе с режимом» возвращает сбалансированный режим и + после перезапуска XiControl — раньше откат помнился только до выхода; + - на вкладке «Драйверы» «Нет доступа» к службе Xiaomi пишется, только если Windows и правда + отказала в доступе; прочие сбои — «Не прочиталось», без ложного совета про права; + - в журнале больше нет ложных ошибок: от оборванного по таймауту запроса к HTTP API и от + закрытия приложения с открытым «Монитором»; + - перед сном порог заряда пишется сразу, без лишнего чтения в последние миллисекунды; + - настройки, сохранённые одновременно из двух мест, больше не теряют одну из правок. ## [0.17.0] — 2026-10-11