docs(N34): 网络不稳根因复盘总览(比喻版)+ 全景图 SVG 生成器

- 新增 网络不稳根因总览.md:七个误会(传达室比喻+机制+证据出处)→
  环↔拆法↔落点对照表 → E13 僵尸会话 Mermaid 时序图 → 一句话总结
- 新增 tools/arch_diagram/gen_net_instability_svg.py(draw-diagram 规范,
  纯标准库)与产物 docs/images/网络不稳根因总览.svg(headless Edge 目检两轮)
- N34 README:实施状态表补全阶段 1.5/2/3 与 E6 插桩行(原「阶段 2/3
  未开始」陈旧口径修正);挂复盘总览为第一入口;§5.56 改紧邻写法
- 门禁:check_doc_links --sections-strict 本批 4 文件通过
This commit is contained in:
2026-10-13 17:12:36 +08:00
parent 123d80afc7
commit c7459b393f
4 changed files with 546 additions and 4 deletions
+155
View File
@@ -0,0 +1,155 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1560" height="1468" viewBox="0 0 1560 1468" font-family="Microsoft YaHei, Segoe UI, PingFang SC, sans-serif">
<rect x="0" y="0" width="1560" height="1468" fill="#ffffff"/>
<text x="26" y="40" font-size="20" font-weight="700" fill="#0f172a">为什么网络「一直不稳」——七个误会与逐个拆掉(N34 复盘)</text>
<text x="26" y="62" font-size="13" fill="#64748b">左列 = 根因(比喻 + 机制);右列 = N34 阶段 0~3 的对应拆法;环与环叠加才构成「玄学」</text>
<rect x="26" y="78.0" width="690" height="120.0" rx="10" fill="#fff1f2" stroke="#e11d48" stroke-opacity="0.45"/>
<text x="42" y="100.0" font-size="15" font-weight="700" fill="#e11d48">① 门铃会丢</text>
<text x="136" y="100.0" font-size="11.5" fill="#64748b">Trap 22:丢中断</text>
<rect x="42" y="110.0" width="422" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="253" y="131.6" font-size="13" fill="#1e293b" text-anchor="middle">症状:UI 复连永久拒连、FTP 第 29 轮自己死掉,全程不报一个错</text>
<rect x="42" y="152.0" width="481" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="282" y="173.6" font-size="13" fill="#1e293b" text-anchor="middle">机制:快速复连时 INT# 门铃哑火,netTask 只靠门铃干活 → 状态静默滞留</text>
<rect x="844" y="78.0" width="690" height="120.0" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-opacity="0.45"/>
<text x="860" y="100.0" font-size="15" font-weight="700" fill="#16a34a">100ms 巡逻对账</text>
<rect x="860" y="110.0" width="459" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1089" y="131.6" font-size="13" fill="#1e293b" text-anchor="middle">单一 reconciler:判据读驱动状态缓存(E2 背书 18.1µs),分级去抖</text>
<rect x="860" y="152.0" width="288" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1004" y="173.6" font-size="13" fill="#1e293b" text-anchor="middle">门铃丢了不要紧,每 100ms 亲自数一遍人头</text>
<line x1="722" y1="138.0" x2="834" y2="138.0" stroke="#94a3b8" stroke-width="2.5"/>
<path d="M 834 138.0 l -9 -6 v 12 z" fill="#94a3b8"/>
<text x="780" y="130.0" font-size="11" fill="#64748b" text-anchor="middle">拆掉</text>
<rect x="26" y="238.0" width="690" height="120.0" rx="10" fill="#fff1f2" stroke="#e11d48" stroke-opacity="0.45"/>
<text x="42" y="260.0" font-size="15" font-weight="700" fill="#e11d48">② 门铃总闸看一眼就清零</text>
<text x="226" y="260.0" font-size="11.5" fill="#64748b">Trap 25 B3</text>
<rect x="42" y="270.0" width="395" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="239" y="291.6" font-size="13" fill="#1e293b" text-anchor="middle">症状:别的 Socket 的 CONNECT/DISCONNECT 从此永久失踪</text>
<rect x="42" y="312.0" width="505" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="294" y="333.6" font-size="13" fill="#1e293b" text-anchor="middle">机制:全局中断寄存器整体读即清,open/relisten 丢弃式读一遍=拔全楼门铃线</text>
<rect x="844" y="238.0" width="690" height="120.0" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-opacity="0.45"/>
<text x="860" y="260.0" font-size="15" font-weight="700" fill="#16a34a">总闸唯一读者</text>
<rect x="860" y="270.0" width="526" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1123" y="291.6" font-size="13" fill="#1e293b" text-anchor="middle">F-NET-7:全局寄存器唯一读者=net_poll;open/relisten 只清本 Socket SINT</text>
<rect x="860" y="312.0" width="223" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="971" y="333.6" font-size="13" fill="#1e293b" text-anchor="middle">查自己门铃时,不许拔别人家的线</text>
<line x1="722" y1="298.0" x2="834" y2="298.0" stroke="#94a3b8" stroke-width="2.5"/>
<path d="M 834 298.0 l -9 -6 v 12 z" fill="#94a3b8"/>
<text x="780" y="290.0" font-size="11" fill="#64748b" text-anchor="middle">拆掉</text>
<rect x="26" y="398.0" width="690" height="120.0" rx="10" fill="#fff1f2" stroke="#e11d48" stroke-opacity="0.45"/>
<text x="42" y="420.0" font-size="15" font-weight="700" fill="#e11d48">③ 柜台和货梯是同一台</text>
<text x="211" y="420.0" font-size="11.5" fill="#64748b">单连接模型</text>
<rect x="42" y="430.0" width="260" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="172" y="451.6" font-size="13" fill="#1e293b" text-anchor="middle">症状:会话 EOF 之后端口「连接被拒」</text>
<rect x="42" y="472.0" width="512" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="298" y="493.6" font-size="13" fill="#1e293b" text-anchor="middle">机制:监听 fd == 数据 fd,应用在 EOF 时 net_close = 把柜台连电梯一起拆</text>
<rect x="844" y="398.0" width="690" height="120.0" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-opacity="0.45"/>
<text x="860" y="420.0" font-size="15" font-weight="700" fill="#16a34a">送客走正门</text>
<rect x="860" y="430.0" width="490" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1105" y="451.6" font-size="13" fill="#1e293b" text-anchor="middle">铁律:EOF 不得 close 监听 fd;新 API net_drop_conn(拆会话保柜台)</text>
<rect x="860" y="472.0" width="401" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1060" y="493.6" font-size="13" fill="#1e293b" text-anchor="middle">net_drop_conn = 优雅 FIN + 本地拆除 + relisten,幂等</text>
<line x1="722" y1="458.0" x2="834" y2="458.0" stroke="#94a3b8" stroke-width="2.5"/>
<path d="M 834 458.0 l -9 -6 v 12 z" fill="#94a3b8"/>
<text x="780" y="450.0" font-size="11" fill="#64748b" text-anchor="middle">拆掉</text>
<rect x="26" y="558.0" width="690" height="120.0" rx="10" fill="#fff1f2" stroke="#e11d48" stroke-opacity="0.45"/>
<text x="42" y="580.0" font-size="15" font-weight="700" fill="#e11d48">④ 三个临时工都在修电梯</text>
<text x="226" y="580.0" font-size="11.5" fill="#64748b">多所有者 relisten</text>
<rect x="42" y="590.0" width="429" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="256" y="611.5" font-size="13" fill="#1e293b" text-anchor="middle">症状:重建风暴、慢消费者的 EOF 被吞、新客户端数据被旧会话吸收</text>
<rect x="42" y="632.0" width="490" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="287" y="653.5" font-size="13" fill="#1e293b" text-anchor="middle">机制:accept/recv/pending 三处都重建监听,谁先看到断连谁动手互相踩脚</text>
<rect x="844" y="558.0" width="690" height="120.0" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-opacity="0.45"/>
<text x="860" y="580.0" font-size="15" font-weight="700" fill="#16a34a">修电梯的只剩一个人</text>
<rect x="860" y="590.0" width="528" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1124" y="611.5" font-size="13" fill="#1e293b" text-anchor="middle">重建收敛到唯一所有者(net_listener + reconciler),其余位置只置事实不动手</text>
<rect x="860" y="632.0" width="457" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1088" y="653.5" font-size="13" fill="#1e293b" text-anchor="middle">不变量 II:唯一周期重建决策者(消费者即时重建三处保留,P2a 实测)</text>
<line x1="722" y1="618.0" x2="834" y2="618.0" stroke="#94a3b8" stroke-width="2.5"/>
<path d="M 834 618.0 l -9 -6 v 12 z" fill="#94a3b8"/>
<text x="780" y="610.0" font-size="11" fill="#64748b" text-anchor="middle">拆掉</text>
<rect x="26" y="718.0" width="690" height="120.0" rx="10" fill="#fff1f2" stroke="#e11d48" stroke-opacity="0.45"/>
<text x="42" y="740.0" font-size="15" font-weight="700" fill="#e11d48">⑤ 传达员被按在椅子上</text>
<text x="211" y="740.0" font-size="11.5" fill="#64748b">队头阻塞</text>
<rect x="42" y="750.0" width="477" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="280" y="771.5" font-size="13" fill="#1e293b" text-anchor="middle">症状:FTP banner 迟到 25~30 s(E-02 实测),netTask 频繁冻结半秒</text>
<rect x="42" y="792.0" width="468" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="276" y="813.5" font-size="13" fill="#1e293b" text-anchor="middle">机制:30 s 阻塞收 + 500 ms 盲等忙等,而 netTask 是全楼唯一传达员</text>
<rect x="844" y="718.0" width="690" height="120.0" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-opacity="0.45"/>
<text x="860" y="740.0" font-size="15" font-weight="700" fill="#16a34a">传达员永不离开传达室</text>
<rect x="860" y="750.0" width="499" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1109" y="771.5" font-size="13" fill="#1e293b" text-anchor="middle">忙等统一为 net_sock_reset_sync 有界排渣(600 ms 硬上限),阻塞消息化</text>
<rect x="860" y="792.0" width="489" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1104" y="813.5" font-size="13" fill="#1e293b" text-anchor="middle">阶段 1.5:net_recv 默认非阻塞 + net_recv_nb + 60 s tripwire 日志</text>
<line x1="722" y1="778.0" x2="834" y2="778.0" stroke="#94a3b8" stroke-width="2.5"/>
<path d="M 834 778.0 l -9 -6 v 12 z" fill="#94a3b8"/>
<text x="780" y="770.0" font-size="11" fill="#64748b" text-anchor="middle">拆掉</text>
<rect x="26" y="878.0" width="690" height="120.0" rx="10" fill="#fff1f2" stroke="#e11d48" stroke-opacity="0.45"/>
<text x="42" y="900.0" font-size="15" font-weight="700" fill="#e11d48">⑥ 僵尸客人赖着不走</text>
<text x="196" y="900.0" font-size="11.5" fill="#64748b">E13:网卡闪断</text>
<rect x="42" y="910.0" width="381" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="232" y="931.5" font-size="13" fill="#1e293b" text-anchor="middle">症状:设备网络「莫名死了」、新连接被拒——在和幽灵做生意</text>
<rect x="42" y="952.0" width="564" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="324" y="973.5" font-size="13" fill="#1e293b" text-anchor="middle">机制:PC 网卡闪断,内核悄悄撕掉 socket 不发 FIN,芯片侧仍 ESTABLISHED 占死柜台</text>
<rect x="844" y="878.0" width="690" height="120.0" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-opacity="0.45"/>
<text x="860" y="900.0" font-size="15" font-weight="700" fill="#16a34a">查岗处理幽灵</text>
<rect x="860" y="910.0" width="557" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1138" y="931.5" font-size="13" fill="#1e293b" text-anchor="middle">keepalive 60/5/3(E11 实测 ≈30 s 判死)+ 芯片 TIMEOUT → QUARANTINE → 重建</text>
<rect x="860" y="952.0" width="404" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1062" y="973.5" font-size="13" fill="#1e293b" text-anchor="middle">自愈 30~90 s,不需要冷启动(E13 台架收口:用户判断正确)</text>
<line x1="722" y1="938.0" x2="834" y2="938.0" stroke="#94a3b8" stroke-width="2.5"/>
<path d="M 834 938.0 l -9 -6 v 12 z" fill="#94a3b8"/>
<text x="780" y="930.0" font-size="11" fill="#64748b" text-anchor="middle">拆掉</text>
<rect x="26" y="1038.0" width="690" height="120.0" rx="10" fill="#fff1f2" stroke="#e11d48" stroke-opacity="0.45"/>
<text x="42" y="1060.0" font-size="15" font-weight="700" fill="#e11d48">⑦ 只会礼貌送客不会轰人</text>
<text x="226" y="1060.0" font-size="11.5" fill="#64748b">close 无 RST(手册定案)</text>
<rect x="42" y="1070.0" width="440" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="262" y="1091.5" font-size="13" fill="#1e293b" text-anchor="middle">症状:FIN_WAIT_2 长尾,监听恢复受限于对端 close(E-02 定案)</text>
<rect x="42" y="1112.0" width="483" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="283" y="1133.5" font-size="13" fill="#1e293b" text-anchor="middle">机制:芯片 close 只发 FIN;对端内核存活但应用挂死 = 回 ACK 却不起身</text>
<rect x="844" y="1038.0" width="690" height="120.0" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-opacity="0.45"/>
<text x="860" y="1060.0" font-size="15" font-weight="700" fill="#16a34a">知道他不轰人,就别指望他轰人</text>
<rect x="860" y="1070.0" width="441" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1080" y="1091.5" font-size="13" fill="#1e293b" text-anchor="middle">定案写入 Trap 07:兜底靠查岗(keepalive/TIMEOUT),不靠 RST</text>
<rect x="860" y="1112.0" width="415" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1067" y="1133.5" font-size="13" fill="#1e293b" text-anchor="middle">net_drop_conn 本地拆除 + reconciler 形态③④ 认领僵尸态</text>
<line x1="722" y1="1098.0" x2="834" y2="1098.0" stroke="#94a3b8" stroke-width="2.5"/>
<path d="M 834 1098.0 l -9 -6 v 12 z" fill="#94a3b8"/>
<text x="780" y="1090.0" font-size="11" fill="#64748b" text-anchor="middle">拆掉</text>
<rect x="26" y="1198.0" width="690" height="120.0" rx="10" fill="#fff1f2" stroke="#e11d48" stroke-opacity="0.45"/>
<text x="42" y="1220.0" font-size="15" font-weight="700" fill="#e11d48">⑧ 垃圾不清越扫越脏</text>
<text x="196" y="1220.0" font-size="11.5" fill="#64748b">残渣风暴态</text>
<rect x="42" y="1230.0" width="448" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="266" y="1251.5" font-size="13" fill="#1e293b" text-anchor="middle">症状:同一份固件故障率 2%~54% 摆动(E8 实测),看起来像「玄学」</text>
<rect x="42" y="1272.0" width="515" height="34" rx="7" fill="#ffffff" stroke="#e11d48" stroke-opacity="0.55"/>
<text x="299" y="1293.5" font-size="13" fill="#1e293b" text-anchor="middle">机制:迟到 DISCONNECT 锁存 / 半死 Socket / 粘滞中断位,被带进下一条连接</text>
<rect x="844" y="1198.0" width="690" height="120.0" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-opacity="0.45"/>
<text x="860" y="1220.0" font-size="15" font-weight="700" fill="#16a34a">开张前把垃圾扫干净</text>
<rect x="860" y="1230.0" width="527" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1123" y="1251.5" font-size="13" fill="#1e293b" text-anchor="middle">sock_reset 标准排渣:读空 RX → CLOSE → 排空事件 → 确认落地(byte0=00H)</text>
<rect x="860" y="1272.0" width="397" height="34" rx="7" fill="#ffffff" stroke="#16a34a" stroke-opacity="0.55"/>
<text x="1058" y="1293.5" font-size="13" fill="#1e293b" text-anchor="middle">M3 矛盾态门 + M5 drain-until-quiescent(风暴态免疫)</text>
<line x1="722" y1="1258.0" x2="834" y2="1258.0" stroke="#94a3b8" stroke-width="2.5"/>
<path d="M 834 1258.0 l -9 -6 v 12 z" fill="#94a3b8"/>
<text x="780" y="1250.0" font-size="11" fill="#64748b" text-anchor="middle">拆掉</text>
<line x1="86" y1="203.0" x2="86" y2="231.0" stroke="#e11d48" stroke-width="1.6" stroke-dasharray="4 3" stroke-opacity="0.7"/>
<path d="M 86 231.0 l -4.5 -7 h 9 z" fill="#e11d48" fill-opacity="0.7"/>
<line x1="86" y1="363.0" x2="86" y2="391.0" stroke="#e11d48" stroke-width="1.6" stroke-dasharray="4 3" stroke-opacity="0.7"/>
<path d="M 86 391.0 l -4.5 -7 h 9 z" fill="#e11d48" fill-opacity="0.7"/>
<line x1="86" y1="523.0" x2="86" y2="551.0" stroke="#e11d48" stroke-width="1.6" stroke-dasharray="4 3" stroke-opacity="0.7"/>
<path d="M 86 551.0 l -4.5 -7 h 9 z" fill="#e11d48" fill-opacity="0.7"/>
<line x1="86" y1="683.0" x2="86" y2="711.0" stroke="#e11d48" stroke-width="1.6" stroke-dasharray="4 3" stroke-opacity="0.7"/>
<path d="M 86 711.0 l -4.5 -7 h 9 z" fill="#e11d48" fill-opacity="0.7"/>
<line x1="86" y1="843.0" x2="86" y2="871.0" stroke="#e11d48" stroke-width="1.6" stroke-dasharray="4 3" stroke-opacity="0.7"/>
<path d="M 86 871.0 l -4.5 -7 h 9 z" fill="#e11d48" fill-opacity="0.7"/>
<line x1="86" y1="1003.0" x2="86" y2="1031.0" stroke="#e11d48" stroke-width="1.6" stroke-dasharray="4 3" stroke-opacity="0.7"/>
<path d="M 86 1031.0 l -4.5 -7 h 9 z" fill="#e11d48" fill-opacity="0.7"/>
<line x1="86" y1="1163.0" x2="86" y2="1191.0" stroke="#e11d48" stroke-width="1.6" stroke-dasharray="4 3" stroke-opacity="0.7"/>
<path d="M 86 1191.0 l -4.5 -7 h 9 z" fill="#e11d48" fill-opacity="0.7"/>
<text x="12" y="618.0" font-size="11" fill="#e11d48" transform="rotate(-90 12 618.0)">叠加累积 → 越扫越脏</text>
<rect x="26" y="1344" width="690" height="78" rx="10" fill="#fff1f2" stroke="#e11d48" stroke-opacity="0.5"/>
<text x="42" y="1372" font-size="15" font-weight="700" fill="#e11d48">七环叠加 =「玄学不稳」</text>
<text x="42" y="1396" font-size="12" fill="#334155">随机 · 无错误日志 · 时好时坏——每个环节单看都「正常工作」</text>
<rect x="844" y="1344" width="690" height="78" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-opacity="0.5"/>
<text x="860" y="1372" font-size="15" font-weight="700" fill="#16a34a">逐环拆掉 = 稳(N34 阶段 0~3 已实施)</text>
<text x="860" y="1396" font-size="12" fill="#334155">不依赖门铃 · 唯一主人 · 永不冻结 · 100ms 对账 · 开张前排渣——台架回归全绿</text>
<text x="26" y="1452" font-size="12" fill="#94a3b8">证据链:docs/问题记录/CH395F_Trap_Records.md(Trap 07/09/14/17/22/23/25) · docs/网络/N34-自愈审计/实验方案.md(E1~E13) · 修复落地见 docs/网络/N34-自愈审计/实施方案.md</text>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

+12 -4
View File
@@ -10,17 +10,25 @@
## 实施状态(2026-10-13 更新)
> 想先要"为什么一直不稳"的人话版(比喻 + 全景图):先读
> `docs/网络/N34-自愈审计/网络不稳根因总览.md`。
| 提交 | 内容 | 结果 |
|---|---|---|
| `9ac9879` | P0 止血包(M1+M2+M4-lite) | 台架验证(E7b/E8) |
| `ae17d36` | M3 矛盾态可疑门(风暴态免疫,资产门版) | E9/E9b 全绿 |
| `4c0a0af` | **阶段 0** 零风险清理 9 条 | E10:0/20 + 20/20 + 20/20 + 11/11 |
| `b8c6a3d` | **阶段 1** API 面收口(对账⑤ + 删 3 API + time_sync/lftpd) | E10:全绿回归(详见实验方案 E10) |
| `13a4a40` `d346e4e` | KeepAlive 转正(`docs/芯片手册/CH395F.md` §5.56)+ **阶段 1.5** recv 语义主流化 | E11/E12 台架 |
| `f7f1793` `83a7072` | **阶段 2** ch395f 五原语(只加不接线)+ E2/E3/E4 定案 | Phase 15 套件 20/20 |
| `6394853` `f51c083` | **阶段 3** net 层收敛:单一 reconciler + 忙等统一 + 镜像降级 + E-02 收口(`net_drop_conn`) | 终版回归全绿(Trap 22/23/25、E-02 双 PASS、104、对时、integrity 15/15) |
| `123d80a` | E6 插桩 `NET_U15_PROBE`(默认关,零成本) | 24h 挂机进行中(阶段 3 项 4 门禁) |
阶段 0/1 已完成并转正;阶段 2/3(驱动原语 / net 层收敛)未开始。阶段 1 实施
过程中的一条关键台架发现已留痕(实验方案 E10):**`net_listener_armed` 必须
放行 ESTABLISHED**——单连接模型的"待 accept"电平,漏放行时 i104(200ms 周期)
抢不过对账② 的 ~400ms 去抖而整场饿死(18/18 FAIL → 修复后 18/18 PASS,127s→8.3s)。
阶段 0~3 已完成并转正;在途仅 **E6 挂机**(裁决项 4 U15 内化做不做)与延后项
(relisten 沉降段统一,见实施方案 §5.2)。阶段 1 实施过程中的一条关键台架发现
已留痕(实验方案 E10):**`net_listener_armed` 必须放行 ESTABLISHED**——单连接模型
的"待 accept"电平,漏放行时 i104(200ms 周期)抢不过对账② 的 ~400ms 去抖而整场
饿死(18/18 FAIL → 修复后 18/18 PASS,127s→8.3s)。
## 0. 结论先行(直接回答本次审计的问题)
@@ -0,0 +1,119 @@
# 为什么网络「一直不稳」——七个误会与逐个拆掉(N34 复盘总览)
> 定位:本文是 N34 自愈审计的**人类可读复盘总览**——把散在 Trap 记录、实验台账、
> 实施方案里的根因,用一个贯穿比喻串成一条因果链,并逐环给出已落地的拆法与证据出处。
> 技术细节与行号证据一律指向本目录其余文档与 `docs/问题记录/CH395F_Trap_Records.md`,
> 此处不重复。适合新成员第一遍读,也适合对外解释"我们的网络为什么曾经时好时坏"。
**一句话回答**:不稳从来不是"网断了",而是**一个会丢门铃、只会礼貌送客的芯片**,
配上一套**靠门铃驱动、人人插手重建、唯一传达员还会被按在椅子上半分钟**的软件——
每个环节单看都"正常工作",叠起来就是随机、无日志、时好时坏的"玄学"。
## 0. 角色表
把 CH395F 芯片想成一个**外国籍传达室大爷**:他会收发信件(TCP 栈),但**门铃会坏**
(中断会丢)、**只会礼貌送客不会轰人**(close 无 RST)。netTask 是**全楼唯一能进传达室
的传达员**(所有 CH395F 事件只在 `net_poll` 集中读清)。FTP、对时、UI 推流、IEC104
是**楼里等信的住户**(各自监听一个端口,但共用这一个传达员)。
## 1. 七个误会(叠加才成为"玄学")
### ① 门铃会丢(Trap 22:丢中断)
快速复连时芯片本应"按门铃"(INT# 拉低)通知有新来访/客人离开,但门铃**会哑**——
人已经站在屋里,铃没响。传达员只靠门铃干活,于是本层状态**静默滞留**:端口其实
死了,日志里**一个错都不报**。当年"待机后 UI 复连 2/10 永久拒连、FTP 第 29 轮自己
死掉"就是它——不是断网,是**没人报信**。裸机芯片台架(剥离 OS/net/FTP)独立复现,
证明这是芯片天性,不是固件 bug(见 `docs/网络/N34-自愈审计/README.md` 结论 1)。
### ② 门铃总闸是"看一眼就清零"(Trap 25 B3)
全局中断寄存器是**一整排灯,读一次全部熄灭**。历史上 open/relisten 里有两处
"丢弃式读一遍"——等于**查自己门铃时把全楼别的住户的门铃线也拔了**,别人的
CONNECT/DISCONNECT 从此永久失踪。这是"自家制造的丢中断"放大器。
### ③ 柜台和货梯是同一台(单连接模型)
监听 fd 就是数据 fd——**商店柜台平时兼货运电梯**。住户看到"客人走了"(EOF)习惯性
`net_close` → **把柜台连电梯一起拆了** → 端口"连接被拒"。而芯片侧其实已自动恢复
(auto_relisten),应用这一刀纯属帮倒忙。注意:单连接复用本身**不是**错误(主流硬
TCP 栈全部同形),错的是住户不懂模型就动手。
### ④ 三个临时工都在修电梯(多所有者 relisten)
accept、recv、pending 三处都会"顺手重建监听",谁先看到断连谁动手 → 互相踩脚:
重建风暴、慢消费者还没读完的 EOF 被吞、新客户端数据被旧会话静默吸收。修得越多越乱。
### ⑤ 传达员被按在椅子上(队头阻塞)
一个挂起的对时客户端不发数据 → 旧代码 **30 秒阻塞收**;而 netTask 是**全楼唯一
传达员**——这 30 秒里 FTP 的开门请求、UI 的连接请求全部排队冻结(E-02 实测 FTP
banner 迟到 25~30 s)。各处还有 500 ms 盲等忙等,传达员频繁"冻结半秒"。
### ⑥ 僵尸客人赖着不走(E13:网卡闪断)
PC 网卡闪断一下 → PC 内核**悄悄把自己的连接记录撕了**(不发 FIN),芯片那头还认为
"客人正在屋里聊天"(ESTABLISHED)。单连接模型下**唯一的柜台被这个不存在的客人永久
占用**,真客人来了只得到"连接被拒"。当时看起来像"设备网络莫名死了",其实是
**和幽灵做生意**。
### ⑦ 大爷只会礼貌送客,不会轰人(close 无 RST,手册定案)
芯片的 close 只发 FIN("您请回吧")。对面**内核活着但程序挂死**时——内核礼貌回
ACK,但客人**就是不起身**——芯片永远站在门口等(FIN_WAIT_2 长尾,E-02 定案:监听
恢复受限于对端 close)。它**没有"轰出去"(RST)这个动作**——手册全文 RST 只出现在
listen 占用与多连接两处。这是芯片天性。
### ⑧ 垃圾不清,越扫越脏(残渣风暴态)
上面每一条的残骸(迟到的 DISCONNECT 锁存、半死 Socket、粘滞中断位)都会**带进下
一条连接**。所以故障率不是常数:残渣少时 2%,堆起来能摆到 54%(E8 实测)——同一份
固件时好时坏,看起来像"玄学"。
## 2. 全景图
![七个误会与逐个拆掉](../../images/网络不稳根因总览.svg)
## 3. 僵尸会话时间线(误会⑥ 的完整一生)
```mermaid
sequenceDiagram
participant PC as PC(网卡+内核)
participant CH as CH395F(大爷)
participant NT as netTask(传达员)
Note over PC,CH: 会话正常通信(ESTABLISHED)
Note over PC: 网卡闪断(驱动重启/电源管理)
PC--)CH: 内核撕掉 socket,不发 FIN(静默)
Note over CH: 仍认为 ESTABLISHED,唯一柜台被幽灵占用
Note over NT: 新连接到达 → 连接被拒(端口"死了")
CH->>PC: keepalive 探测(空闲 60s 起,每 5s,共 3 次)
PC--xCH: 无人认领 → 探测无响应
CH->>NT: TIM_OUT 中断(≈30~90 s)
NT->>CH: QUARANTINE:sock_reset 排渣(读空→CLOSE→排空→确认)
NT->>CH: relisten → LISTEN
Note over NT: 端口恢复,新客人正常进门
```
## 4. 环 → 拆法 → 落点(对照表)
| 环 | 拆法(已落地) | 证据/落点 |
|---|---|---|
| ① 丢中断 | 单一 reconciler:100 ms 巡逻对账五种形态,判据读驱动状态缓存 | Trap 22;实验方案 E2(回读 18.1 µs 背书);实施方案 阶段 3 项 1 |
| ② 总闸读清 | 全局寄存器唯一读者=net_poll(F-NET-7);open/relisten 只清本 Socket SINT | Trap 25 B3;实施方案 阶段 0/2 |
| ③ 拆柜台 | 铁律"EOF 不得 close 监听 fd" + 新 API `net_drop_conn`(拆会话保监听) | 生命周期设计说明 §10.10;实验方案 E-02 |
| ④ 多所有者 | 重建收敛唯一所有者(net_listener + reconciler),其余只置事实不动手 | README 结论 4(不变量 II);实施方案 阶段 3 项 1 |
| ⑤ 队头阻塞 | 忙等统一 `net_sock_reset_sync` 有界排渣(600 ms 硬顶);recv 非阻塞消息化 | 实验方案 E-02(banner 39 ms);阶段 1.5 |
| ⑥ 僵尸会话 | keepalive 60/5/3 + 芯片 TIMEOUT → QUARANTINE → 重建(30~90 s 自愈) | 实验方案 E11/E13(用户判断"不该依赖冷启动"被证实) |
| ⑦ 无 RST | 定案入 Trap 07:兜底靠查岗不靠 RST;`net_drop_conn` 本地拆除 + 对账③④ 认领 | Trap 07 定案更正;实验方案 E-02 收口 |
| ⑧ 残渣风暴 | `sock_reset` 标准排渣(读空→CLOSE→排空事件→byte0=00H 确认)+ M3 矛盾态门 | Trap 23/25;实施方案 M3/M5(E8/E9 风暴态免疫) |
## 5. 一句话总结
原来的"不稳" = 芯片天性(丢门铃、无 RST)× 软件形态(靠门铃驱动、人人插手、
唯一传达员会冻结)× 残渣复利(脏态带进下条连接)。N34 做的事不是修某一个 bug,
而是把每一环改成**不依赖门铃(100 ms 对账)、只有一个主人(单所有者)、永远不冻结
(有界排渣+消息化)、开张前扫垃圾(sock_reset)**——所以现在的"稳"是结构性的。
> 遗留观察项:E6 正在挂机量化"RX 非空但 readable=0"(RECV_OK 中断丢失的数据面
> 形态)在生产是否真实发生(`NET_U15_PROBE`,实验方案 E6 节)——命中>0 则上层
> 32 轮盲收内化进 net 层(阶段 3 项 4);=0 则保留现状登记观察。
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成 docs/images/网络不稳根因总览.svg —— N34 复盘配图(纯标准库)。
布局:左列 = 七个误会(问题环,玫红),右列 = 对应拆法(绿),行间水平箭头;
左列环间竖向细箭头 = "叠加累积";底部两个总结盒。
为什么不用 Mermaid:双列对齐 + 水平映射箭头需要精确布局。
用法(仓库根目录,任意 python3):
python tools/arch_diagram/gen_net_instability_svg.py
输出:
docs/images/网络不稳根因总览.svg
改内容时:编辑本文件顶部的 RINGS / SUMMARY 数据后重跑即可。
"""
import html
import os
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(ROOT, "docs", "images", "网络不稳根因总览.svg")
# ----------------------------- 画布与尺寸参数 -----------------------------
W = 1560
PAD = 26
TITLE_FONT = 20
SUB_FONT = 13
BAND_TITLE_FONT = 15
BAND_SUB_FONT = 11.5
CHIP_FONT = 13
CHIP_H = 34 # 芯片盒高
CHIP_GAP = 10 # 芯片水平间距
CHIP_ROW_GAP = 8 # 芯片行间距
CHIP_PAD = 14 # 芯片内左右留白
BAND_PAD_X = 16 # 框内左右留白
BAND_TITLE_H = 32 # 框内标题区高
BAND_PAD_BOTTOM = 12
COL_GAP = 128 # 左右列间距(放映射箭头)
ROW_V_GAP = 40 # 行间距(放"叠加"竖向箭头)
TITLE_H = 78 # 顶部标题区高
SUM_H = 78 # 底部总结盒高
FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif"
C_PROB = "#e11d48" # 问题列(玫红)
C_PROB_BG = "#fff1f2"
C_FIX = "#16a34a" # 拆法列(绿)
C_FIX_BG = "#f0fdf4"
C_GRAY = "#94a3b8"
# ----------------------------- 数据(改这里) -----------------------------
# (问题标题, 问题副标, [问题芯片], 拆法标题, [拆法芯片])
RINGS = [
("① 门铃会丢", "Trap 22:丢中断",
["症状:UI 复连永久拒连、FTP 第 29 轮自己死掉,全程不报一个错",
"机制:快速复连时 INT# 门铃哑火,netTask 只靠门铃干活 → 状态静默滞留"],
"100ms 巡逻对账",
["单一 reconciler:判据读驱动状态缓存(E2 背书 18.1µs),分级去抖",
"门铃丢了不要紧,每 100ms 亲自数一遍人头"]),
("② 门铃总闸看一眼就清零", "Trap 25 B3",
["症状:别的 Socket 的 CONNECT/DISCONNECT 从此永久失踪",
"机制:全局中断寄存器整体读即清,open/relisten 丢弃式读一遍=拔全楼门铃线"],
"总闸唯一读者",
["F-NET-7:全局寄存器唯一读者=net_poll;open/relisten 只清本 Socket SINT",
"查自己门铃时,不许拔别人家的线"]),
("③ 柜台和货梯是同一台", "单连接模型",
["症状:会话 EOF 之后端口「连接被拒」",
"机制:监听 fd == 数据 fd,应用在 EOF 时 net_close = 把柜台连电梯一起拆"],
"送客走正门",
["铁律:EOF 不得 close 监听 fd;新 API net_drop_conn(拆会话保柜台)",
"net_drop_conn = 优雅 FIN + 本地拆除 + relisten,幂等"]),
("④ 三个临时工都在修电梯", "多所有者 relisten",
["症状:重建风暴、慢消费者的 EOF 被吞、新客户端数据被旧会话吸收",
"机制:accept/recv/pending 三处都重建监听,谁先看到断连谁动手互相踩脚"],
"修电梯的只剩一个人",
["重建收敛到唯一所有者(net_listener + reconciler),其余位置只置事实不动手",
"不变量 II:唯一周期重建决策者(消费者即时重建三处保留,P2a 实测)"]),
("⑤ 传达员被按在椅子上", "队头阻塞",
["症状:FTP banner 迟到 25~30 s(E-02 实测),netTask 频繁冻结半秒",
"机制:30 s 阻塞收 + 500 ms 盲等忙等,而 netTask 是全楼唯一传达员"],
"传达员永不离开传达室",
["忙等统一为 net_sock_reset_sync 有界排渣(600 ms 硬上限),阻塞消息化",
"阶段 1.5:net_recv 默认非阻塞 + net_recv_nb + 60 s tripwire 日志"]),
("⑥ 僵尸客人赖着不走", "E13:网卡闪断",
["症状:设备网络「莫名死了」、新连接被拒——在和幽灵做生意",
"机制:PC 网卡闪断,内核悄悄撕掉 socket 不发 FIN,芯片侧仍 ESTABLISHED 占死柜台"],
"查岗处理幽灵",
["keepalive 60/5/3(E11 实测 ≈30 s 判死)+ 芯片 TIMEOUT → QUARANTINE → 重建",
"自愈 30~90 s,不需要冷启动(E13 台架收口:用户判断正确)"]),
("⑦ 只会礼貌送客不会轰人", "close 无 RST(手册定案)",
["症状:FIN_WAIT_2 长尾,监听恢复受限于对端 close(E-02 定案)",
"机制:芯片 close 只发 FIN;对端内核存活但应用挂死 = 回 ACK 却不起身"],
"知道他不轰人,就别指望他轰人",
["定案写入 Trap 07:兜底靠查岗(keepalive/TIMEOUT),不靠 RST",
"net_drop_conn 本地拆除 + reconciler 形态③④ 认领僵尸态"]),
("⑧ 垃圾不清越扫越脏", "残渣风暴态",
["症状:同一份固件故障率 2%~54% 摆动(E8 实测),看起来像「玄学」",
"机制:迟到 DISCONNECT 锁存 / 半死 Socket / 粘滞中断位,被带进下一条连接"],
"开张前把垃圾扫干净",
["sock_reset 标准排渣:读空 RX → CLOSE → 排空事件 → 确认落地(byte0=00H)",
"M3 矛盾态门 + M5 drain-until-quiescent(风暴态免疫)"]),
]
SUM_LEFT = ["七环叠加 =「玄学不稳」",
"随机 · 无错误日志 · 时好时坏——每个环节单看都「正常工作」"]
SUM_RIGHT = ["逐环拆掉 = 稳(N34 阶段 0~3 已实施)",
"不依赖门铃 · 唯一主人 · 永不冻结 · 100ms 对账 · 开张前排渣——台架回归全绿"]
FOOT = ("证据链:docs/问题记录/CH395F_Trap_Records.md(Trap 07/09/14/17/22/23/25) · "
"docs/网络/N34-自愈审计/实验方案.md(E1~E13) · 修复落地见 docs/网络/N34-自愈审计/实施方案.md")
# ----------------------------- 工具函数 -----------------------------
def esc(s):
return html.escape(s, quote=True)
def text_w(s, fs):
"""粗略估算文本像素宽:CJK/全角/圈码/箭头 ≈ 1.0em,其余 ≈ 0.58em(宁大勿小)。"""
w = 0.0
for ch in s:
w += fs * (1.0 if ord(ch) >= 0x2460 else 0.58)
return w
def chip_w(label):
return text_w(label, CHIP_FONT) + 2 * CHIP_PAD
def wrap_chips(chips, avail):
rows, cur, cur_w = [], [], 0.0
for c in chips:
cw = chip_w(c)
need = cw if not cur else cur_w + CHIP_GAP + cw
if cur and need > avail:
rows.append(cur)
cur, cur_w = [c], cw
else:
cur.append(c)
cur_w = need
if cur:
rows.append(cur)
return rows
def band_h(rows):
return BAND_TITLE_H + len(rows) * CHIP_H + (len(rows) - 1) * CHIP_ROW_GAP + BAND_PAD_BOTTOM
# ----------------------------- 生成 -----------------------------
def draw_band(out, title, sub, chips, bx, by, bw, bh, color, bg):
add = out.append
add('<rect x="%d" y="%.1f" width="%d" height="%.1f" rx="10" fill="%s" stroke="%s" '
'stroke-opacity="0.45"/>' % (bx, by, bw, bh, bg, color))
add('<text x="%d" y="%.1f" font-size="%d" font-weight="700" fill="%s">%s</text>'
% (bx + BAND_PAD_X, by + 22, BAND_TITLE_FONT, color, esc(title)))
if sub:
tw = text_w(title, BAND_TITLE_FONT)
add('<text x="%.0f" y="%.1f" font-size="%s" fill="#64748b">%s</text>'
% (bx + BAND_PAD_X + tw + 10, by + 22, BAND_SUB_FONT, esc(sub)))
cy = by + BAND_TITLE_H
for row in chips:
cx = bx + BAND_PAD_X
for c in row:
cw = chip_w(c)
add('<rect x="%d" y="%.1f" width="%d" height="%d" rx="7" fill="#ffffff" '
'stroke="%s" stroke-opacity="0.55"/>' % (cx, cy, cw, CHIP_H, color))
add('<text x="%d" y="%.1f" font-size="%d" fill="#1e293b" text-anchor="middle">%s</text>'
% (cx + cw / 2, cy + CHIP_H / 2 + CHIP_FONT * 0.35, CHIP_FONT, esc(c)))
cx += cw + CHIP_GAP
cy += CHIP_H + CHIP_ROW_GAP
def build():
out = []
add = out.append
lw = (W - 2 * PAD - COL_GAP) // 2
rw = W - 2 * PAD - COL_GAP - lw
avail_l = lw - 2 * BAND_PAD_X
avail_r = rw - 2 * BAND_PAD_X
# 预排:每行高度 = 左右两框较高者
placed = []
y = TITLE_H
for (pt, ps, pc, ft, fc) in RINGS:
lrows = wrap_chips(pc, avail_l)
rrows = wrap_chips(fc, avail_r)
h = max(band_h(lrows), band_h(rrows))
placed.append((pt, ps, lrows, ft, rrows, y, h))
y += h + ROW_V_GAP
sum_y = y - ROW_V_GAP + 26
total_h = sum_y + SUM_H + 46
add('<rect x="0" y="0" width="%d" height="%d" fill="#ffffff"/>' % (W, total_h))
add('<text x="%d" y="%d" font-size="%d" font-weight="700" fill="#0f172a">%s</text>'
% (PAD, 40, TITLE_FONT, esc("为什么网络「一直不稳」——七个误会与逐个拆掉(N34 复盘)")))
add('<text x="%d" y="%d" font-size="%d" fill="#64748b">%s</text>'
% (PAD, 62, SUB_FONT,
esc("左列 = 根因(比喻 + 机制);右列 = N34 阶段 0~3 的对应拆法;环与环叠加才构成「玄学」")))
for (pt, ps, lrows, ft, rrows, by, h) in placed:
draw_band(out, pt, ps, lrows, PAD, by, lw, h, C_PROB, C_PROB_BG)
draw_band(out, ft, "", rrows, PAD + lw + COL_GAP, by, rw, h, C_FIX, C_FIX_BG)
# 水平映射箭头(问题 → 拆法)
ay = by + h / 2
x0 = PAD + lw + 6
x1 = PAD + lw + COL_GAP - 10
add('<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="2.5"/>'
% (x0, ay, x1, ay, C_GRAY))
add('<path d="M %d %.1f l -9 -6 v 12 z" fill="%s"/>' % (x1, ay, C_GRAY))
add('<text x="%d" y="%.1f" font-size="11" fill="#64748b" text-anchor="middle">%s</text>'
% (PAD + lw + COL_GAP / 2, ay - 8, esc("拆掉")))
# 左列环间竖向箭头(叠加累积)
for i in range(len(placed) - 1):
y0 = placed[i][5] + placed[i][6] + 5
y1 = placed[i + 1][5] - 7
ax = PAD + 60
add('<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1.6" '
'stroke-dasharray="4 3" stroke-opacity="0.7"/>' % (ax, y0, ax, y1, C_PROB))
add('<path d="M %d %.1f l -4.5 -7 h 9 z" fill="%s" fill-opacity="0.7"/>' % (ax, y1, C_PROB))
add('<text x="%d" y="%.1f" font-size="11" fill="%s" transform="rotate(-90 %d %.1f)">%s</text>'
% (PAD - 14, placed[3][5] + placed[3][6] / 2, C_PROB, PAD - 14,
placed[3][5] + placed[3][6] / 2, esc("叠加累积 → 越扫越脏")))
# 底部总结盒
add('<rect x="%d" y="%d" width="%d" height="%d" rx="10" fill="%s" stroke="%s" '
'stroke-opacity="0.5"/>' % (PAD, sum_y, lw, SUM_H, C_PROB_BG, C_PROB))
add('<text x="%d" y="%d" font-size="15" font-weight="700" fill="%s">%s</text>'
% (PAD + BAND_PAD_X, sum_y + 28, C_PROB, esc(SUM_LEFT[0])))
add('<text x="%d" y="%d" font-size="12" fill="#334155">%s</text>'
% (PAD + BAND_PAD_X, sum_y + 52, esc(SUM_LEFT[1])))
rx = PAD + lw + COL_GAP
add('<rect x="%d" y="%d" width="%d" height="%d" rx="10" fill="%s" stroke="%s" '
'stroke-opacity="0.5"/>' % (rx, sum_y, rw, SUM_H, C_FIX_BG, C_FIX))
add('<text x="%d" y="%d" font-size="15" font-weight="700" fill="%s">%s</text>'
% (rx + BAND_PAD_X, sum_y + 28, C_FIX, esc(SUM_RIGHT[0])))
add('<text x="%d" y="%d" font-size="12" fill="#334155">%s</text>'
% (rx + BAND_PAD_X, sum_y + 52, esc(SUM_RIGHT[1])))
add('<text x="%d" y="%d" font-size="12" fill="#94a3b8">%s</text>'
% (PAD, total_h - 16, esc(FOOT)))
svg = ('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
'viewBox="0 0 %d %d" font-family="%s">\n%s\n</svg>\n'
% (W, total_h, W, total_h, FONT, "\n".join(out)))
return svg, total_h
def main():
svg, h = build()
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8", newline="\n") as f:
f.write(svg)
import xml.etree.ElementTree as ET
ET.fromstring(svg)
print("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, h, OUT))
if __name__ == "__main__":
main()