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
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/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); /* не критично */ }
}
/// Конфиг для первого старта (файла нет / повреждён): язык — по языку ОС.
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/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/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/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/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/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/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/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/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 Dispose_UnsubscribesFromPowerEvents()
_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)
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 sealed class HttpApiBodyTests
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));
+ }
+
// ---- Потоки-фейки ----
/// Бесконечный поток нулей; считает, сколько отдал.
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 sealed class LatestValueTests
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();
+ }
}
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 sealed class OemServiceTests
[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 c7a214e..d3da883 100644
--- a/tests/XiControl.Tests/PowerModeSyncTests.cs
+++ b/tests/XiControl.Tests/PowerModeSyncTests.cs
@@ -76,6 +76,7 @@ public PowerModeSyncTests()
[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);
@@ -164,6 +165,32 @@ public PowerModeSyncTests()
_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 Отказ_записи_не_запоминается_как_наше()
{