写在前面
调试里最贵的一类 bug,不是抛异常的,而是返回成功、显示在跑、日志正常,但什么都没发生的。本文八个案例全部来自同一台 Windows 机器上真实运行的一整套自动化设施(计划任务守护 + 浏览器自动化 + Vue 表单操作),每个案例都消耗了真实的排查时间。共同教训只有一句话:"看起来成功"不是成功,每个"成功"都要配一个独立信号来验。
一、pid 复用:进程"活着",但那不是你的进程
现象:守护脚本写了自己的 pid 文件,监控逻辑Get-Process -Id $pid返回 True,判定"存活"。连续几个小时都显示正常。
真相:那个 pid 的原进程早就死了,pid 号被操作系统回收并分配给了浏览器的渲染进程。alive=True是真的,只是活的不是你。
误导性:单看 pid 存在性,100% 通过。
修法:存活判定至少两个独立信号交叉:
$p = Get-CimInstance Win32_Process -Filter "ProcessId = $pid" $alive = $p -and $p.CommandLine -like '*watchdog.py*' # 命令行核对 # 辅助信号:内存量级(python 常驻 ~15MB+,1.8MB 一眼假)、心跳文件新鲜度我们的案例里,watchdog 死亡 70 分钟,监控全程显示"alive"——直到人去数心跳序列才发现断档。
二、LogonTrigger:只在登录那一刻活一次,之后听不见任何呼救
现象:给守护进程注册了"登录时启动"的计划任务。进程半夜死了,等它按预期被再次拉起——永远不会。
根因:AtLogOn触发器只在登录事件那一刻触发一次。它不是"保活",是"开机自启"。进程死后没有任何东西再触发它。更隐蔽的是:对 LogonTrigger 任务执行schtasks /run会被静默忽略——返回"SUCCESS: Attempted to run...",进程一个不起。
修法:长驻守护用TimeTrigger + Repetition(间隔 5 分钟)+ MultipleInstancesPolicy=IgnoreNew:活着时每次触发被吸收,死了后最多一个周期就被下一次触发拉起来。实测杀进程 → 5 分钟内自愈。
<Triggers><TimeTrigger> <Repetition><Interval>PT5M</Interval><Duration>P3650D</Duration> <StopAtDurationEnd>false</StopAtDurationEnd></Repetition> <StartBoundary>2026-01-01T00:00:00</StartBoundary> </TimeTrigger></Triggers>三、任务显示"成功运行",进程其实秒退:一个引号的事
现象:计划任务 LastTaskResult=0,任务历史显示运行成功,但目标进程就是不在。查了一小时。
根因:任务 XML 的<Arguments>拼成这样:
"C:\path\to\script.py run" ← 错:参数被包进闭引号内 "C:\path\to\script.py" run ← 对:引号只包路径第一种写法下,解释器收到的是一个带空格的假文件名,立刻报"文件不存在"退出——而计划任务把这记为一次"已完成"的运行。
通用教训:凡是"注册/部署成功",验收标准必须是真触发一次 + 验证进程/产物出现,不是注册返回值。我们在安装脚本尾部加了固定冒烟段:注册完立刻/run一次,8 秒后验 pid 文件指向活进程,不过就红字报错。
四、默认 72 小时:长驻进程的死缓
现象:守护进程稳定运行,第三天同一时刻准时消失,无日志无报错。
根因:Task Scheduler 对任务有默认执行时限PT72H(3 天),到点即停。schtasks命令行不暴露这个参数,只能用/xml建任务时显式写:
<Settings> <ExecutionTimeLimit>PT0S</ExecutionTimeLimit> <!-- 0 = 无限制 --> <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy> <StartWhenAvailable>true</StartWhenAvailable> </Settings>StartWhenAvailable也值得单独说一句:不写它的话,到点时机器若处于关机/睡眠,这次触发就永久错过,不会补跑。
五、PowerShell:良性 stderr +$ErrorActionPreference='Stop'= 脚本猝死
现象:脚本第一行就死,错误是原生命令的一行 stderr——而那行输出是预期内的(比如清理一个不存在的任务:ERROR: The system cannot find the file specified.)。你自己写的容错分支根本没机会执行。
根因:EAP=Stop下,native.exe 2>&1管道里的 stderr 会被升级成终止性异常。
修法:把原生调用收进一个永不抛出的封装,成败一律看退出码:
function Invoke-NativeText { param([string]$Exe, [Parameter(ValueFromRemainingArguments)][string[]]$NativeArgs) $ErrorActionPreference = 'Continue' # 函数作用域,局部降级 $output = & $Exe @NativeArgs 2>&1 | Out-String [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output; Success = ($LASTEXITCODE -eq 0) } }同族坑再记两个:中文控制台把SUCCESS:本地化成成功:,匹配英文文本会假阴(又是退出码的天下);(& $args | ForEach-Object ...)里的&是调用运算符,会把参数值当命令执行。
六、Vue 表单:DOM 赋值"成功"了,下一帧就被冲回来
现象:用原生 setter +input事件给 Element-UI 输入框赋值,当帧读到el.value是新值,以为成了。半秒后页面重渲染,值弹回旧值——因为组件的响应式数据源根本没动。表单不"脏",保存按钮不出现,一切像没发生过。
判据:赋值后隔一帧再读一次。当帧成功、下帧回弹,就是数据流没通,不是选择器错。
正解:从输入框往上找第一个__vue__祖先(注意:__vue__挂在组件根元素div.el-input上,不在内部<input>上),沿$parent链走到ElFormItem,改它注入的表单模型:
let v = null; for (let n = inputEl; n && !v; n = n.parentElement) v = n.__vue__; let bi = v; while (bi && (bi.$options.name || bi.$options._componentTag) !== 'ElFormItem') bi = bi.$parent; bi.form.model.nickname = '新值'; // 响应式生效,界面自己变脏七、提交按钮不叫"保存",方法不是无参的
现象:全页搜"保存"无按钮,一度得出"这个表单没有提交入口"的荒谬结论。
真相:按钮文本是「提交」。而组件方法changeBaseInfo必须带参数("nickname")——无参调用不抛错、不变状态,静默无事发生。
破局方法:别猜,读渲染函数源码。组件实例的$options.render.toString()里能直接看到事件绑定:
on: { click: function(e){ return t.changeBaseInfo("nickname") } }一眼拿到正确的函数名和签名。比穷举点击、猜按钮文案可靠一个数量级。(组件方法本身toString()显示[native code]读不到,但 render 源码是完整的。)
八、多实例风暴:三份进程同时采样,账居然没算重
现象:某时段"每分钟采样"的计数从 60/小时 变成180–221/小时——三份守护进程同时在跑(计划任务、手工启动、自愈拉起各一份)。
为什么账没重:落账函数按(日期,小时)键做了幂等——同小时重复结算直接返回已有行。如果某个副作用不是幂等的(比如派单、通知、转账),这一晚就是三重执行。
修法:实例唯一性要在写 pid 文件之前用系统级锁解决(Windows 命名互斥体:进程死亡即由内核释放,不留死锁);pid 文件只能证明"有人写过",不能证明"只有一份"。
收尾:给"看起来成功"立五条验收规矩
- 进程存活= pid + 命令行核对 + 内存量级 + 心跳新鲜度,四信号取交集;
- 注册/部署成功= 立刻真触发一次,验产物出现(冒烟验证进脚本,不靠人);
- UI 赋值成功= 隔一帧回读,防框架冲回;
- 原生调用成败= 只看退出码,永远不匹配输出文本(本地化会咬人);
- 副作用幂等= 假设一切都会被并发执行三次,写的时候按这个标准要求自己。
八案例的共同点:系统没有骗你,是"成功"这个词太宽。把每个"成功"翻译成它下游的可观测信号,静默失败就无处遁形。
声明:本文内容由 AI 辅助生成,全部案例来自同一台 Windows 机器的实录(错误码原文、XML 片段、组件链取证),路径与账号信息均已做占位处理。技术结论请以你自己环境的实测为准。