# 任务失败判定逻辑调研:Tlusty&Synspec收敛性判断LUSTY / SYNSPEC 退出码可靠性与兜底机制 > 调研日期:2026-08-04 > 范围:`crates/common/src/runner.rs`、`crates/node/src/reporter.rs`、`crates/common/src/conv_check.rs` > 的任务成败判定逻辑,对照 TLUSTY 208 / SYNSPEC 54 源码(`tlusty/tlusty208.f`、`synspec/synspec54.f`) > 与 gfortran 运行时退出码行为。 > 方法:源码级静态分析 + gfortran 实测(退出码、运行时错误)+ 三方独立复核。 > 本文仅记录调研结论,不含修复实现。 --- ## 0. TL;DR 调度系统通过子进程退出码 `rc` 判定 TLUSTY / SYNSPEC 成败,但 **gfortran 下裸 `STOP` 默认返回 rc=0**,而两个 Fortran 程序的几乎所有错误路径用的都是裸 `STOP` 或 `call quit`(内部亦为裸 `stop`)。因此 **rc 本身不可靠**。 当前判定能"基本不出错",靠的是 rc 之外的兜底: - **TLUSTY**:`rc==0 && fort.7 存在` 才进入 `check_fort9`(max_relc < chmax)+ `atmosphere_has_nan` 双重否决。这三重条件对"显式发散 STOP"和"硬崩溃(信号)"覆盖可靠。 - **SYNSPEC**:**仅靠 `synspec_rc==0`,下游零内容校验**,是当前系统最大的科学正确性风险。 复核(含 gfortran 运行时错误实测)发现两类此前未点出的风险: 1. **gfortran 运行时错误默认返回 rc=0 且静默**(实数 IEEE 异常、数组越界),生产编译未启用 `-fcheck`/`-ffpe-trap`。 2. **fort.9 缺失分支无条件判收敛**,且写入虚构的 `best_max_relc=0.0`,会污染种子选择并向相邻网格点扩散。 修复优先级收敛为:SYNSPEC `.spec` 内容校验 > 收紧 `atmosphere_has_nan` 阈值 > 修正 fort.9 缺失分支 > 补齐 SOLVES/RYBSOL 发散路径归因。 --- ## 1. 现状:失败判定的四层结构 node 端任务失败判定从底向上分四层: ``` execute_task (executor.rs) ← 前置 IO 失败 → 直接返回 Err └─ run_model_with_timeout ← 计算失败 → 产出 ModelSummary(converged 字段) └─ derive_report_status ← 半失败判定 → Completed / Failed └─ infer_failed_stage ← 失败归因 → tlusty / synspec ``` ### 第一层:`execute_task`(executor.rs:11)—— 前置 IO 失败 直接返回 `Err`、进不到计算: | 失败情形 | 处理 | 行号 | | -------------------------------------- | ----------------------------------- | ------------------- | | 原子数据文件拉取失败 | `warn` 但**继续**(非致命) | executor.rs:46-50 | | 种子大气下载失败(HTTP 非 2xx / 网络) | `bail!` 返回 Err | executor.rs:114-137 | | 沙盒目录创建失败 | `?` 返回 Err | executor.rs:57 | 返回 Err 时,worker.rs:456 转为 `String`,reporter 走 `Err(e)` 分支(reporter.rs:78-89): `status=Failed, converged=false, error_message=e`。 ### 第二层:`run_model_with_timeout`(runner.rs:232)—— 计算过程收敛判定 核心,`final_converged` 在此累积,分三块: **① TLUSTY 阶段链收敛判定(runner.rs:402-469)** 每个阶段执行后,只有 `rc==0 && fort.7 存在` 才进入收敛检查,否则该阶段 `converged=false`: ```rust // runner.rs:402 if rc == 0 && fort7.is_file() { if fort9.is_file() { let res = check_fort9(&fort9, eff_chmax); stage_summary.converged = res.converged; // max_relc < chmax } else { // NITER=0 grey start without fort.9 ← 见 §4 漏洞 2 stage_summary.converged = true; stage_summary.best_max_relc = Some(0.0); } } else { stage_summary.note = Some(format!("tlusty rc={} or missing fort.7", rc)); } ``` `check_fort9` 的收敛判据(conv_check.rs:163): ``` converged = max_relc.is_finite() && max_relc < chmax ``` 其中 `max_relc` 是 fort.9 **最后一次迭代**中所有深度点里**最大的相对修正绝对值**。关键防御: - `chmax ≤ 0 或非有限` → 直接判失败并报错(conv_check.rs:58,防误配) - fort.9 无有效迭代数据 → `max_relc=Infinity`,判失败(conv_check.rs:127) - 发散值(NaN/Inf/无-E 记数法 `-1.35+118`)→ `is_finite()=false` → 判失败(conv_check.rs:160) - fort.9 缺失但 rc=0 → 视为"NITER=0 grey start",**判收敛**(runner.rs:419)—— 见 §4 漏洞 2 阶段间联动:若某阶段 `require_converged=true` 且未收敛 → `break`,中止后续所有阶段(runner.rs:463)。 `final_converged` 取最后执行阶段的值。 **② 大气 NaN 强制否决(runner.rs:488-491)** ```rust let atmo_has_nan = atmosphere_has_nan(&final_7); if atmo_has_nan { final_converged = false; } ``` `atmosphere_has_nan`(conv_check.rs:190)检测大气文件里 `NaN`/`Inf`/`********`(Fortran 字段溢出), **超 10% 行命中**即判无效。此步会推翻前面收敛结论 —— 见 §4 漏洞 3 的阈值盲区。 **③ SYNSPEC 阶段(runner.rs:498-603)** SYNSPEC 不直接改 `final_converged`,记录到 `synspec_rc` / `synspec_error`: - `fort.8`(大气输入)复制失败 → 记 `synspec_error`,跳过(runner.rs:508) - `fort.55`(控制卡)写入失败 → 记 `synspec_error`,跳过(runner.rs:531) - synspec 进程 rc≠0 或超时(上限 `min(600, timeout_sec)`)→ 记 `synspec_rc`(runner.rs:568-575) **仅 SYNSPEC 场景**(tlusty 关闭)的收敛重判(runner.rs:611-616): `final_converged = (synspec_rc == 0)`。 ### 第三层:`derive_report_status`(reporter.rs:38)—— 半失败 = 失败 ```rust let synspec_failed = synspec_error.is_some() || matches!(synspec_rc, Some(rc) if rc != 0); if s.converged && !synspec_failed { TaskStatus::Completed } else { TaskStatus::Failed } ``` 旧逻辑 `if converged { Completed }` 只看大气收敛,TLUSTY 收敛但 SYNSPEC 失败时 `converged` 仍为 true → 误报成功 → 服务端吸收为终态。新逻辑:**大气好 + 光谱坏 = 整体失败**,让服务端弹 synspec 链重试。 `converged` 字段仍按大气真实状态上报(为 true 时服务端仍存 .7 作种子)。 ### 第四层:`infer_failed_stage`(reporter.rs:15)—— 失败归因 | 条件 | 归因 | | ---------------------------------------- | -------------------- | | `converged=false` + tlusty 启用 | `tlusty`(含级联) | | `converged=false` + tlusty 关闭 | `synspec` | | `converged=true` 但 synspec 出错/rc≠0 | `synspec` | | 全部成功 | `None` | --- ## 2. `rc==0` 的真实含义与不可靠性 ### 2.1 gfortran 退出码实测(实证) 在 `/tmp` 用最小 Fortran 程序实测(gfortran 15.2.0,`-fno-automatic -O3` 等价于生产编译环境): | Fortran 语句 / 错误类型 | 触发方式 | 退出码 rc | runner 能否抓住 | | ---------------------------------- | ------------- | --------------------------- | ----------------------- | | 裸`STOP` | `stop` | **0** | ❌ 靠 fort.9 兜 | | `STOP 'msg'` | `stop 'x'` | **0** | ❌ 靠 fort.9 兜 | | `STOP 1`(带数字) | `stop 1` | 1 | ✅(TLUSTY 未用此形式) | | 自然`END` | 主程序结束 | 0 | — | | 实数除零`1.0/0.0` | IEEE 默认 | **0** | ❌ 产 Inf,无陷阱 | | `0.0/0.0` | IEEE 默认 | **0** | ❌ 产 NaN | | 实数溢出`1e30*1e30` | IEEE 默认 | **0** | ❌ 产 Inf | | `sqrt(-1)` / `log(0)` | IEEE 默认 | **0** | ❌ 产 NaN/-Inf | | **整数除零** `1/0` | SIGFPE | **136**(128+8) | ✅ | | **数组越界(默认)** | 无`-fcheck` | **0,静默写越界内存** | ❌ | | 数组越界(`-fcheck=bounds`) | 启用检查 | 2 | ✅ | | READ 遇 EOF(无`end=` 守卫) | 运行时 | **2** | ✅ | | READ 类型不匹配(无`err=` 守卫) | 运行时 | **2** | ✅ | | 栈溢出 / 段错误 | SIGSEGV | **139**(128+11) | ✅ | > 生产编译命令(deployment.md:86,90)为 `gfortran -fno-automatic -O3`,**未启用** `-fcheck=bounds` 与 > `-ffpe-trap=invalid,zero,overflow`。因此表中所有 rc=0 的静默失败在生产二进制中真实存在。 ### 2.2 核心矛盾 **Fortran 的 `STOP` 语句默认返回 0**,而 TLUSTY/SYNSPEC 几乎所有错误处理用的都是裸 `STOP` 或 `call quit`(内部裸 `stop`)。导致**进程异常终止的退出码 = 正常完成的退出码 = 0**。 - TLUSTY `subroutine quit`(tlusty208.f:29946-29955)末行裸 `stop`。 - SYNSPEC `subroutine quit`(synspec54.f:12014-12021)末行裸 `stop`。 - 两者全文 80+ 处 `STOP`/`call quit`,错误路径几乎全走裸 `STOP`。 --- ## 3. 源码级失败路径分析 ### 3.1 TLUSTY:三重兜底对"显式发散"覆盖可靠 **主循环结构(tlusty208.f:1-62):** ```fortran 10 ITER=ITER+1 CALL RESOLV ← 形式解;内部 CALL OUTPUT(:3807/:3828) 每次迭代写 fort.7 IF(LFIN) GO TO 20 ← 上一轮设了 LFIN 就跳出 IF(IACC.GT.0) CALL ACCEL2 IF(IFRYB.EQ.0) THEN IF(NN.GT.MSMX) THEN CALL SOLVE ← 含发散 STOP(:14703) ELSE CALL SOLVES ← 含发散 STOP(:15050) END IF ELSE CALL RYBSOL ← 含发散 STOP(:47585-47590) ← 修正点:第三条路径 END IF GO TO 10 20 STOP ← 主程序正常结束,裸 STOP → rc=0 END ``` **关键事实:** - **fort.7 每次迭代都写**:`CALL OUTPUT`(tlusty208.f:3807, 3828)在 RESOLV 内,每次迭代执行。 - **发散 STOP 在三处平行求解器内**(不止 SOLVE): - `SOLVE` :14701-14706(`CHMX > 1.D16` → 裸 STOP) - `SOLVES` :15047-15051(同逻辑) - `RYBSOL`/RYBCHN :47585-47590(同逻辑,`IFRYB≠0` 时走此路) 三处发散检查都在 `PRCHAN`/RYBCHN 写完 fort.9 **之后**,故发散 STOP 时 fort.7 与 fort.9 均已落盘。 - **达到 NITER 未收敛**:`LFIN=ABS(CHMX).LE.CHMAX.OR.ITER.GE.NITER`(:14728),走主程序正常 END,rc=0,fort.9 含未收敛的 max_relc。 **TLUSTY 失败场景对照:** | 场景 | Fortran 处理 | rc | 写 fort.7 | 写 fort.9 | runner 判定 | 是否合适 | | --------------------- | --------------------------------- | ----------- | --------- | --------- | ---------------------------------------- | -------- | | 正常收敛 | 主程序 END | 0 | ✅ | ✅ | check_fort9 → converged | ✅ | | 达到 NITER 未收敛 | `LFIN`,END | 0 | ✅ | ✅ | check_fort9 → max_relc≥chmax → 未收敛 | ✅ | | 数值发散 CHMX>1e16 | SOLVE/SOLVES/RYBSOL 内裸 STOP | **0** | ✅ | ✅ | check_fort9 → 发散值未收敛 | ✅ | | 输入错误(call quit) | 裸 stop | 0 | ❌ | ❌ | rc=0 但 fort.7 缺失 → 失败 | ✅ | | temp 越界 | `stop 'partf; temp...'`(:44624) | **0** | ❌ | ❌ | fort.7 缺失 → 失败 | ✅ | **结论**:rc 虽不可靠,但 `rc==0 + fort.7 存在 + check_fort9 + atmosphere_has_nan` 四重条件对"显式 STOP 类发散"和"硬崩溃(信号)"覆盖可靠 —— 这是一个**巧合但有效**的设计。 ### 3.2 SYNSPEC:仅靠 rc,基本裸奔 **主程序结构(synspec54.f:1-174):** 主循环 `:10` 标签到 `:150 IF(IBLANK.LT.NBLANK) GO TO 10`, `CALL OUTPRI`(:143)写谱输出(unit 7 谱 / 17 连续谱 / 16 部分等宽 / 6 诊断)且**在循环内、多次追加写**(无 REWIND)。 主程序以自然 `END`(:174)结束,正常路径 rc=0。 **call quit / STOP 相对 OUTPRI 的时序分类(复核产出):** | 归类 | 数量 | 代表行号 | 风险 | | ------------------------------------------------------------------ | -------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------ | | **OUTPRI 之前**(START 阶段输入校验) | 多数 | :726, :808, :937, :1062, :1856, :2229, :12168, :12224, :12252, :12323, :12324, :16745, :19455, :19601 | 低 —— 谱未生成,靠"文件缺失"兜得住 | | **OUTPRI 之后 / 之间**(主循环内 RESOLV/RESOLW/OPAC 调用链) | **6 处** | **:4203, :8208, :9935, :18863, :18865, :19891** | 高 —— 脏数据风险 | | 死代码 / 坏码 | 3 处 | :17414`stop23`(坏码)、:17851 CHCKAB(调用被注释)、:18383 MOLSET(从未调用) | 无 | **脏数据风险的精确触发条件**:仅在 **NBLANK>1 或分子/集合重处理**(synspec54.f:152-162)导致前序迭代已写 fort.7 之后,后续迭代在 INISET/OPAC/RESOLW/SETRAY 等处中途 `call quit` → 残缺谱已落盘 + rc=0 → 被误判成功。**单集合单次运行(NBLANK=1)所有 quit 都在首次 OUTPRI 之前,无脏数据**。 **SYNSPEC 失败场景对照:** | 场景 | Fortran 处理 | rc | runner 判定 | 是否合适 | | ---------------------------------------- | ------------------ | ----------- | --------------------------------------------------------------- | --------------- | | 正常完成 | 主程序 END(:174) | 0 | synspec_rc=0 → 成功 | ✅ | | 输入数据错误(OUTPRI 前 quit) | 裸 stop | **0** | 谱未生成 → .spec 缺失(runner.rs:579 is_file 不成立)→ 未产谱 | ⚠️ 靠文件缺失 | | 计算中途错误(OUTPRI 后 quit,多轮场景) | 裸 stop | **0** | synspec_rc=0 →**误判成功** | ❌ 严重 | --- ## 4. 复核发现的漏洞(按严重度排序) > 以下为源码级静态分析与 gfortran 实测、经三方独立复核确认的风险。 ### 漏洞 1(最严重):SYNSPEC 产出的 `.spec` 全链路零内容校验 runner 对 SYNSPEC 产出(runner.rs:578-599)**只有 `is_file()` 存在性检查 + rename/copy**,无任何内容校验: ```rust if model_dir.join("fort.7").is_file() { let _ = tokio::fs::rename(..., ... ".spec").await; // 不查内容 } ``` 下游全部不拦截: - `executor.rs:191-215`:只读 `.7`(种子),从不碰 `.spec`。 - `result_filter.rs:64-110`:纯文件名后缀白名单,`spec` 在白名单内(result_filter.rs:28),0 字节 `.spec` 与干净的 50MB `.spec` **同等归档**。 - `derive_report_status`(reporter.rs:38-45):只看 `synspec_rc` 与 `synspec_error`,不看产物。 完整穿透链:SYNSPEC `call quit`(rc=0)+ 写出含 NaN/Inf/0 字节的 `.spec` → `is_file()` 成立 → rename 成 `.spec` → `final_converged = rc==0`(runner.rs:612)→ `synspec_failed=false`(reporter.rs:39)→ `Completed` → 脏谱归档、服务端吸收为终态、不重试。**没有任何一道防线检查 `.spec` 内容。** ### 漏洞 2(严重):fort.9 缺失分支无条件判收敛 + 虚构 best_max_relc=0.0 runner.rs:417-421,当 `rc==0 && fort.7 存在` 但 fort.9 **不存在**时: ```rust } else { // NITER=0 grey start without fort.9 stage_summary.converged = true; // 无条件判收敛 stage_summary.best_max_relc = Some(0.0); // 虚构"完美收敛"值 stage_summary.note = Some("NITER=0 grey start".to_string()); } ``` - 注释"NITER=0 grey start"是**断言而非检查**,代码未校验本次 stage 的 `niter` 配置是否真为 0。 - `best_max_relc = Some(0.0)` 进入 `final_max_relc`(runner.rs:458-459)→ 上报(reporter.rs:73)。若服务端用 max_relc 选种子,会把误判模型当"完美收敛"优先选作下一轮种子,**污染向相邻网格点扩散**。 - 合法 grey start(`lte`/`nc` 阶段,`require_converged=false`,runner.rs:23/38)与致命崩溃共用同一段无校验代码,无法区分。 **触发条件**:rc==0(gfortran 裸 STOP 或运行时静默错误)+ fort.7 已写 + fort.9 缺失。严重度比漏洞 1 低,因为多数发散情况 fort.9 会写出含发散值的行,反而触发 `check_fort9` 的 `is_finite()=false` 判不收敛。 ### 漏洞 3(严重):gfortran 运行时错误默认 rc=0 且静默 见 §2.1 实测表。在生产编译(无 `-fcheck`/`-ffpe-trap`)下: - **实数 IEEE 异常**(除零、溢出、NaN 产生)→ rc=0,无陷阱,静默产出 NaN/Inf。 - **数组越界**(无 `-fcheck=bounds`)→ rc=0,**静默写越界内存**,可能产出物理上错误但无 NaN 标记的大气。 这两类若发生在 RESOLV 写完 fort.7(tlusty208.f:3828)之后、SOLVE 写 fort.9 之前 → fort.7 在、fort.9 不在 → 命中漏洞 2 → 误判收敛。`atmosphere_has_nan` 能否兜住取决于越界是否恰好产生 NaN/Inf 字面值;若只是静默改写相邻内存,此防线无效。 ### 漏洞 4(中):`atmosphere_has_nan` 的 10% 阈值对物理产物过松 conv_check.rs:212:`(bad_lines as f64) > (total_lines as f64 * 0.1)`,严格大于。 典型 `.7` 文件 60-100 个深度行,阈值 6-10 行: | 场景 | bad/total | 判定 | 物理正确性 | | --------------------------- | --------- | ----------------------- | -------------- | | 全 NaN(彻底发散) | 100% | true → 否决 | 正确 | | 6/100 行 NaN(表层发散) | 6% | **false → 放过** | **错误** | | 5/60 行 NaN(典型深度网格) | 8.3% | **false → 放过** | **错误** | | 单行 NaN | 1% | **false → 放过** | **错误** | 物理上**任意一个深度点的温度/密度是 NaN,整个大气就不可用**(积分会传播 NaN)。10% 是统计学语义,与大气可用性的布尔语义不匹配。表层(深度浅、温度高)最易数值发散,往往只有最外几层出 NaN —— 正好落在盲区。 补充盲区:regex `(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,})`(conv_check.rs:199)只匹配字面 `NaN`/`Inf`/`***`;若 Fortran 把发散值写成接近 f64 上限但未溢出的普通科学记数法(如 `1.0E+308`),不命中任何模式。 ### 漏洞 5(低):`infer_failed_stage` / 日志归因对 RYBSOL 路径不完整 发散 STOP 在 SOLVE / SOLVES / RYBSOL 三处(§3.1),但失败 note 统一记 `tlusty rc=0 or missing fort.7`(runner.rs:429),丢失"为何未收敛"的线索。对走 RYBSOL(`IFRYB≠0`)的发散,归因信息不完整。 ### 漏洞 6(低):仅 SYNSPEC 场景 final_converged 重判只看 rc runner.rs:611-616,TLUSTY 关闭的纯复算场景,`final_converged = (synspec_rc == 0)`,与漏洞 1 同源,场景更窄。 --- ## 5. 被排除的伪风险 **超时 / shutdown 后半成品 fort.7 进 check_fort9 误判收敛 —— 不存在。** `run_child_async_with_timeout` 在超时/shutdown 时返回 `Err`(runner.rs:151, 156)→ 上层 match `rc = -1` (runner.rs:380-383 / 568-574)。于是 `rc==0 && fort.7 存在` 前置门不成立 → 进 `else` 分支 → `converged` 保持 false → `check_fort9` 根本不调用。SYNSPEC 超时同理 → `synspec_rc=-1` → 判失败。 **超时路径是可靠的。** --- ## 6. 修复优先级(仅建议,不含实现) | 优先级 | 漏洞 | 建议 | 改动范围 | | ------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | | P0 | 漏洞 1 | SYNSPEC`.spec` 内容校验:NaN/Inf/`***` + 行数下限 + 非全零,命中则置 `synspec_rc` 为非零或设 `synspec_error` | runner.rs(SYNSPEC 产出后,runner.rs:585 附近) | | P1 | 漏洞 4 | 收紧物理产物 NaN 阈值:产物准入用 0 行容忍(任意一行 NaN 即否决),而非 10% | conv_check.rs:212 或新增严格版函数 | | P1 | 漏洞 2 | runner.rs:417-421:至少把`best_max_relc` 从 `Some(0.0)` 改为 `None`(消除污染扩散);理想是校验 stage 的 `niter` 配置才走该分支 | runner.rs:417-421 | | P2 | 漏洞 5 | 补齐 SOLVES / RYBSOL 发散路径的归因,失败 note 区分求解器类型 | runner.rs:429 | | P3 | 漏洞 3 | 生产编译加`-fcheck=bounds -ffpe-trap=invalid,zero,overflow`,让数组越界与 IEEE 异常返回 rc≠0(性能换正确性) | deployment.md 编译命令 | --- ## 7. 参考资料 - 源码: - `tlusty/tlusty208.f`(主程序 :1-62;发散 STOP :14703/:15050/:47585;quit :29946-29955; PRCHAN :22876-22944;RYBCHN :47445-47598;RESOLV :3724-3903;OUTPUT :14055 起) - `synspec/synspec54.f`(主程序 :1-174;OUTPRI :3343-3458;quit :12014-12021; 脏数据风险点 :4203/:8208/:9935/:18863/:18865/:19891) - 判定逻辑: - `crates/common/src/runner.rs`(run_model_with_timeout :232-682;漏洞 1/2/5/6 所在) - `crates/node/src/reporter.rs`(derive_report_status :38-45;infer_failed_stage :15-27) - `crates/common/src/conv_check.rs`(check_fort9 :54-175;atmosphere_has_nan :190-213) - 编译配置:`docs/deployment.md:86,90`(确认生产编译无 `-fcheck`/`-ffpe-trap`) - 相关设计:`docs/task_engine_decoupling_design.md`(阶段独立配置、半失败判定语义)