feat(watchdog): M52 资源压力可观测 + multi_font 日志去刷屏 + audiod bind 失败即退

- watchdog(新 wd_resguard.h + meow_watchdog.c):每 10s 采 /proc/meminfo
  (MemFree/MemAvailable)、进程总数、单父进程子进程数前 5 名;过阈值时向
  MeowOS/logs 打 RESGUARD 告警,带等级/命中规则/进程名+PID 名单。零 fork、零堆分配、
  不发任何信号(只报警)。阈值依据 = 真机实测 56872kB / 空闲 100 进程 / maxch=11,
  推导写在 wd_resguard.h 文件头;日志只在等级跃迁或同态每 5 分钟复述。
- launcherd:multi_font 那行「每 2s 无条件打一条」改为只在配置签名跃迁时打
  (新 mf_logsig.h 纯函数 + test_mf_logsig 宿主单测,接进 build_launcher.sh test)。
  真机 6603 行日志里该行占 5297 行(80%)。
- audiod:io bind 失败不再只记一行就 return(旧行为让主线程 watchdog 空转 720s、
  客户端全 ECONNREFUSED),改为明确报错 + g_stop 干净退出;仅动 io_thread 一个 hunk。
This commit is contained in:
AskaEth committed 2026-11-12 08:02:22 +08:00
1 parent f3d7b386b2
commit 166b32d0e5
8 files changed
+755 -4

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
/* wd_resguard.h — meow_watchdog 的设备资源压力采样与判定(纯函数,宿主可单测)。
*
* 背景(2026-11-11 18:44 用户态崩塌):设备 55 MB 内存、单核、无 swap。launcherd 最后一行
* 日志 18:44:13,之后 11 分钟连 sshd 都写不出 "Child connection from"(fork 都做不到),
* 而 ICMP 仍应答;同时 M50 清掉了 55 个挂在「耳机不在」上的 bluetoothctl 残留进程。
* 无 pstore(/sys/fs/pstore 不存在)、reboot 后 dmesg 清空、crash_*.log 全 0 字节 ——
* OOM / 进程耗尽**无法事后证实**。所以只能事前留痕:周期采样本,过阈值时把带进程名与
* PID 的现场写进 MeowOS/logs(wd_log 写的同一文件)。
*
* 阈值依据(真机实测 2026-11-11 21:00,uptime 25min,100 个进程):
* MemTotal 56872 kB;MemFree 1424~1496 kB;MemAvailable 24808~24864 kB
* (Buffers 11656 + Cached 10040 → MemFree 常年只有 1.5 MB,单看 MemFree 会天天误报)
* 非内核进程里「单父进程子进程数」最大 = 11(ppid=1);ppid=2(kthreadd) 恒有 ~70 个
* 内核线程,必须排除,否则永远报最高档(见 wd_rg_parent_eligible)。
*
* avail < 10240 kB(10 MiB ≈ 空闲可用 24.3 MB 的 40%) → WARN
* 无 swap;一次 fork+exec(dropbear 子进程、bluetoothctl)的匿名页需求约 1–2 MiB。
* 剩 10 MB 时回收路径已经很紧,再叠加几十个 bluetoothctl 就进入实测的 fork 失败区。
* avail < 5120 kB(5 MiB ≈ MemTotal 的 9%) → CRIT(最后一次舒适余量)
* procs >= 140(实测空闲 100 的 +40%) → WARN
* procs >= 180(≈1.8× 空闲) → CRIT(事故当晚 ≈100+55=155)
* maxch >= 20(实测空闲最大值 11 的 ~2×) → WARN
* maxch >= 40(事故形态:55 个残留孤儿全挂 ppid=1) → CRIT
*
* 为什么 MemFree 不做阈值:内核本来就把空闲页压到 ~1.5 MB(实测),可回收性全在
* MemAvailable 里(Buffers+Cached ≈ 21 MB)。单用 MemFree 会在每次大文件读/写时误报。
* MemFree 只作为告警行里的现场数字保留。
*
* 采样周期 10 s:事故从「launcherd 最后一行」到「sshd 完全静默」是分钟级,10 s 一次,
* 一次事故至少留 6 条/分钟。开销 = 一次 /proc 扫描(约 100 个 stat 文件),真机 <10 ms,
* 摊到 10 s 即 <0.1% CPU。日志只在**等级跃迁**(含首次)或同一告警态每 5 分钟复述一次,
* 绝不「每采样一条」——那正是本单要消灭的刷屏形态。
*
* 硬性约束(事故教训,本模块 + 调用点共同保证):
* - 零 fork / 零 popen:内存紧张时 fork 正是最先失败的动作(sshd 就是这样死的),
* 本模块只 read(2) /proc 与 getdents64(2) 列目录。
* - 零堆分配:malloc 在 MemAvailable 见底时可能失败或直接触发 OOM killer;所有缓冲
* 都是调用方给的静态/栈数组。
* - **绝不 kill**:本模块只产出「等级 + 原因」,不发任何信号。App 治理是既有逻辑,
* 与本模块无关;dropbear / launcherd / audiod 尤其不可碰。
*/
#ifndef WD_RESGUARD_H
#define WD_RESGUARD_H
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define WD_RG_PERIOD_MS 10000u /* 采样周期 */
#define WD_RG_REALERT_MS 300000u /* 同一告警态复述间隔(5 分钟) */
#define WD_RG_TOP_PARENTS 5 /* 记录「子进程数最多的前 5 名」父进程 */
#define WD_RG_CHILD_LIST 8 /* 告警里列多少个具体子进程(名字+PID) */
#define WD_RG_MAX_PIDS 256 /* /proc 扫描上限(实测 100,留 2.5× 余量) */
#define WD_RG_COMM_LEN 16 /* /proc/<pid>/stat 的 comm(内核上限 15+1) */
/* 阈值(依据见文件头)。单位 kB 的用 unsigned,进程数用 int。 */
#define WD_RG_AVAIL_WARN_KB 10240u
#define WD_RG_AVAIL_CRIT_KB 5120u
#define WD_RG_PROCS_WARN 140
#define WD_RG_PROCS_CRIT 180
#define WD_RG_CHILD_WARN 20
#define WD_RG_CHILD_CRIT 40
#define WD_RG_KB_UNKNOWN 0u /* 0 = 该字段没读到(MemAvailable 缺失时用) */
enum { WD_RG_OK = 0, WD_RG_WARN = 1, WD_RG_CRIT = 2 };
typedef struct {
unsigned long long total_kb;
unsigned long long free_kb;
unsigned long long avail_kb; /* WD_RG_KB_UNKNOWN = /proc/meminfo 无 MemAvailable */
int procs; /* /proc/<pid> 个数(不含线程) */
int maxch; /* 单父进程子进程数最大值(已排除内核线程) */
int maxch_ppid; /* 该父进程 PID(0 = 没有合格父进程) */
} wd_rg_snap;
/* 解析 /proc/meminfo 文本。成功(MemTotal + MemFree 都在)返回 0;MemAvailable 缺失时
* *avail_kb = WD_RG_KB_UNKNOWN(内核 3.14+ 都有,缺失只作降级,不作致命错)。 */
static inline int wd_rg_parse_meminfo(const char *buf,
unsigned long long *total_kb,
unsigned long long *free_kb,
unsigned long long *avail_kb)
{
unsigned long long t = 0, f = 0, a = WD_RG_KB_UNKNOWN;
int seen_t = 0, seen_f = 0;
if (!buf || !total_kb || !free_kb || !avail_kb) return -1;
const char *p = buf;
while (*p) {
if (strncmp(p, "MemTotal:", 9) == 0) { t = strtoull(p + 9, NULL, 10); seen_t = 1; }
else if (strncmp(p, "MemFree:", 8) == 0) { f = strtoull(p + 8, NULL, 10); seen_f = 1; }
else if (strncmp(p, "MemAvailable:", 13) == 0) { a = strtoull(p + 13, NULL, 10); }
/* 下一行(/proc/meminfo 每行定长友好,但按行推进更稳) */
const char *nl = strchr(p, '\n');
if (!nl) break;
p = nl + 1;
}
if (!seen_t || !seen_f) return -1;
*total_kb = t;
*free_kb = f;
*avail_kb = a;
return 0;
}
/* 解析一行 /proc/<pid>/stat:"pid (comm) state ppid ..."。
* comm 里可以有空格与 ')'(例如 "kworker/0:1H"、自命名带括号的进程),所以取**最后一个** ')'
* 再往后解析 state/ppid。成功返回 0 并填 out_pid / comm / out_ppid。 */
static inline int wd_rg_parse_stat(const char *buf, int *out_pid,
char *comm, size_t commlen, int *out_ppid)
{
if (!buf || !out_pid || !comm || commlen == 0 || !out_ppid) return -1;
const char *lp = strchr(buf, '(');
const char *rp = strrchr(buf, ')');
if (!lp || !rp || rp < lp) return -1;
long pid = strtol(buf, NULL, 10);
if (pid <= 0) return -1;
size_t n = (size_t)(rp - (lp + 1));
if (n >= commlen) n = commlen - 1;
memcpy(comm, lp + 1, n);
comm[n] = 0;
/* rp+1 = ' ' state ' ' ppid ... */
const char *q = rp + 1;
while (*q == ' ') q++;
if (!*q) return -1;
q++; /* state 单字符 */
while (*q == ' ') q++;
if (!*q) return -1;
long ppid = strtol(q, NULL, 10);
if (ppid < 0) return -1;
*out_pid = (int)pid;
*out_ppid = (int)ppid;
return 0;
}
/* 该 ppid 是否参与「子进程数最多」排名。
* 排除 0(无父)与 2(kthreadd:真机恒有 ~70 个内核线程,计入则每次采样都报最高档)。
* ppid=1 **要保留**——孤儿进程(父死掉后 reparent)正是本单要抓的形态。 */
static inline int wd_rg_parent_eligible(int ppid)
{
if (ppid <= 0) return 0;
if (ppid == 2) return 0; /* kthreadd */
return 1;
}
/* 统计 ppids[0..n) 里父进程 = parent 的个数。 */
static inline int wd_rg_count_children(const int *ppids, int n, int parent)
{
int c = 0;
if (!ppids || n <= 0) return 0;
for (int i = 0; i < n; i++) if (ppids[i] == parent) c++;
return c;
}
/* 取子进程数最多的前 k 个合格父进程(降序;同数按 ppid 升序,保证可复现)。
* out_ppid/out_cnt 各至少 k 项。返回实际填了几项(0..k)。
* O(n^2),n<=256 → 每次采样最多 6.5 万次比较,真机 <1 ms。 */
static inline int wd_rg_top_parents(const int *ppids, int n, int k,
int *out_ppid, int *out_cnt)
{
int found = 0;
if (!ppids || !out_ppid || !out_cnt || n <= 0 || k <= 0) return 0;
for (int i = 0; i < n; i++) {
int pid = ppids[i];
if (!wd_rg_parent_eligible(pid)) continue;
int cnt = wd_rg_count_children(ppids, n, pid);
/* 已记录过同一个 ppid? */
int seen = 0;
for (int j = 0; j < found; j++) if (out_ppid[j] == pid) { seen = 1; break; }
if (seen) continue;
/* 插入排序进前 k */
int pos = found;
while (pos > 0 && (out_cnt[pos - 1] < cnt ||
(out_cnt[pos - 1] == cnt && out_ppid[pos - 1] > pid))) {
if (pos < k) { out_cnt[pos] = out_cnt[pos - 1]; out_ppid[pos] = out_ppid[pos - 1]; }
pos--;
}
if (pos < k) { out_cnt[pos] = cnt; out_ppid[pos] = pid; }
if (found < k) found++;
}
return found;
}
/* 资源压力等级:取所有命中规则里最严重的一档。 */
static inline int wd_rg_level(const wd_rg_snap *s)
{
int lvl = WD_RG_OK;
if (!s) return WD_RG_OK;
if ((s->avail_kb != WD_RG_KB_UNKNOWN && s->avail_kb < WD_RG_AVAIL_WARN_KB) ||
s->procs >= WD_RG_PROCS_WARN || s->maxch >= WD_RG_CHILD_WARN)
lvl = WD_RG_WARN;
if ((s->avail_kb != WD_RG_KB_UNKNOWN && s->avail_kb < WD_RG_AVAIL_CRIT_KB) ||
s->procs >= WD_RG_PROCS_CRIT || s->maxch >= WD_RG_CHILD_CRIT)
lvl = WD_RG_CRIT;
return lvl;
}
/* 命中的规则名(逗号分隔),用于告警行 —— 让读日志的人不用回代码查阈值。
* 当前等级的规则全列;WARN 档不列 CRIT 阈值(避免「写 5MB 却没到 5MB」的误读)。 */
static inline void wd_rg_reason(const wd_rg_snap *s, char *out, size_t olen)
{
size_t off = 0;
if (!out || olen == 0) return;
out[0] = 0;
if (!s) return;
int lvl = wd_rg_level(s);
if (lvl == WD_RG_OK) return;
#define WD_RG_ADD(txt) do { \
if (off + 1 < olen) \
off += (size_t)snprintf(out + off, olen - off, "%s%s", off ? "," : "", txt); \
} while (0)
if (s->avail_kb != WD_RG_KB_UNKNOWN && s->avail_kb < WD_RG_AVAIL_WARN_KB) {
if (lvl == WD_RG_CRIT && s->avail_kb < WD_RG_AVAIL_CRIT_KB) WD_RG_ADD("avail<5120kB");
else WD_RG_ADD("avail<10240kB");
}
if (s->procs >= WD_RG_PROCS_WARN) {
if (lvl == WD_RG_CRIT && s->procs >= WD_RG_PROCS_CRIT) WD_RG_ADD("procs>=180");
else WD_RG_ADD("procs>=140");
}
if (s->maxch >= WD_RG_CHILD_WARN) {
if (lvl == WD_RG_CRIT && s->maxch >= WD_RG_CHILD_CRIT) WD_RG_ADD("children>=40");
else WD_RG_ADD("children>=20");
}
#undef WD_RG_ADD
}
/* 这一轮该不该写日志:首次 / 等级跃迁 / 同一告警态超过 REALERT → 1,否则 0。
* first=1 只在本进程第一次采样时为真(调用方用静态标志)。 */
static inline int wd_rg_should_emit(int prev_level, int cur_level,
unsigned long long since_last_ms, int first)
{
if (first) return 1;
if (cur_level != prev_level) return 1;
if (cur_level != WD_RG_OK && since_last_ms >= WD_RG_REALERT_MS) return 1;
return 0;
}
static inline const char *wd_rg_level_name(int lvl)
{
return (lvl == WD_RG_CRIT) ? "CRIT" : (lvl == WD_RG_WARN) ? "WARN" : "OK";
}
#endif /* WD_RESGUARD_H */