Parcourir la source

feat(watchdog): D3-05 control崩溃看门狗(独立进程探活+崩溃重拉) + TDD + 真机5项

补单点续命缺口:operate→control只一次性拉起,control崩了无人重拉(尤其operate关闭后)=培养中断风险。
独立watchdog常驻探活control崩则自动重拉,不依赖operate是否开着:
- 新增ivf_tl_Watchdog.Core(WatchdogArgs/RelaunchDecision/WatchdogPaths/CredentialStore纯逻辑)
  + ivf_tl_Watchdog(WinExe/requireAdmin:单实例+探活循环+崩溃重拉+退避+CLI) + .Tests(17单测TDD)
- ControlHost:Login成功DPAPI加密缓存凭据+清停机标记;SafeShutdown写control.stopped停机标记
- 凭据DPAPI(CurrentUser)缓存,明文不落盘;重拉读缓存凭据提权拉起
- 三档手动控制可干净卸载:--pause/--resume(维护让路) --stop(停这次) --install/--uninstall(自启)
- 受护栏/shutdown写停机标记→看门狗不重拉故意停机(不与人对着干)

TDD red→green 17单测(Args/ShouldRelaunch真值表/DPAPI往返);真机5项全过:
凭据缓存(246字节密文不泄明文)/崩溃重拉(杀control→新pid started:true)/故意停不重拉/暂停让路/卸载干净。
踩坑:看门狗部署必拷全目录(deps.json+runtimes的Windows DPAPI),否则PlatformNotSupported(已写部署指南红线)。
control sln+operate Release双编译0错;SerialHelper 40单测仍过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie il y a 2 jours
Parent
commit
d3091bc32d

+ 30 - 0
ivf_tl_operate_2.0/control/ivf_tl_Control.sln

@@ -31,6 +31,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ivf_tl_ControlHost", "ivf_t
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ivf_tl_SerialHelper.Tests", "ivf_tl_SerialHelper.Tests\ivf_tl_SerialHelper.Tests.csproj", "{635D3540-E4CC-429A-B6B1-DF3BBF125F1C}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ivf_tl_Watchdog.Core", "ivf_tl_Watchdog.Core\ivf_tl_Watchdog.Core.csproj", "{A275A940-6B8E-4ED8-8A8D-7085E8B53ACA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ivf_tl_Watchdog.Tests", "ivf_tl_Watchdog.Tests\ivf_tl_Watchdog.Tests.csproj", "{A0F26A21-D9E7-475A-A84F-2B8B1B65467C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ivf_tl_Watchdog", "ivf_tl_Watchdog\ivf_tl_Watchdog.csproj", "{BC68ECC3-5155-4605-AF7D-FF72D54850B3}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -143,6 +149,30 @@ Global
 		{635D3540-E4CC-429A-B6B1-DF3BBF125F1C}.Release|Any CPU.Build.0 = Release|Any CPU
 		{635D3540-E4CC-429A-B6B1-DF3BBF125F1C}.Release|x64.ActiveCfg = Release|Any CPU
 		{635D3540-E4CC-429A-B6B1-DF3BBF125F1C}.Release|x64.Build.0 = Release|Any CPU
+		{A275A940-6B8E-4ED8-8A8D-7085E8B53ACA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{A275A940-6B8E-4ED8-8A8D-7085E8B53ACA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{A275A940-6B8E-4ED8-8A8D-7085E8B53ACA}.Debug|x64.ActiveCfg = Debug|x64
+		{A275A940-6B8E-4ED8-8A8D-7085E8B53ACA}.Debug|x64.Build.0 = Debug|x64
+		{A275A940-6B8E-4ED8-8A8D-7085E8B53ACA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{A275A940-6B8E-4ED8-8A8D-7085E8B53ACA}.Release|Any CPU.Build.0 = Release|Any CPU
+		{A275A940-6B8E-4ED8-8A8D-7085E8B53ACA}.Release|x64.ActiveCfg = Release|x64
+		{A275A940-6B8E-4ED8-8A8D-7085E8B53ACA}.Release|x64.Build.0 = Release|x64
+		{A0F26A21-D9E7-475A-A84F-2B8B1B65467C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{A0F26A21-D9E7-475A-A84F-2B8B1B65467C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{A0F26A21-D9E7-475A-A84F-2B8B1B65467C}.Debug|x64.ActiveCfg = Debug|x64
+		{A0F26A21-D9E7-475A-A84F-2B8B1B65467C}.Debug|x64.Build.0 = Debug|x64
+		{A0F26A21-D9E7-475A-A84F-2B8B1B65467C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{A0F26A21-D9E7-475A-A84F-2B8B1B65467C}.Release|Any CPU.Build.0 = Release|Any CPU
+		{A0F26A21-D9E7-475A-A84F-2B8B1B65467C}.Release|x64.ActiveCfg = Release|x64
+		{A0F26A21-D9E7-475A-A84F-2B8B1B65467C}.Release|x64.Build.0 = Release|x64
+		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Debug|x64.ActiveCfg = Debug|x64
+		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Debug|x64.Build.0 = Debug|x64
+		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Release|Any CPU.Build.0 = Release|Any CPU
+		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Release|x64.ActiveCfg = Release|x64
+		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Release|x64.Build.0 = Release|x64
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE

+ 17 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Program.cs

@@ -2,6 +2,7 @@ using System;
 using System.Diagnostics;
 using System.Threading;
 using Aivfo.OperationLog;             // D1-10/D3-04:control 侧操作日志(审计埋点)
+using IvfTl.Watchdog.Core;            // D3-05:看门狗凭据缓存 / 停机标记
 using ivf_tl_Control;                 // AppData, StartMain
 using IvfTl.Control.Services;         // Log4netHelper(程序集 ivf_tl_Control_Services;切勿写 operate 端 ivf_tl_Services)
 
@@ -99,6 +100,20 @@ namespace IvfTl.ControlHost
                     Log4netHelper.WriteLog("ControlHost: control 登录失败");
                     return;
                 }
+                // D3-05:本次是"故意启动"(operate/看门狗/人显式拉起)→ 清故意停机标记,恢复看门狗守护;
+                // 并把首次登录凭据 DPAPI 加密缓存到本地,供看门狗崩溃重拉时使用(明文不落盘)。全 try 兜底。
+                try { WatchdogPaths.SetStopped(false); } catch { }
+                try
+                {
+                    CredentialStore.Save(new Credentials
+                    {
+                        Account = hostArgs.Account,
+                        Password = hostArgs.Password,
+                        CacheDisk = hostArgs.CacheDisk
+                    });
+                    Log4netHelper.WriteLog("[D3-05]control 登录凭据已加密缓存(供看门狗重拉)");
+                }
+                catch (Exception cex) { Log4netHelper.WriteLog("[D3-05]凭据缓存失败(已忽略):" + cex.Message); }
                 if (!string.IsNullOrEmpty(hostArgs.CacheDisk))
                 {
                     ivf_tl_UtilHelper.PathHelper.pan = hostArgs.CacheDisk;
@@ -199,6 +214,8 @@ namespace IvfTl.ControlHost
         /// <summary>统一安全停机:关相机/串口句柄(ShutdownAll)→ Set 退出事件(主线程 finally 停 HTTP+释放 Mutex)。</summary>
         private static void SafeShutdown()
         {
+            // D3-05:受护栏 /shutdown = 故意停机 → 写停机标记,看门狗见标记不重拉(直到下次故意启动清除)。
+            try { WatchdogPaths.SetStopped(true); Log4netHelper.WriteLog("[D3-05]已写故意停机标记,看门狗将不重拉"); } catch { }
             // D1-10:停机生命周期审计 + flush 操作日志(确保停机前在途审计落库),全 try 兜底。
             try { OperationLogger.Log("生命周期", "control 安全停机", result: "成功"); } catch { }
             try { OperationLogger.Shutdown(); } catch { }

+ 2 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj

@@ -15,6 +15,8 @@
     <ProjectReference Include="..\ivf_tl_ServicesImpl\ivf_tl_ServicesImpl.csproj" />
     <!-- D1-10/D3-04:control 侧操作日志(审计埋点)。Project=control,与 operate 共用同一 OperationLogger 组件/Kafka topic。 -->
     <ProjectReference Include="..\..\..\Aivfo.OperationLog\Aivfo.OperationLog.csproj" />
+    <!-- D3-05:看门狗共享核心(凭据缓存/停机标记)。control 写,watchdog 读。 -->
+    <ProjectReference Include="..\ivf_tl_Watchdog.Core\ivf_tl_Watchdog.Core.csproj" />
   </ItemGroup>
   <ItemGroup>
     <!-- D1-10:操作日志按模块开关配置(随 exe 部署到输出目录,≤15s 热加载;改输出目录那份即生效)。 -->

+ 63 - 0
ivf_tl_operate_2.0/control/ivf_tl_Watchdog.Core/CredentialStore.cs

@@ -0,0 +1,63 @@
+using System;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace IvfTl.Watchdog.Core
+{
+    /// <summary>看门狗重拉 control 所需的登录凭据。</summary>
+    public class Credentials
+    {
+        public string Account { get; set; } = "";
+        public string Password { get; set; } = "";
+        public string CacheDisk { get; set; } = "";
+    }
+
+    /// <summary>
+    /// 凭据本地加密缓存(DPAPI / DataProtectionScope.CurrentUser)。
+    /// control 首次登录成功后 Save;看门狗重拉时 Load。明文不落盘。
+    /// </summary>
+    public static class CredentialStore
+    {
+        // 应用专属熵,提升保护强度(非密钥,DPAPI 密钥由 OS 按当前用户管理)。
+        private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("ivf_tl_watchdog_v1");
+
+        /// <summary>加密写入凭据(覆盖)。字段各自 Base64 后以 '|' 连接,避免任何分隔符冲突。</summary>
+        public static void Save(Credentials c)
+        {
+            if (c == null) return;
+            WatchdogPaths.EnsureDataDir();
+            string payload = string.Join("|",
+                Convert.ToBase64String(Encoding.UTF8.GetBytes(c.Account ?? "")),
+                Convert.ToBase64String(Encoding.UTF8.GetBytes(c.Password ?? "")),
+                Convert.ToBase64String(Encoding.UTF8.GetBytes(c.CacheDisk ?? "")));
+            byte[] enc = ProtectedData.Protect(
+                Encoding.UTF8.GetBytes(payload), Entropy, DataProtectionScope.CurrentUser);
+            File.WriteAllBytes(WatchdogPaths.CredsFile, enc);
+        }
+
+        /// <summary>读取并解密凭据;无缓存或解密失败返回 null。</summary>
+        public static Credentials Load()
+        {
+            try
+            {
+                if (!File.Exists(WatchdogPaths.CredsFile)) return null;
+                byte[] enc = File.ReadAllBytes(WatchdogPaths.CredsFile);
+                byte[] plain = ProtectedData.Unprotect(enc, Entropy, DataProtectionScope.CurrentUser);
+                string[] parts = Encoding.UTF8.GetString(plain).Split('|');
+                if (parts.Length < 3) return null;
+                return new Credentials
+                {
+                    Account = Encoding.UTF8.GetString(Convert.FromBase64String(parts[0])),
+                    Password = Encoding.UTF8.GetString(Convert.FromBase64String(parts[1])),
+                    CacheDisk = Encoding.UTF8.GetString(Convert.FromBase64String(parts[2])),
+                };
+            }
+            catch
+            {
+                // 解密失败(换用户/文件损坏)→ 视为无凭据,看门狗等 operate 重新拉起缓存。
+                return null;
+            }
+        }
+    }
+}

+ 17 - 0
ivf_tl_operate_2.0/control/ivf_tl_Watchdog.Core/RelaunchDecision.cs

@@ -0,0 +1,17 @@
+using System;
+
+namespace IvfTl.Watchdog.Core
+{
+    /// <summary>
+    /// 看门狗"是否重拉 control"纯决策。穷举条件:
+    /// 仅当 control 不在、未暂停、非故意停机、不在退避冷却中,才重拉。
+    /// 提取为纯函数以便单测覆盖真值表。
+    /// </summary>
+    public static class RelaunchDecision
+    {
+        public static bool ShouldRelaunch(bool controlAlive, bool paused, bool deliberatelyStopped, bool cooldownActive)
+        {
+            return !controlAlive && !paused && !deliberatelyStopped && !cooldownActive;
+        }
+    }
+}

+ 43 - 0
ivf_tl_operate_2.0/control/ivf_tl_Watchdog.Core/WatchdogArgs.cs

@@ -0,0 +1,43 @@
+using System;
+
+namespace IvfTl.Watchdog.Core
+{
+    /// <summary>看门狗运行命令(由命令行 flag 决定)。</summary>
+    public enum WatchdogCommand { Run, Install, Uninstall, Pause, Resume, Stop }
+
+    /// <summary>看门狗启动参数(纯 BCL,可单测)。仿 ControlHost HostArgs。</summary>
+    public class WatchdogArgs
+    {
+        public int Port { get; set; } = 38080;
+        public int IntervalSec { get; set; } = 10;
+        public string CacheDisk { get; set; } = "";
+        public WatchdogCommand Command { get; set; } = WatchdogCommand.Run;
+
+        public static WatchdogArgs Parse(string[] args)
+        {
+            var a = new WatchdogArgs();
+            if (args == null) return a;
+            foreach (var raw in args)
+            {
+                if (string.IsNullOrEmpty(raw)) continue;
+                var s = raw.StartsWith("--") ? raw.Substring(2) : raw;
+                int eq = s.IndexOf('=');
+                string key = eq > 0 ? s.Substring(0, eq) : s;
+                string val = eq > 0 ? s.Substring(eq + 1) : "";
+
+                switch (key.ToLowerInvariant())
+                {
+                    case "port": if (int.TryParse(val, out var p)) a.Port = p; break;
+                    case "interval": if (int.TryParse(val, out var i)) a.IntervalSec = i; break;
+                    case "cachedisk": a.CacheDisk = val; break;
+                    case "install": a.Command = WatchdogCommand.Install; break;
+                    case "uninstall": a.Command = WatchdogCommand.Uninstall; break;
+                    case "pause": a.Command = WatchdogCommand.Pause; break;
+                    case "resume": a.Command = WatchdogCommand.Resume; break;
+                    case "stop": a.Command = WatchdogCommand.Stop; break;
+                }
+            }
+            return a;
+        }
+    }
+}

+ 59 - 0
ivf_tl_operate_2.0/control/ivf_tl_Watchdog.Core/WatchdogPaths.cs

@@ -0,0 +1,59 @@
+using System;
+using System.IO;
+
+namespace IvfTl.Watchdog.Core
+{
+    /// <summary>
+    /// 看门狗 / control 约定的共享数据目录与标志文件路径。
+    /// 默认 %ProgramData%\aivfo\watchdog\;单测可覆写 DataDir 指向临时目录。
+    /// - creds.dat        : DPAPI 加密的首次登录凭据(control 写、watchdog 读)
+    /// - watchdog.pause   : 人工暂停标志(在 → 看门狗只探活不重拉)
+    /// - control.stopped  : 故意停机标记(control 受护栏 /shutdown 写,故意启动清)
+    /// </summary>
+    public static class WatchdogPaths
+    {
+        private static string _dataDir = DefaultDataDir();
+
+        /// <summary>共享数据目录(可覆写以便测试)。</summary>
+        public static string DataDir
+        {
+            get => _dataDir;
+            set => _dataDir = value;
+        }
+
+        private static string DefaultDataDir() =>
+            Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "aivfo", "watchdog");
+
+        public static string CredsFile => Path.Combine(DataDir, "creds.dat");
+        public static string PauseFile => Path.Combine(DataDir, "watchdog.pause");
+        public static string StoppedFile => Path.Combine(DataDir, "control.stopped");
+
+        public static void EnsureDataDir() => Directory.CreateDirectory(DataDir);
+
+        public static bool IsPaused() => File.Exists(PauseFile);
+        public static bool IsDeliberatelyStopped() => File.Exists(StoppedFile);
+
+        /// <summary>建/删暂停标志。</summary>
+        public static void SetPaused(bool on) => Toggle(PauseFile, on);
+
+        /// <summary>建/删故意停机标记。</summary>
+        public static void SetStopped(bool on) => Toggle(StoppedFile, on);
+
+        private static void Toggle(string file, bool on)
+        {
+            try
+            {
+                if (on)
+                {
+                    EnsureDataDir();
+                    File.WriteAllText(file, "1");
+                }
+                else if (File.Exists(file))
+                {
+                    File.Delete(file);
+                }
+            }
+            catch { /* 标志读写失败不致命:看门狗下一拍重判 */ }
+        }
+    }
+}

+ 20 - 0
ivf_tl_operate_2.0/control/ivf_tl_Watchdog.Core/ivf_tl_Watchdog.Core.csproj

@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <!--
+    看门狗共享纯逻辑核心(D3-05):WatchdogArgs/RelaunchDecision/WatchdogPaths/CredentialStore。
+    被 ivf_tl_Watchdog(exe)、ivf_tl_ControlHost(写凭据/停机标记)、ivf_tl_Watchdog.Tests 共用。
+    net6.0-windows:CredentialStore 用 DPAPI(ProtectedData,Windows-only)。
+  -->
+  <PropertyGroup>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <ImplicitUsings>disable</ImplicitUsings>
+    <Nullable>disable</Nullable>
+    <Platforms>AnyCPU;x64</Platforms>
+    <RootNamespace>IvfTl.Watchdog.Core</RootNamespace>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="6.0.0" />
+  </ItemGroup>
+
+</Project>

+ 125 - 0
ivf_tl_operate_2.0/control/ivf_tl_Watchdog.Tests/WatchdogCoreTests.cs

@@ -0,0 +1,125 @@
+using System;
+using System.IO;
+using Xunit;
+using IvfTl.Watchdog.Core;
+
+namespace IvfTl.Watchdog.Tests
+{
+    public class WatchdogArgsTests
+    {
+        [Fact]
+        public void 无参_默认Run_端口38080_间隔10()
+        {
+            var a = WatchdogArgs.Parse(new string[0]);
+            Assert.Equal(WatchdogCommand.Run, a.Command);
+            Assert.Equal(38080, a.Port);
+            Assert.Equal(10, a.IntervalSec);
+        }
+
+        [Fact]
+        public void 解析端口与间隔与缓存盘()
+        {
+            var a = WatchdogArgs.Parse(new[] { "--port=39000", "--interval=5", "--cacheDisk=D" });
+            Assert.Equal(39000, a.Port);
+            Assert.Equal(5, a.IntervalSec);
+            Assert.Equal("D", a.CacheDisk);
+            Assert.Equal(WatchdogCommand.Run, a.Command);
+        }
+
+        [Theory]
+        [InlineData("--install", WatchdogCommand.Install)]
+        [InlineData("--uninstall", WatchdogCommand.Uninstall)]
+        [InlineData("--pause", WatchdogCommand.Pause)]
+        [InlineData("--resume", WatchdogCommand.Resume)]
+        [InlineData("--stop", WatchdogCommand.Stop)]
+        public void 各命令flag正确解析(string flag, WatchdogCommand expected)
+        {
+            var a = WatchdogArgs.Parse(new[] { flag });
+            Assert.Equal(expected, a.Command);
+        }
+
+        [Fact]
+        public void 命令flag与端口可并存()
+        {
+            var a = WatchdogArgs.Parse(new[] { "--uninstall", "--port=40000" });
+            Assert.Equal(WatchdogCommand.Uninstall, a.Command);
+            Assert.Equal(40000, a.Port);
+        }
+    }
+
+    public class RelaunchDecisionTests
+    {
+        [Fact]
+        public void 仅当不在且未暂停且非故意停且非退避_才重拉()
+        {
+            Assert.True(RelaunchDecision.ShouldRelaunch(controlAlive: false, paused: false, deliberatelyStopped: false, cooldownActive: false));
+        }
+
+        [Theory]
+        // control 还活着 → 永不重拉
+        [InlineData(true, false, false, false)]
+        // 暂停中 → 不重拉
+        [InlineData(false, true, false, false)]
+        // 故意停机 → 不重拉
+        [InlineData(false, false, true, false)]
+        // 退避冷却中 → 不重拉
+        [InlineData(false, false, false, true)]
+        // 多条同时 → 不重拉
+        [InlineData(false, true, true, true)]
+        public void 任一阻断条件成立_都不重拉(bool alive, bool paused, bool stopped, bool cooldown)
+        {
+            Assert.False(RelaunchDecision.ShouldRelaunch(alive, paused, stopped, cooldown));
+        }
+    }
+
+    public class CredentialStoreTests : IDisposable
+    {
+        private readonly string _tmp;
+
+        public CredentialStoreTests()
+        {
+            _tmp = Path.Combine(Path.GetTempPath(), "wd_test_" + Guid.NewGuid().ToString("N"));
+            WatchdogPaths.DataDir = _tmp;
+        }
+
+        public void Dispose()
+        {
+            try { if (Directory.Exists(_tmp)) Directory.Delete(_tmp, true); } catch { }
+        }
+
+        [Fact]
+        public void 加密写入再读取_等于原值()
+        {
+            var c = new Credentials { Account = "admin", Password = "123456", CacheDisk = "C" };
+            CredentialStore.Save(c);
+
+            // 落盘的不是明文
+            byte[] raw = File.ReadAllBytes(WatchdogPaths.CredsFile);
+            string asText = System.Text.Encoding.UTF8.GetString(raw);
+            Assert.DoesNotContain("123456", asText);
+
+            var got = CredentialStore.Load();
+            Assert.NotNull(got);
+            Assert.Equal("admin", got.Account);
+            Assert.Equal("123456", got.Password);
+            Assert.Equal("C", got.CacheDisk);
+        }
+
+        [Fact]
+        public void 无缓存文件_Load返回null()
+        {
+            Assert.Null(CredentialStore.Load());
+        }
+
+        [Fact]
+        public void 含特殊字符的密码也能往返()
+        {
+            var c = new Credentials { Account = "u|ser", Password = "p@ss=w|0rd!", CacheDisk = "E" };
+            CredentialStore.Save(c);
+            var got = CredentialStore.Load();
+            Assert.Equal("u|ser", got.Account);
+            Assert.Equal("p@ss=w|0rd!", got.Password);
+            Assert.Equal("E", got.CacheDisk);
+        }
+    }
+}

+ 21 - 0
ivf_tl_operate_2.0/control/ivf_tl_Watchdog.Tests/ivf_tl_Watchdog.Tests.csproj

@@ -0,0 +1,21 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <ImplicitUsings>disable</ImplicitUsings>
+    <Nullable>disable</Nullable>
+    <IsPackable>false</IsPackable>
+    <Platforms>AnyCPU;x64</Platforms>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
+    <PackageReference Include="xunit" Version="2.4.2" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\ivf_tl_Watchdog.Core\ivf_tl_Watchdog.Core.csproj" />
+  </ItemGroup>
+
+</Project>

+ 209 - 0
ivf_tl_operate_2.0/control/ivf_tl_Watchdog/Program.cs

@@ -0,0 +1,209 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using IvfTl.Watchdog.Core;
+
+namespace IvfTl.Watchdog
+{
+    /// <summary>
+    /// control 崩溃看门狗(D3-05)。常驻探活 control /ping,崩溃自动重拉(读 DPAPI 缓存凭据)。
+    /// 不依赖 operate 是否在跑。可 --pause/--resume(维护让路)、--stop(停这次)、--install/--uninstall(开机自启)。
+    /// </summary>
+    public static class Program
+    {
+        private const string MutexName = @"Global\ivf_tl_watchdog_singleton";
+        private const string StopEventName = @"Global\ivf_tl_watchdog_stop";
+        private const string RunKey = @"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
+        private const string RunValueName = "ivf_tl_watchdog";
+
+        private const int RelaunchCooldownSec = 30;     // 两次重拉最小间隔(防崩溃风暴)
+        private const int MaxConsecutiveFailures = 5;    // 连续失败超此数 → 拉长冷却 + 告警
+        private const int LongCooldownSec = 300;
+
+        private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
+
+        [STAThread]
+        public static int Main(string[] args)
+        {
+            var a = WatchdogArgs.Parse(args);
+            try
+            {
+                switch (a.Command)
+                {
+                    case WatchdogCommand.Install: return DoInstall(a);
+                    case WatchdogCommand.Uninstall: return DoUninstall();
+                    case WatchdogCommand.Pause: WatchdogPaths.SetPaused(true); Log("已暂停看门狗(只探活不重拉);--resume 恢复"); return 0;
+                    case WatchdogCommand.Resume: WatchdogPaths.SetPaused(false); Log("已恢复看门狗守护"); return 0;
+                    case WatchdogCommand.Stop: SignalStop(); Log("已向常驻看门狗发送停止信号"); return 0;
+                    default: return RunLoop(a);
+                }
+            }
+            catch (Exception ex)
+            {
+                Log("看门狗致命异常:" + ex);
+                return 1;
+            }
+        }
+
+        /// <summary>常驻探活循环。</summary>
+        private static int RunLoop(WatchdogArgs a)
+        {
+            bool isNew;
+            using (var mutex = new Mutex(true, MutexName, out isNew))
+            {
+                if (!isNew) { Log("已有看门狗实例在运行,本进程退出"); return 0; }
+
+                using (var stopEvent = new EventWaitHandle(false, EventResetMode.ManualReset, StopEventName))
+                {
+                    Log($"看门狗启动 port={a.Port} interval={a.IntervalSec}s controlExe={ResolveControlExe()}");
+                    int intervalMs = Math.Max(1, a.IntervalSec) * 1000;
+                    DateTime lastRelaunchUtc = DateTime.MinValue;
+                    int consecutiveFailures = 0;
+                    bool? lastAlive = null;   // 状态变化才记日志,避免刷屏
+
+                    while (true)
+                    {
+                        // 先判停止信号(--stop / --uninstall 触发):立刻优雅退出。
+                        if (stopEvent.WaitOne(0)) { Log("收到停止信号,看门狗退出"); return 0; }
+
+                        bool alive = IsControlAlive(a.Port);
+                        if (alive != lastAlive) { Log(alive ? "control 存活(探活正常)" : "control 探活失败(不在)"); lastAlive = alive; }
+                        if (alive)
+                        {
+                            consecutiveFailures = 0;
+                        }
+                        else
+                        {
+                            bool paused = WatchdogPaths.IsPaused();
+                            bool stopped = WatchdogPaths.IsDeliberatelyStopped();
+                            int cooldown = consecutiveFailures >= MaxConsecutiveFailures ? LongCooldownSec : RelaunchCooldownSec;
+                            bool cooldownActive = (DateTime.UtcNow - lastRelaunchUtc).TotalSeconds < cooldown;
+
+                            if (RelaunchDecision.ShouldRelaunch(alive, paused, stopped, cooldownActive))
+                            {
+                                var creds = CredentialStore.Load();
+                                if (creds == null)
+                                {
+                                    Log($"control 不在,但无可用缓存凭据(creds.dat 存在={File.Exists(WatchdogPaths.CredsFile)});等 operate 首次拉起 control 后再守护");
+                                }
+                                else
+                                {
+                                    lastRelaunchUtc = DateTime.UtcNow;
+                                    consecutiveFailures++;
+                                    bool ok = Relaunch(creds, a.Port);
+                                    if (ok && consecutiveFailures >= MaxConsecutiveFailures)
+                                        Log($"⚠ control 已连续 {consecutiveFailures} 次重拉仍未稳定,冷却拉长至 {LongCooldownSec}s,请人工检查");
+                                }
+                            }
+                            else if (paused) Log("control 不在,但看门狗处于暂停(--resume 恢复),不重拉");
+                            else if (stopped) Log("control 不在,但为故意停机(受护栏 /shutdown),不重拉");
+                            // cooldown 中静默等待,不刷屏
+                        }
+
+                        // 等一个周期;期间若收到停止信号则立刻醒来退出。
+                        if (stopEvent.WaitOne(intervalMs)) { Log("收到停止信号,看门狗退出"); return 0; }
+                    }
+                }
+            }
+        }
+
+        /// <summary>探活:control /ping 是否可达。</summary>
+        private static bool IsControlAlive(int port)
+        {
+            try
+            {
+                var resp = _http.GetAsync($"http://127.0.0.1:{port}/ping").GetAwaiter().GetResult();
+                return resp.IsSuccessStatusCode;
+            }
+            catch { return false; }
+        }
+
+        /// <summary>读缓存凭据,提权拉起 control(与 operate ControlProcessLauncher 同形态)。</summary>
+        private static bool Relaunch(Credentials c, int port)
+        {
+            string exe = ResolveControlExe();
+            if (!File.Exists(exe)) { Log("找不到 control.exe:" + exe); return false; }
+            try
+            {
+                var psi = new ProcessStartInfo
+                {
+                    FileName = exe,
+                    Arguments = $"--account={c.Account} --password={c.Password} --cacheDisk={c.CacheDisk} --port={port}",
+                    UseShellExecute = true,            // requireAdministrator 子进程需 ShellExecute(看门狗已是管理员,不弹 UAC)
+                    WindowStyle = ProcessWindowStyle.Hidden
+                };
+                Process.Start(psi);
+                Log($"control 不在 → 已重拉 control.exe(port={port})");
+                return true;
+            }
+            catch (Exception ex) { Log("重拉 control 失败:" + ex.Message); return false; }
+        }
+
+        /// <summary>control 可执行路径:默认与看门狗同目录(部署在同一 control\ 子目录)。</summary>
+        private static string ResolveControlExe() =>
+            Path.Combine(AppContext.BaseDirectory, "ivf_tl_ControlHost.exe");
+
+        // ── 控制面 ──────────────────────────────────────────────
+
+        private static int DoInstall(WatchdogArgs a)
+        {
+            string exe = Process.GetCurrentProcess().MainModule.FileName;
+            string cmd = $"\"{exe}\" --port={a.Port} --interval={a.IntervalSec}";
+            int rc = RunReg($"add \"{RunKey}\" /v {RunValueName} /t REG_SZ /d \"{cmd}\" /f");
+            Log(rc == 0 ? $"已写开机自启:{cmd}" : "写开机自启失败 rc=" + rc);
+            return rc;
+        }
+
+        private static int DoUninstall()
+        {
+            // 先停常驻实例,再删自启项 = 一条命令彻底卸载、无残留。
+            SignalStop();
+            int rc = RunReg($"delete \"{RunKey}\" /v {RunValueName} /f");
+            Log(rc == 0 ? "已删除开机自启项,看门狗已卸载" : "删除开机自启项(可能本就不存在) rc=" + rc);
+            return 0;
+        }
+
+        /// <summary>给常驻看门狗发停止信号(打开已存在的命名事件并置位);无常驻实例则静默。</summary>
+        private static void SignalStop()
+        {
+            try
+            {
+                using (var ev = EventWaitHandle.OpenExisting(StopEventName)) ev.Set();
+            }
+            catch (WaitHandleCannotBeOpenedException) { Log("无常驻看门狗在运行(无需停止)"); }
+            catch (Exception ex) { Log("发送停止信号异常:" + ex.Message); }
+        }
+
+        private static int RunReg(string arguments)
+        {
+            try
+            {
+                var psi = new ProcessStartInfo("reg.exe", arguments) { UseShellExecute = false, CreateNoWindow = true };
+                var p = Process.Start(psi);
+                p.WaitForExit();
+                return p.ExitCode;
+            }
+            catch (Exception ex) { Log("reg.exe 执行异常:" + ex.Message); return -1; }
+        }
+
+        // ── 日志 ────────────────────────────────────────────────
+
+        private static readonly object _logLock = new object();
+        private static void Log(string msg)
+        {
+            string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}  {msg}";
+            try
+            {
+                lock (_logLock)
+                {
+                    WatchdogPaths.EnsureDataDir();
+                    File.AppendAllText(Path.Combine(WatchdogPaths.DataDir, "watchdog.log"), line + Environment.NewLine);
+                }
+            }
+            catch { }
+            try { Console.WriteLine(line); } catch { }
+        }
+    }
+}

+ 10 - 0
ivf_tl_operate_2.0/control/ivf_tl_Watchdog/app.manifest

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
+  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
+    <security>
+      <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v2">
+        <requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
+      </requestedPrivileges>
+    </security>
+  </trustInfo>
+</assembly>

+ 24 - 0
ivf_tl_operate_2.0/control/ivf_tl_Watchdog/ivf_tl_Watchdog.csproj

@@ -0,0 +1,24 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <!--
+    control 崩溃看门狗(D3-05)。独立常驻进程:探活 control /ping,崩了自动重拉(读 DPAPI 缓存凭据)。
+    不依赖 operate 是否开着——覆盖"operate 关了 control 独立崩溃"最危险场景。
+    可人工 pause/resume(维护让路)、stop(停这次)、install/uninstall(开机自启)。
+    无窗口、requireAdministrator(与 control 同权限,提权拉起不弹 UAC)。
+  -->
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <Nullable>disable</Nullable>
+    <ImplicitUsings>disable</ImplicitUsings>
+    <Platforms>AnyCPU;x64</Platforms>
+    <ApplicationManifest>app.manifest</ApplicationManifest>
+    <AssemblyName>ivf_tl_Watchdog</AssemblyName>
+    <RootNamespace>IvfTl.Watchdog</RootNamespace>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\ivf_tl_Watchdog.Core\ivf_tl_Watchdog.Core.csproj" />
+  </ItemGroup>
+
+</Project>

+ 35 - 3
项目文档/开发环境/双进程部署指南.md

@@ -12,9 +12,12 @@
 |------|------|------|
 | **operate** 主程序(前端) | `ivf_tl_operate_2.0/ivf_tl_Operate/bin/Release/net6.0-windows/` 全目录 | 装机目录,如 `C:/TLData/app/operate/` |
 | **control** 后台采集进程 | `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/bin/Release/net6.0-windows/` 全目录 | operate 目录下的 **`control/` 子目录**(`.../operate/control/`) |
+| **watchdog** 崩溃看门狗(D3-05) | `ivf_tl_operate_2.0/control/ivf_tl_Watchdog/bin/Release/net6.0-windows/` **全目录** | 与 control **同目录**(`.../operate/control/`,看门狗按同目录找 `ivf_tl_ControlHost.exe`) |
 
 **关键**:control 必须放在 operate 输出目录的 `control\` 子目录(operate `App.config` 的 `controlExePath=control\ivf_tl_ControlHost.exe` 据此相对定位;`ControlProcessLauncher.ResolveExePath` 解析)。换布局则同步改 `controlExePath`。
 
+> ⚠ **看门狗部署必须拷"全目录",别只拷 exe+dll(真机踩坑实锤)**:watchdog 用 DPAPI(`System.Security.Cryptography.ProtectedData`)读凭据,真 Windows 实现在 **`runtimes/win/lib/net6.0/System.Security.Cryptography.ProtectedData.dll`**,由 **`ivf_tl_Watchdog.deps.json`** 在运行期定位。只拷根目录那个 `ProtectedData.dll`(=非 Windows 占位 stub)会让看门狗起 control 时抛 `PlatformNotSupportedException: DPAPI is not supported`、无法重拉。**正确做法:把 watchdog 的 `bin/Release/net6.0-windows/` 整个目录(含 `*.deps.json` + `runtimes/`)拷进 `control\`**;与 control 文件同目录共存(两 exe 各有自己的 `*.deps.json`,不冲突)。
+
 **构建命令**(真机/连内网必须 Release):
 ```bash
 cd ivf_tl_operate_2.0
@@ -70,6 +73,35 @@ Remove-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
 ## 五、已知遗留(部署相关)
 
 - **调试页借串口**:拆分后调试页驱动 control 持有的串口需"串口/相机命令代理"设计(见 `待验证清单 D2-02`),当前未接通;装机若需现场调试,暂走旧单机调试或待该子任务完成。
-- **ComBin 两套栈(G1-2)**:operate(`ivf_tl_Entity/ComEntitys/ComBin`)与 control(`ivf_tl_SerialHelper/ComBin`)两套串口栈未去重;control 操作不进 `operation_log`(oplog 埋点在 operate 那套栈,见 `待验证清单 D1-10`)。属阶段3 余留专项。
-- **D1-09**:control 本地 SQLite `InitTables` AUTOINCREMENT 建表失败(不阻塞运行)。
-- **M-01~M-07**:合并降级遗留(排气阀/灯光 EEPROM 等),见 `操作端逻辑与配置全景.md §八`。
+- **ComBin 两套栈(G1-2)**:operate(`ivf_tl_Entity/ComEntitys/ComBin`)与 control(`ivf_tl_SerialHelper/ComBin`)两套串口栈未去重(=删 operate 死栈,`待验证清单 D3-04` 延后,被 D2-02 阻塞)。注:control 硬件操作**已进** `operation_log`(`D1-10` 审计埋点已迁移到 control 活栈,已完成)。
+- **M-01~M-07**:合并降级遗留已全部闭合(见 `操作端逻辑与配置全景.md §八`);**D1-09** 本地 SQLite 建表缺口已修复。
+
+## 六、control 崩溃看门狗(D3-05)
+
+补"单点续命缺口":operate→control 只一次性拉起,control 自己崩了原本无人重拉(尤其 operate 关闭后)。看门狗 = **独立常驻进程**,持续探活 control 崩则自动重拉,**不依赖 operate 是否开着**。
+
+**形态与凭据**:
+- `ivf_tl_Watchdog.exe`(requireAdministrator、无窗口),与 control 同目录,按同目录找 `ivf_tl_ControlHost.exe`。
+- 重拉所需登录账号 = control **首次登录成功时 DPAPI 加密缓存**的凭据(`%ProgramData%\aivfo\watchdog\creds.dat`,明文不落盘)。**首次仍需 operate 拉起 control 一次**以生成缓存;之后看门狗即可自给重拉。
+- 数据目录 `%ProgramData%\aivfo\watchdog\`:`creds.dat`(加密凭据)/`watchdog.pause`(暂停标志)/`control.stopped`(故意停机标记)/`watchdog.log`(运行日志)。
+
+**手动控制(三档,维护必备——看门狗绝不与人对着干)**:
+```
+ivf_tl_Watchdog.exe --pause      # 维护前:看门狗只探活、不重拉(建 watchdog.pause)
+ivf_tl_Watchdog.exe --resume     # 维护后:恢复守护(删 watchdog.pause)
+ivf_tl_Watchdog.exe --stop       # 停掉当前常驻看门狗(发命名事件,优雅退出)
+ivf_tl_Watchdog.exe --install    # 写开机自启(HKCU\...\Run);可带 --port=/--interval=
+ivf_tl_Watchdog.exe --uninstall  # 先 --stop 再删自启项 = 一条命令彻底卸载、无残留
+```
+- 看门狗是普通进程(非内核驱动),任务管理器/提权 taskkill 也能直接结束。
+- **故意停机不被反复拉起**:受护栏 `/shutdown`(工程师口令)安全停机时 control 写 `control.stopped` 标记,看门狗见标记不重拉;下次"故意启动"(operate/人显式拉起 control,登录成功)自动清标记、恢复守护。
+- 防崩溃风暴:两次重拉最小间隔 30s;连续 5 次仍不稳定则冷却拉长至 300s + 告警日志。
+
+**开机自启(看门狗版,推荐)**:让**看门狗**开机自启(它保证 control 常驻);operate 仍由用户开 UI 时启动并各自探活。
+```powershell
+# 部署后执行一次(看门狗已在 control\ 目录):
+"C:\TLData\app\operate\control\ivf_tl_Watchdog.exe" --install --port=38080
+# 卸载:
+"C:\TLData\app\operate\control\ivf_tl_Watchdog.exe" --uninstall
+```
+> 与 §三 operate 自启可并存:operate 自启拉起 UI、看门狗自启保 control 常驻;首次开机需 operate 登录一次以缓存凭据,之后看门狗自给。

+ 19 - 0
项目文档/进度/交接卡.md

@@ -319,6 +319,25 @@
 
 ---
 
+## 2026-06-23 · D3-05 control 崩溃看门狗(= 昨日建议第2条 单点续命缺口)+ TDD + 真机 5 项验证
+
+- **背景**:用户从昨日 6 条建议里选「control 看门狗」。缺口:operate→control 只一次性拉起(`MainWindow_Loaded:66` 登录后 `Task.Run` 跑一次 `EnsureRunning`,之后不再探活),control 自己崩了无人重拉,尤其 operate 关闭后 control 独立崩溃 = 直接培养中断。经 brainstorming/规划,用户拍三决策:① 独立 watchdog 进程;② DPAPI 缓存首次凭据;③ 受护栏 /shutdown 写停机标记区分"崩溃 vs 故意停"。并明确要求"能人工干净卸载"。
+- **实现(新增 3 工程 + control 2 处小改,纯新增·不动采集/电机)**:
+  - `control/ivf_tl_Watchdog.Core`(net6.0-windows 类库):`WatchdogArgs`(CLI 解析)/`RelaunchDecision.ShouldRelaunch`(纯决策)/`WatchdogPaths`(数据目录+标志文件)/`CredentialStore`(DPAPI 加密往返,字段 Base64+'|' 连接避分隔冲突)。
+  - `control/ivf_tl_Watchdog`(WinExe/requireAdmin/无窗口):单实例 Mutex `Global\ivf_tl_watchdog_singleton` + 命名停止事件 `Global\ivf_tl_watchdog_stop` + 探活循环(每 N 秒 /ping)+ 崩溃重拉(读 DPAPI 凭据 `Process.Start` 提权拉起)+ 退避(30s 冷却、连 5 次失败拉长 300s+告警)+ CLI(--pause/--resume/--stop/--install/--uninstall,自启走 reg.exe HKCU\Run)。control exe 同目录解析。
+  - `control/ivf_tl_Watchdog.Tests`(xUnit,入 control sln)。
+  - ControlHost `Program.cs`:`RunStartupSequence` Login 成功 → `CredentialStore.Save`(DPAPI)+ `WatchdogPaths.SetStopped(false)`(故意启动清标记);`SafeShutdown` → `WatchdogPaths.SetStopped(true)`(受护栏停机写标记)。ControlHost 引 Watchdog.Core。
+- **TDD red→green(17 单测)**:Args 各 flag、ShouldRelaunch 真值表(仅"不在+未暂停+非故意停+非退避"才 true)、CredentialStore DPAPI 往返(写后读==原值、密文不含明文123456、特殊字符密码也往返)。先 RED(NotImplemented 17 失败)→补实现→GREEN(17 全过)。
+- **真机 5 项全验(UAC 静默提权,当前无 control 在跑、无活体培养,可自由启停)**:
+  ① **凭据缓存**:control 登录后 `C:\ProgramData\aivfo\watchdog\creds.dat`=246 字节 DPAPI 密文,提权+非提权探针均能解密读回 admin/123456/C,明文不泄露。
+  ② **崩溃重拉(核心)**:control 跑(pidA)→看门狗"control 存活"→提权 taskkill 杀 control→看门狗"control 探活失败→已重拉 control.exe"→新 pidB、/status started:true 续命。
+  ③ **故意停不重拉**:`/shutdown` 口令 tl13579→control 写 `control.stopped`→看门狗连续日志"为故意停机(受护栏 /shutdown),不重拉",control 保持停止。
+  ④ **暂停让路**:`--pause`(建 watchdog.pause)→杀 control→看门狗不重拉→`--resume`(删标志)→重拉恢复(新 pid)。
+  ⑤ **卸载干净**:`--install --port=38080`→自启项写入→`--uninstall`→自启项删空 + 常驻看门狗收停止信号优雅退出(进程数 0)。
+- **真机踩坑(关键,已写部署指南红线)**:首次手工部署只拷 `exe+dll`、**漏 `ivf_tl_Watchdog.deps.json` + `runtimes/win/lib/net6.0/System.Security.Cryptography.ProtectedData.dll`** → 看门狗运行期加载根目录的**非 Windows 占位 DPAPI stub** → `PlatformNotSupportedException: DPAPI is not supported` → `CredentialStore.Load()` 吞异常返 null → 日志"无缓存凭据"无法重拉。补全 deps.json + runtimes 后立即正常。**结论:看门狗部署必拷 bin 全目录**(含 deps.json + runtimes/);两 exe 各有自己的 deps.json 可同目录共存。靠加诊断日志(裸 Unprotect 记异常)定位,非猜。
+- **核实**:17 单测 red→green 真跑;5 项真机实测(watchdog.log/进程数/注册表项/creds.dat 字节逐一实查);control sln + operate Release 双编译 0 错;SerialHelper 40 单测仍过;调试代码(DiagCredsLoad)已删;测试残留(进程/`%ProgramData%\aivfo`)已清;临时 CredProbe harness 在 gitignore `临时文件/`。
+- **下一步**:D3-05 闭合。剩余方向(待用户定优先级):昨日建议 HIL 回归套件入库 / 配置收敛;工作计划 D2-02 调试页命令代理(大改面,解锁 D3-04 删死栈)/ 整机自启复测(需重启)。
+
 ## 2026-06-23 · 续:工作计划剩余项依赖/规模评估(D3-04 被 D2-02 阻塞 / D2-02 大改面定性 / D3-02 需重启)
 
 - **背景**:D1-10 提交后续做工作计划剩余三延后专项,先评估各自能否自主安全闭环。结论:**D1-10 是剩余里唯一可无监督安全闭环的;另三项各有真实阻塞,不宜本会话蛮干**。

+ 1 - 1
项目文档/进度/工作计划表.md

@@ -27,7 +27,7 @@
 |------|------|------|----------|
 | **阶段1** | control 独立进程骨架 | 🟢 代码完成·真机闭环打通(待并 main) | control 独立 exe 能起✓、HTTP探活/读状态✓、续命✓、单实例✓、硬件获取✓、**真机自控环运行✓**;阻塞闭环的 D1-08 串口握手死锁已修复 |
 | **阶段2** | 监控补全 + 调试借串口 + 受护栏停止 | 🟢 监控/受护栏停止/借串口让路 已实现+真机验;调试页完整驱动待设计 | 监控页跨进程 /status 显示完整✓;受护栏 /shutdown 安全停✓;/serial 让路✓(调试页完整借串口需命令代理设计+受监督真机) |
-| **阶段3** | 清理老壳 + 装机收尾 | 🟢 退役删ControlTest+部署文档+开机自启 已做;**D1-10 control oplog审计埋点已迁移+真机验证(2026-06-23)**;删operate死栈延后 | 退役删 ivf_tl_ControlTest✓(两编译0错);双进程部署指南✓;开机自启注册表方案✓;**D1-10 oplog审计埋点迁移到control活栈✓(与删死栈解耦先做,project=control真机入库)**;ComBin删operate死栈(D3-04)仍延后专项 |
+| **阶段3** | 清理老壳 + 装机收尾 | 🟢 退役删ControlTest+部署文档+开机自启 已做;**D1-10 control oplog审计埋点已迁移+真机验证**;**D3-05 control崩溃看门狗已实现+真机验证(2026-06-23)**;删operate死栈延后 | 退役删 ivf_tl_ControlTest✓;双进程部署指南✓;开机自启✓;**D1-10 oplog审计迁移到control活栈✓**;**D3-05 看门狗(崩溃重拉/DPAPI凭据/可暂停停止卸载)✓**;ComBin删operate死栈(D3-04,被D2-02阻塞)仍延后 |
 
 ---
 

+ 10 - 1
项目文档/进度/待验证清单.md

@@ -56,7 +56,8 @@
 | D3-01 | 退役删 ivf_tl_ControlTest 后 control sln / operate 仍编译正常 | 编译 | ☑ 已删+两编译0错 |
 | D3-02 | operate 开机自启(注册表 Run),开机自动起 operate→拉起 control | 运行 | ◑ 注册表方案已验/整机开机自启复测需重启 |
 | D3-03 | 全新部署一次到位(两 exe + control 子目录 + 端口 + DependFile) | 部署 | ☑ 部署布局已验(operate E2E 即用 control/ 子目录拉起) |
-| D3-04 | ComBin 两套栈去重(G1-2,含 D1-10 oplog) | 编译+运行 | ✗ 延后专项(有风险重构) |
+| D3-04 | ComBin 两套栈去重(G1-2,含 D1-10 oplog) | 编译+运行 | ✗ 延后专项(有风险删除,被 D2-02 阻塞) |
+| D3-05 | control 崩溃看门狗(独立进程探活+崩溃重拉,DPAPI 缓存凭据,可暂停/停止/卸载) | **真机** | ☑ 已实现并真机验证(2026-06-23,5 项全过) |
 
 > **2026-06-22 阶段3 实测说明(详见交接卡同日段)**:
 > - **D3-01 ☑**:`ivf_tl_ControlTest`(ControlMain 老壳)从 `control/ivf_tl_Control.sln` 移除并删目录;control sln 重编 0 错误、operate 编译 0 错误(operate 仅 `SettingPageView:392` 字符串引用旧进程名 `ivf_tl_ControlMain`=死代码,无类型依赖,随调试页借串口改造一并清)。
@@ -64,6 +65,14 @@
 > - **D3-03 ☑**:部署布局(operate 目录 + `control/` 子目录,control 自带 dll.config + DependFile)已在 operate WPF 真外壳 E2E 实证(operate 即从 `control/ivf_tl_ControlHost.exe` 拉起 control)。部署指南 `开发环境/双进程部署指南.md`。
 > - **D3-04 ✗ 延后**:operate(`ivf_tl_Entity/ComEntitys/ComBin`,带 OperationLogger 埋点)与 control(`ivf_tl_SerialHelper/ComBin`,真驱动硬件)两套串口栈。拆分后 operate 已不驱动串口→operate 那套栈成运行期死代码;control 操作不进 operation_log(oplog 埋点在 operate 栈,=D1-10)。去重=动串口代码的有风险重构,属收尾清理不阻塞功能,列延后专项(需设计 oplog 收口归属 + 受控验证)。
 
+> **2026-06-23 D3-05 ☑ control 崩溃看门狗已实现并真机验证(TDD + 5 项真机)**:
+> - **背景/缺口**:operate→control 只一次性拉起(`MainWindow_Loaded` 登录后跑一次 `EnsureRunning`,之后不再探活);control 自己崩溃后无人重拉,尤其 operate 关闭后 control 独立崩溃 = 直接培养中断。补独立看门狗常驻探活+崩溃重拉。
+> - **实现(新增 3 工程 + control 2 处小改,纯新增不动采集/电机)**:`ivf_tl_Watchdog.Core`(WatchdogArgs/RelaunchDecision/WatchdogPaths/CredentialStore 纯逻辑)+ `ivf_tl_Watchdog`(WinExe/requireAdmin:单实例 Mutex + 探活循环 + 崩溃重拉 + 退避 + CLI)+ `ivf_tl_Watchdog.Tests`;ControlHost `Program.cs`:Login 成功 DPAPI 缓存凭据 + 清 control.stopped 标记、SafeShutdown 写 control.stopped 标记。
+> - **TDD red→green(17 单测)**:WatchdogArgs.Parse(各 flag)、RelaunchDecision.ShouldRelaunch(真值表:仅"不在+未暂停+非故意停+非退避"才重拉)、CredentialStore DPAPI 往返(写后读==原值、密文不含明文)。先 RED(NotImplemented 17 失败)→ GREEN(17 全过)。
+> - **真机验证 5 项全过(UAC 静默提权,无活体培养可自由启停)**:① **凭据缓存**:control 登录后 `creds.dat`=246 字节 DPAPI 密文,admin/123456 明文均不泄露;② **崩溃重拉(核心)**:control 跑(pidA)→提权 taskkill 杀→看门狗日志"control 探活失败→已重拉 control.exe"→新 pidB、started:true 续命;③ **故意停不重拉**:`/shutdown` 口令 tl13579→写 control.stopped→看门狗连续"为故意停机,不重拉",control 保持停止;④ **暂停让路**:`--pause`→杀 control→不重拉→`--resume`→重拉恢复;⑤ **卸载干净**:`--install`写自启项→`--uninstall`删自启项+常驻看门狗优雅退出(进程数 0)。
+> - **真机踩坑(已定位+写入部署指南)**:首次手工部署只拷 exe+dll、漏 `ivf_tl_Watchdog.deps.json` + `runtimes/win/lib/.../ProtectedData.dll`→看门狗加载非 Windows 占位 DPAPI→`PlatformNotSupportedException`、Load 返回 null 无法重拉。补全 deps.json+runtimes 后即正常。**部署必拷 watchdog 全目录**(部署指南 §一 已写红线 + §六 看门狗专章)。
+> - **核实**:17 单测真跑 red→green;5 项真机实测(日志/进程/注册表/creds.dat 字节实查);control sln + operate Release 双编译 0 错;SerialHelper 40 单测仍过;临时 harness 在 gitignore `临时文件/`。
+
 ## 合并遗留 · operate 侧降级 / 待验证(与双进程拆分并行存在)
 
 > 来源:2026-06-22 复核评审逐条源码坐实;详见 `需求文档/操作端逻辑与配置全景.md` §八。

+ 6 - 5
项目文档/进度/进度数据.js

@@ -1,10 +1,10 @@
 // 实时面板数据源(监控面板.html 读 window.PROGRESS_DATA)。每推进一步更新本文件。
 window.PROGRESS_DATA = {
   project: "operate/control 双进程拆分",
-  generatedAt: "2026-06-23 11:55",
-  phase: "三阶段主体完成;合并遗留M区 M-01~M-07 全闭合;D1-10 control侧oplog审计埋点已迁移+真机red→green验证",
-  currentTask: "D1-10 control侧oplog审计埋点迁移(=昨日建议第3条,与D3-04删死栈解耦先做):4 control程序集引Aivfo.OperationLog;ControlHost InitOperationLog(Project=control)+生命周期审计+停机flush;相机Init/UnInit、HouseBin换气/补气、SerialChannelImpl 5个EEPROM写埋点(全try兜底·不碰串口字节/电机)。真机RED(control=0)→GREEN(control=70:生命周期3+相机68)。",
-  note: "D1-10已闭合:control子树原零OperationLogger埋点(埋点都在operate不驱动硬件的死栈)→设备物理动作审计缺失(合规级)。本轮纯新增埋点(不改串口字节/时序/电机),与删operate死栈(D3-04)解耦。真机red→green:108 oplog管道在线,RED基线operation_log project=control=0(operate=883证管道通)→提权启control(pid6488/started:true/MQTT连/舱7-9真温37℃)→GREEN project=control=70行(生命周期:进程启动/采集启动成功/安全停机flush;相机68结构化input/output)。/shutdown口令tl13579安全停机+7COM释放。换气/补气/写EEPROM埋点编译验证+同已证机制,放皿培养/调试接通时触发(HouseBin:670舱有培养记录才换气)。双编译0错+40单测过。剩延后专项:D3-04删operate死栈(有风险删除)/D2-02命令代理/整机自启复测需重启;之后回昨日建议(看门狗/HIL套件/配置收敛/验证清零)。",
+  generatedAt: "2026-06-23 13:10",
+  phase: "三阶段主体完成;M区全闭合;D1-10 control oplog审计埋点已迁移真机验证;D3-05 control崩溃看门狗已实现真机验证",
+  currentTask: "D3-05 control崩溃看门狗(=昨日建议第2条单点续命缺口):独立常驻进程探活control崩溃自动重拉,DPAPI加密缓存首次凭据,受护栏/shutdown写停机标记区分崩溃vs故意停,--pause/--resume/--stop/--install/--uninstall三档手动控制可干净卸载。TDD 17单测+真机5项全过。",
+  note: "D3-05看门狗补单点续命缺口:operate→control只一次性拉起,control崩了原本无人重拉(尤其operate关闭后)。新增ivf_tl_Watchdog.Core(纯逻辑)+ivf_tl_Watchdog(WinExe:探活/重拉/退避/CLI)+.Tests;ControlHost Login成功DPAPI缓存凭据+清停机标记、SafeShutdown写停机标记。TDD red→green 17单测(Args/ShouldRelaunch真值表/DPAPI往返)。真机5项全过:①凭据缓存(246字节DPAPI密文不泄明文)②崩溃重拉(杀control→看门狗自动重拉新pid started:true)③故意停不重拉(受护栏/shutdown写control.stopped标记)④暂停让路(--pause不重拉/--resume恢复)⑤卸载干净(--install自启项→--uninstall删项+优雅退出)。踩坑:部署必拷watchdog全目录(deps.json+runtimes的Windows DPAPI),否则PlatformNotSupported(已写部署指南红线)。双编译0错+SerialHelper40单测过。剩余方向:昨日建议HIL套件/配置收敛,或D2-02命令代理(解锁D3-04删死栈)。",
   milestones: [
     { name: "阶段1 · control 独立进程骨架(完成)", tasks: [
       { id: "Task1-7", name: "全过+D1-08死锁修复+operate真外壳E2E+数据入库DB铁证", status: "☑" }
@@ -19,7 +19,8 @@ window.PROGRESS_DATA = {
       { id: "D3-03", name: "双进程部署指南+布局E2E验", status: "☑" },
       { id: "D3-02", name: "开机自启注册表方案验(整机复测需重启)", status: "◑" },
       { id: "D1-10", name: "control oplog审计埋点迁移到活栈(project=control真机入库)", status: "☑" },
-      { id: "D3-04", name: "删operate死串口栈(去重·有风险删除)延后专项", status: "✗" }
+      { id: "D3-05", name: "control崩溃看门狗(崩溃重拉/DPAPI凭据/可暂停停止卸载,TDD+真机5项)", status: "☑" },
+      { id: "D3-04", name: "删operate死串口栈(去重·有风险删除·被D2-02阻塞)延后专项", status: "✗" }
     ]}
   ],
   pending: [

+ 10 - 8
项目文档/进度/进度状态.yaml

@@ -1,14 +1,16 @@
 # 续接断点状态(机器可解析)。换会话/换电脑后首先读它定位。
 # 状态取值: 未开始 / 进行中 / 完成 / 代码完成待验证
 # 纪律:本字段只存【当前断点】,历史细节进 交接卡.md(见 CLAUDE.md 第三节)。
-更新时间: 2026-06-23 D1-10 control 侧 oplog 审计埋点迁移完成并真机 red→green 验证(project=control 0→70 行:生命周期+相机 68;换气/补气/写EEPROM 埋点已编译+同机制,放皿/调试接通时触发)。当前无 control 在跑(已安全停机)、无活体培养。
+更新时间: 2026-06-23 D3-05 control 崩溃看门狗已实现并真机验证(独立进程探活+崩溃重拉,DPAPI 缓存凭据,TDD 17 单测 + 5 项真机全过)。= 昨日建议第2条(单点续命缺口)。当前无 control 在跑、无活体培养。
 当前任务: >
-  【D1-10 control 侧 oplog 审计埋点迁移 ☑ 完成】(= 昨日建议第3条 control 审计埋点,与 D3-04 删死栈解耦先做)
-  · 4 control 程序集引 Aivfo.OperationLog;ControlHost InitOperationLog(Project=control)+生命周期审计+停机flush;
-    相机Init/UnInit、HouseBin换气/补气、SerialChannelImpl 5个EEPROM写 埋点(全 try 兜底、不碰串口字节/电机)。
-  · 真机 RED(control=0)→GREEN(control=70:生命周期3+相机68);双编译0错;40单测过。
-  · 下一步:按工作计划续做剩余延后专项——D3-04 删 operate 死串口栈(有风险删除,谨慎评估)、D2-02 调试页借串口命令代理;
-    之后再回到昨日建议(看门狗/HIL回归套件/配置收敛/验证清零)。
+  【D3-05 control 崩溃看门狗 ☑ 完成】(= 昨日建议第2条:单点续命缺口)
+  · 新增 ivf_tl_Watchdog.Core(纯逻辑)+ ivf_tl_Watchdog(WinExe:探活循环/崩溃重拉/退避/CLI)+ .Tests;
+    ControlHost Login 成功 DPAPI 缓存凭据+清停机标记、SafeShutdown 写停机标记。
+  · 看门狗不依赖 operate;--pause/--resume/--stop/--install/--uninstall 三档手动控制(可干净卸载);
+    受护栏 /shutdown 写 control.stopped → 看门狗不与人对着干。
+  · TDD 17 单测 red→green;真机 5 项全过(凭据缓存/崩溃重拉/故意停不拉/暂停让路/卸载干净);双编译 0 错。
+  · 真机踩坑:看门狗部署必拷全目录(deps.json+runtimes 的 Windows DPAPI),否则 PlatformNotSupported(已写部署指南)。
+  · 下一步:回到昨日建议剩余项(HIL 回归套件/配置收敛)或工作计划延后专项(D2-02 命令代理→解锁 D3-04),按用户优先级。
 说明: >
   operate/control 双进程拆分三阶段主体早已完成;合并遗留 M 区 M-01~M-07 本轮全部闭合
   (M-01/02/03 builder去桩、M-04 存图代码定论、M-05 0x12帧长回归、M-06 按well焦点零点、M-07 网关)。
@@ -34,4 +36,4 @@
     名称: 清理老壳 + 装机收尾
     状态: 未开始
     备注: "退役删ivf_tl_ControlTest脏壳 + operate开机自启 + ComBin两套栈去重(G1-2) + 部署文档。待阶段2完成后拆计划"
-下一步: D1-10 control oplog 审计埋点已完成并真机验证。按工作计划续做剩余延后专项:D3-04 删 operate 死串口栈(有风险删除,谨慎编译+运行双验)、D2-02 调试页借串口命令代理(需设计命令代理面,涉电机在安全区间自驱);之后回到昨日建议(看门狗/HIL套件/配置收敛/验证清零)
+下一步: D3-05 看门狗已完成并真机验证。可选方向:昨日建议剩余项(HIL 回归套件入库 / 配置收敛)或工作计划延后专项(D2-02 调试页命令代理设计→解锁 D3-04 删死栈;整机开机自启复测需重启)。按用户优先级