feat(all): 科学计算正确性修复、调度竞态消除、节点优雅退出、安全加固与收敛分析重构
科学计算正确性: - 修复 Fortran 无-E 科学记数法(指数≥100 时 E 被挤掉,如 -1.35+118)导致 发散行被静默跳过、误判收敛的 bug;扩展大气无效检测覆盖 Inf 与 *** 溢出标记 - 种子匹配改为 CNO 有向距离(富金属方向重罚 4×、贫金属方向轻罚 1×), 基于 1191 个真实种子配对回测标定,回测净改善 314 个点 - GridAxisValue 反序列化拒绝非法文本(不再静默 NaN);chmax≤0 显式报错 - ions 行宽列宽对齐真实 fort.5 格式 调度与队列竞态: - 原子选点(IMMEDIATE 事务 SELECT+UPDATE)消除并发调度重复派发 (#5) - 调度互斥锁 + 冷启动优先策略(SeedStep 仅作失败后救援,不再正常路径热启动) - 毒消息 dead_letter 标记防出队死循环;clear_queue 保留 claimed 行 (#6) - 孤儿 running 点回收兜底;stale_sec 默认 7800→21600s(3 倍超时缓冲) 节点生命周期: - SIGTERM+SIGINT 双信号监听(修复 Docker stop 发 SIGTERM 不触发优雅退出) - SlotGuard RAII 防活动 slot 泄漏;子进程超时增加二级 30s wait 防 Fortran hang - SeedStep 种子下载 fail-fast + 沙盒私有副本解耦 LRU 清理竞争 - reqwest Client 增加连接/请求超时;启动清理残留 task_* 沙盒 安全加固: - 节点注册 registration_secret 二次凭据 (H8),恒定时间比对防时序旁路 - token 缓存 generation 机制消除 reissue 后旧 token TOCTOU 复活窗口 - 新增 /api/auth/logout 服务端 session 即时撤销;fail-closed 鉴权启动策略 - 前端 token 迁移 sessionStorage;YAML 高亮改 DOM API 消除 XSS 注入面 - 备份文件权限收紧 0600;点表动态值全面 escapeHtml 前端 Dashboard: - 收敛性分析从热力图重构为 Parallel Sets 平行集合图(6 维+状态轴,手写 SVG 零依赖) - 进度曲线横轴改为真实时间(服务端 now 锚定,停滞期诚实留白);轮询指数退避 - 移除 imported 收敛途径分类,导入点按实际途径 cold_run/seed_step 归类 - 初始化时 /api/auth/check 校验 token;401 toast 提示替代静默 reload 服务端恢复与工具链: - 启动恢复 initializing 态工作流;默认工作流 INSERT-only 不覆盖 API 编辑 - body limit 分层(10MB 不再截断 256MB report);multipart 显式错误处理 - 嵌入二进制原子写(tmp+rename)防半写损坏 - import_results 判定收敛途径透传 success_method;conv.json 格式对齐本项目 - push_import_results.sh 退出码修复 + .bat UTF-8 BOM + scp 上传 - Docker USE_MIRRORS 默认关闭;移除无用 assets 挂载;删除 hosts.ini 入库
This commit is contained in:
+84
-42
@@ -5,7 +5,7 @@ use common::models::{ModelSummary, TaskSpec, TaskType};
|
||||
use common::runner::ExecutionRunner;
|
||||
use reqwest::Client;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
pub async fn execute_task(
|
||||
client: &Client,
|
||||
@@ -13,6 +13,7 @@ pub async fn execute_task(
|
||||
runtime: &RuntimePaths,
|
||||
work_dir: &Path,
|
||||
task: &TaskSpec,
|
||||
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
) -> Result<(ModelSummary, Option<Vec<u8>>)> {
|
||||
info!(
|
||||
"开始执行计算任务 {} (网格点: {})",
|
||||
@@ -48,46 +49,80 @@ pub async fn execute_task(
|
||||
|
||||
let mut seed_atmos_path: Option<PathBuf> = None;
|
||||
|
||||
// 2. If seed_step, download seed .7 file from server using atomic file rename
|
||||
if task.task_type == TaskType::SeedStep {
|
||||
if let Some(ref seed_name) = task.seed_point_name {
|
||||
let seed_url = format!("{}/api/seed/{}", server_url, seed_name);
|
||||
info!("正在从服务端下载种子大气文件: {}", seed_url);
|
||||
|
||||
match client.get(&seed_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await {
|
||||
let temp_seed_dir = work_dir.join(".seed_cache");
|
||||
tokio::fs::create_dir_all(&temp_seed_dir).await?;
|
||||
// LRU 上限清理:下载新种子前,删除最旧的超出 MAX_SEED_CACHE_FILES 的
|
||||
// .seed.7 文件,防止长期运行后不同种子点累积到 GB 级。同名种子会被
|
||||
// 覆盖写,真正累积的维度是「不同 seed_name」的数量。
|
||||
cleanup_seed_cache(&temp_seed_dir).await;
|
||||
let tmp_path = temp_seed_dir.join(format!(
|
||||
"{}.{}.tmp",
|
||||
seed_name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
let final_seed_path = temp_seed_dir.join(format!("{}.seed.7", seed_name));
|
||||
tokio::fs::write(&tmp_path, bytes).await?;
|
||||
tokio::fs::rename(&tmp_path, &final_seed_path).await?;
|
||||
seed_atmos_path = Some(final_seed_path);
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
warn!("下载种子文件失败: HTTP {}", resp.status());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("下载种子文件失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Isolated task sandbox directory per slot to prevent multi-slot race collisions
|
||||
// 提前创建 per-slot 隔离沙盒目录:种子下载后需复制一份私有副本进沙盒(见下方),
|
||||
// 故沙盒必须先于种子下载就绪。
|
||||
let slot_work_dir = work_dir.join(format!("task_{}", task.task_id));
|
||||
tokio::fs::create_dir_all(&slot_work_dir).await?;
|
||||
|
||||
// 2. If seed_step, download seed .7 file from server using atomic file rename.
|
||||
// 调度入口已改为「冷启动优先」,SeedStep 仅作为冷启动失败后的救援任务出现,服务端
|
||||
// 派发前已经 find_best_seed_from_db 确认种子存在于 DB。因此下载失败(缺种子名/HTTP
|
||||
// 错误/网络异常/响应体读取失败)按硬错误处理,直接失败该任务(fail-fast),不再无种
|
||||
// 子继续运行:default_seed_chain 首阶段 seed_nc 的 ltgray="F" 依赖 fort.8,无种子时
|
||||
// Tlusty 在无初始大气下运行必然崩溃。任务失败后由服务端走既有上报路径,
|
||||
// has_seed_step_attempt 阻止重复回退,网格点保持 failed 终态。
|
||||
if task.task_type == TaskType::SeedStep {
|
||||
let seed_name = task.seed_point_name.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"SeedStep 任务 {} 缺少 seed_point_name,无法热启动",
|
||||
task.point_name
|
||||
)
|
||||
})?;
|
||||
let seed_url = format!("{}/api/seed/{}", server_url, seed_name);
|
||||
info!("正在从服务端下载种子大气文件: {}", seed_url);
|
||||
|
||||
let resp = client.get(&seed_url).send().await.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"SeedStep 任务 {} 下载种子文件 {} 失败: {}",
|
||||
task.point_name,
|
||||
seed_url,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"SeedStep 任务 {} 下载种子文件 {} 失败: HTTP {}",
|
||||
task.point_name,
|
||||
seed_url,
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
let bytes = resp.bytes().await.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"SeedStep 任务 {} 读取种子文件 {} 响应体失败: {}",
|
||||
task.point_name,
|
||||
seed_url,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let temp_seed_dir = work_dir.join(".seed_cache");
|
||||
tokio::fs::create_dir_all(&temp_seed_dir).await?;
|
||||
// LRU 上限清理:下载新种子前,删除最旧的超出 MAX_SEED_CACHE_FILES 的
|
||||
// .seed.7 文件,防止长期运行后不同种子点累积到 GB 级。同名种子会被
|
||||
// 覆盖写,真正累积的维度是「不同 seed_name」的数量。
|
||||
cleanup_seed_cache(&temp_seed_dir).await;
|
||||
let tmp_path = temp_seed_dir.join(format!(
|
||||
"{}.{}.tmp",
|
||||
seed_name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
let final_seed_path = temp_seed_dir.join(format!("{}.seed.7", seed_name));
|
||||
tokio::fs::write(&tmp_path, bytes).await?;
|
||||
tokio::fs::rename(&tmp_path, &final_seed_path).await?;
|
||||
|
||||
// 关键:复制一份种子到本任务沙盒私有副本,让 seed_atmos_path
|
||||
// 指向私有副本而非共享缓存。此后 runner 的 current_seed 全程
|
||||
// 只引用沙盒内文件,与 .seed_cache 完全解耦——这样 LRU 清理
|
||||
// (含并发竞争)即便删掉该缓存文件,也不会破坏正在使用该种子
|
||||
// 的 in-flight 任务。.seed_cache 退化为纯粹的下载去重缓存。
|
||||
let private_seed = slot_work_dir.join("seed_atmos.seed.7");
|
||||
tokio::fs::copy(&final_seed_path, &private_seed).await?;
|
||||
seed_atmos_path = Some(private_seed);
|
||||
}
|
||||
|
||||
// 3. (slot_work_dir 已在种子下载前提前创建,种子私有副本亦已落盘于沙盒内。)
|
||||
|
||||
let runner = ExecutionRunner::new(runtime, slot_work_dir.clone());
|
||||
let summary = runner
|
||||
.run_model_with_timeout(
|
||||
@@ -96,10 +131,13 @@ pub async fn execute_task(
|
||||
// 而非 task.params.model_name()(后者经 DB REAL 列回读已丢精度 "5.0"→"5")。
|
||||
&task.point_name,
|
||||
task.task_type.clone(),
|
||||
// custom_chain 恒为 None:执行链由 task_type 决定(ColdRun→default_cold_chain、
|
||||
// SeedStep→default_seed_chain),节点端不再做「缺种子回退冷启动链」的降级。
|
||||
None,
|
||||
seed_atmos_path.as_deref(),
|
||||
None,
|
||||
task.timeout_sec,
|
||||
shutdown,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -368,10 +406,14 @@ pub async fn cleanup_seed_cache(seed_dir: &Path) {
|
||||
entries.sort_by_key(|(mtime, _)| *mtime);
|
||||
let to_remove = entries.len().saturating_sub(MAX_SEED_CACHE_FILES);
|
||||
for (_, path) in entries.into_iter().take(to_remove) {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
warn!("清理种子缓存文件 {} 失败: {}", path.display(), e);
|
||||
} else {
|
||||
info!("LRU 清理种子缓存文件: {}", path.display());
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(()) => info!("LRU 清理种子缓存文件: {}", path.display()),
|
||||
// 文件已被并发删除(多 slot 同时清理同一批最旧文件):清理目标已达成,
|
||||
// 视为成功,不再误报 warn。其他真实 IO 错误才需要告警。
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
debug!("种子缓存文件已被并发删除: {}", path.display())
|
||||
}
|
||||
Err(e) => warn!("清理种子缓存文件 {} 失败: {}", path.display(), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-2
@@ -33,16 +33,31 @@ async fn main() -> Result<()> {
|
||||
None => {
|
||||
info!("本地未发现 node token,准备向服务端提交注册申请并等待管理员审批...");
|
||||
let public_client = build_client_with_token(None);
|
||||
let issued = NodeWorker::register_and_fetch_token(
|
||||
// 读取上次注册持久化的 registration_secret(若存在)。
|
||||
// 场景:节点曾注册获批、本地 .node_token 因 reissue 失效被删除后重启。
|
||||
// 重新注册时服务端对"已存在节点"不再下发新 secret,但本地旧 secret 仍有效,
|
||||
// 复用它才能在 check_status 取回 reissue 产生的新 token(否则取不回,H8)。
|
||||
let secret_path = runtime_dir.join(".node_secret");
|
||||
let existing_secret = read_node_token(&secret_path);
|
||||
let (issued, reg_secret) = NodeWorker::register_and_fetch_token(
|
||||
&public_client,
|
||||
&node_cfg.server_url,
|
||||
&node_cfg.node_id,
|
||||
existing_secret.clone(),
|
||||
)
|
||||
.await
|
||||
.context("向服务端提交申请或获取专属 token 失败")?;
|
||||
|
||||
write_node_token(&token_path, &issued)?;
|
||||
info!("已持久化获批的专属 node token 到 {}", token_path.display());
|
||||
// 持久化 registration_secret(H8):服务端对新节点会下发一次性 secret,
|
||||
// 对已存在节点不下发(复用本地旧 secret)。无论新旧,落盘以便下次重启复用。
|
||||
let secret_to_persist = reg_secret.or(existing_secret);
|
||||
if let Some(secret) = &secret_to_persist {
|
||||
if let Err(e) = write_node_token(&secret_path, secret) {
|
||||
warn!("持久化 registration_secret 失败(不影响本次启动): {}", e);
|
||||
}
|
||||
}
|
||||
issued
|
||||
}
|
||||
};
|
||||
@@ -91,8 +106,17 @@ fn write_node_token(path: &Path, token: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
/// 构造带 Authorization: Bearer 头的 reqwest client。
|
||||
///
|
||||
/// 设置连接与请求超时,避免心跳/领用/种子下载/上报在网络层挂起时无限等待。
|
||||
/// 旧实现无任何超时:一旦连接 stall,心跳永远不发,节点被误判离线;
|
||||
/// 上报挂起则活动 slot 永不归还。
|
||||
fn build_client_with_token(token: Option<&str>) -> Client {
|
||||
let mut builder = Client::builder();
|
||||
let mut builder = Client::builder()
|
||||
// 连接建立阶段(TCP 握手 + TLS)超时,防止对端不可达时长时间挂起。
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
// 单个请求整体超时。种子下载(.7 大气文件)可能较大,故给到 10 分钟;
|
||||
// 心跳/领用/上报这类小请求会远早于此完成。
|
||||
.timeout(std::time::Duration::from_secs(600));
|
||||
if let Some(t) = token {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", t)) {
|
||||
|
||||
+129
-14
@@ -12,6 +12,26 @@ use std::sync::Arc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// 活动 slot 计数的 RAII 守卫:仅负责 drop 时 -1。
|
||||
///
|
||||
/// **自增的时机**:领用主循环在 `tokio::spawn` 之前、紧跟容量检查之后同步调用
|
||||
/// `active_slots.fetch_add(1, ...)`,与「检查是否还有空闲容量」紧邻成原子操作,
|
||||
/// 杜绝「检查通过 → spawn 排队 → 再回循环检查时计数尚未自增」的竞态窗口
|
||||
/// (曾导致满载时瞬时 `active_slots` 冲到 `max_slots + 1`,前端显示「预设 + 1」)。
|
||||
///
|
||||
/// **归还的可靠性**:自增后立即构造本守卫并移入 spawned future,即便 future 内
|
||||
/// 任意代码 panic(execute_task / save_result_artifacts / 归档清理),Drop 也会执行,
|
||||
/// 确保计数始终归还,不会像「future 末尾手动 fetch_sub」那样泄漏到死锁。
|
||||
struct SlotGuard {
|
||||
counter: Arc<AtomicI32>,
|
||||
}
|
||||
|
||||
impl Drop for SlotGuard {
|
||||
fn drop(&mut self) {
|
||||
self.counter.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// 领用请求的归一化结果。
|
||||
///
|
||||
/// 区分「被管理员停用」与「暂无任务」:前者节点保持存活、空闲待命(拉长轮询),
|
||||
@@ -50,11 +70,19 @@ impl NodeWorker {
|
||||
|
||||
/// 仅注册并领取专属 token(供 main.rs 在本地无 token 时调用)。
|
||||
/// 支持免凭据申请注册并轮询等待管理员在 Web Dashboard 上点击同意。
|
||||
///
|
||||
/// `existing_secret`:本地持久化的旧 registration_secret(若有)。重新注册(节点已存在)
|
||||
/// 时服务端不下发新 secret,此时复用旧 secret 才能在 check_status 取回 reissue 产生的新
|
||||
/// token(H8:防止已存在节点 reissue 后取不回 token)。
|
||||
///
|
||||
/// 返回 (node_token, registration_secret):registration_secret 是注册时服务端下发的一次性
|
||||
/// 凭据(新节点)或复用的旧 secret(已存在节点),取走待发 token 时须回传。
|
||||
pub async fn register_and_fetch_token(
|
||||
client: &Client,
|
||||
server_url: &str,
|
||||
node_id: &str,
|
||||
) -> Result<String> {
|
||||
existing_secret: Option<String>,
|
||||
) -> Result<(String, Option<String>)> {
|
||||
info!(
|
||||
"正在向服务端 {} 提交计算节点 {} 的注册申请...",
|
||||
server_url, node_id
|
||||
@@ -77,10 +105,16 @@ impl NodeWorker {
|
||||
|
||||
let json: Value = resp.json().await?;
|
||||
let status = json.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let registration_secret = json
|
||||
.get("registration_secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
// 已存在节点重新注册:服务端不下发新 secret,复用本地旧 secret(H8)。
|
||||
.or_else(|| existing_secret.clone());
|
||||
|
||||
if status == "approved" {
|
||||
if let Some(t) = json.get("node_token").and_then(|v| v.as_str()) {
|
||||
return Ok(t.to_string());
|
||||
return Ok((t.to_string(), registration_secret));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +127,10 @@ impl NodeWorker {
|
||||
loop {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let check_req = serde_json::json!({ "node_id": node_id });
|
||||
let check_req = serde_json::json!({
|
||||
"node_id": node_id,
|
||||
"registration_secret": registration_secret,
|
||||
});
|
||||
let resp = match client
|
||||
.post(format!("{}/api/node/check_status", server_url))
|
||||
.json(&check_req)
|
||||
@@ -123,7 +160,7 @@ impl NodeWorker {
|
||||
"🎉 节点 {} 已成功获取管理员授权!专属访问 Token 接收完成。",
|
||||
node_id
|
||||
);
|
||||
return Ok(token.to_string());
|
||||
return Ok((token.to_string(), registration_secret));
|
||||
}
|
||||
} else if check_status == "rejected" {
|
||||
anyhow::bail!("节点 {} 的注册申请已被管理员拒绝或清理", node_id);
|
||||
@@ -253,20 +290,80 @@ impl NodeWorker {
|
||||
tokio::fs::create_dir_all(&work_dir).await?;
|
||||
let result_dir = PathBuf::from(&self.config.result_dir);
|
||||
|
||||
// 启动时清理上次崩溃/强杀遗留的 task_* 沙盒目录。
|
||||
// 背景:节点非正常退出(SIGKILL / 断电)时,正在跑的任务沙盒不会清理,
|
||||
// 每次重启都留下一份完整的 TLUSTY 工作目录(含 data 软链 + fort 文件,
|
||||
// 单任务可达数十~上百 MB)。.seed_cache 和 result_dir 都有 LRU 治理,
|
||||
// 唯独 work_dir 无任何清理,长期累积会写满磁盘导致新任务失败。
|
||||
// 此时节点刚启动、无任何活动任务,删除 task_* 子目录是安全的。
|
||||
if let Ok(mut rd) = tokio::fs::read_dir(&work_dir).await {
|
||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with("task_") {
|
||||
let p = entry.path();
|
||||
match tokio::fs::remove_dir_all(&p).await {
|
||||
Ok(_) => warn!("启动清理上次残留沙盒: {}", p.display()),
|
||||
Err(e) => warn!("启动清理残留沙盒 {} 失败: {}", p.display(), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let shutting_down = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let shutdown_signal = shutting_down.clone();
|
||||
|
||||
// 信号处理:同时监听 SIGINT(Ctrl+C)与 SIGTERM。
|
||||
// 生产编排(Docker stop / k8s pod 终止 / systemd stop / kill <pid>)发送的是
|
||||
// **SIGTERM** 而非 SIGINT。历史上只监听 ctrl_c() 会导致收到 SIGTERM 时信号处理器
|
||||
// 完全不触发 → shutting_down 永远 false → 主循环继续领任务 → grace period 后被
|
||||
// SIGKILL 强杀 → 正在跑的长任务结果直接丢失。
|
||||
// 此 shutdown 标志同时传给 runner,让在途 Fortran 子进程在收到信号后被立即 kill,
|
||||
// 避免等满 30s 优雅期仍超时强退(C5)。在途任务结果会丢失,由服务端 stale 重投兜底。
|
||||
tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
info!("收到 Ctrl+C 终止信号,停止领用新任务,准备优雅退出 (再次按 Ctrl+C 可强制立即退出)...");
|
||||
shutdown_signal.store(true, Ordering::Release);
|
||||
|
||||
// 二次 Ctrl+C 强行立即退出
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
warn!("再次收到 Ctrl+C 终止信号,强行立即中断退出!");
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
let mut sigterm = match signal(SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("无法注册 SIGTERM 监听: {}(将仅响应 SIGINT)", e);
|
||||
// 退化为只监听 SIGINT:保留"首次信号优雅退出 + 二次信号强退"完整能力。
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
info!("收到 Ctrl+C 终止信号,停止领用新任务,准备优雅退出 (再次按 Ctrl+C 可强制立即退出)...");
|
||||
shutdown_signal.store(true, Ordering::Release);
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
warn!("再次收到 Ctrl+C,强行立即中断退出!");
|
||||
std::process::exit(130);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut sigint = match signal(SignalKind::interrupt()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
warn!("无法注册 SIGINT 监听,仅响应 SIGTERM");
|
||||
sigterm.recv().await;
|
||||
info!("收到 SIGTERM 终止信号,停止领用新任务,准备优雅退出 (再次发送 SIGTERM 可强制立即退出)...");
|
||||
shutdown_signal.store(true, Ordering::Release);
|
||||
sigterm.recv().await;
|
||||
warn!("再次收到 SIGTERM,强行立即中断退出!");
|
||||
std::process::exit(130);
|
||||
}
|
||||
};
|
||||
|
||||
// 第一次信号:触发优雅退出(停止领用 + kill 在途 child)
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => info!("收到 SIGTERM 终止信号,停止领用新任务,准备优雅退出 (再次发送 SIGTERM/SIGINT 可强制立即退出)..."),
|
||||
_ = sigint.recv() => info!("收到 Ctrl+C (SIGINT) 终止信号,停止领用新任务,准备优雅退出 (再次按 Ctrl+C 可强制立即退出)..."),
|
||||
}
|
||||
shutdown_signal.store(true, Ordering::Release);
|
||||
|
||||
// 第二次信号:强行立即退出
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => warn!("再次收到 SIGTERM,强行立即中断退出!"),
|
||||
_ = sigint.recv() => warn!("再次收到 Ctrl+C,强行立即中断退出!"),
|
||||
}
|
||||
std::process::exit(130);
|
||||
});
|
||||
|
||||
let mut was_disconnected = false;
|
||||
@@ -285,7 +382,6 @@ impl NodeWorker {
|
||||
info!("与服务端恢复网络连接,已自动重新上线并开始领用计算任务!");
|
||||
was_disconnected = false;
|
||||
}
|
||||
self.active_slots.fetch_add(1, Ordering::AcqRel);
|
||||
let client = self.client.clone();
|
||||
let server_url = self.config.server_url.clone();
|
||||
let node_id = self.config.node_id.clone();
|
||||
@@ -293,11 +389,22 @@ impl NodeWorker {
|
||||
let work_dir = work_dir.clone();
|
||||
let result_dir = result_dir.clone();
|
||||
let slots_counter = self.active_slots.clone();
|
||||
let shutdown = shutting_down.clone();
|
||||
|
||||
// 在 spawn 前同步自增,与容量检查紧邻成原子操作:避免
|
||||
// 「检查通过 → spawn 排队 → 回循环再检查时计数尚未自增」的竞态
|
||||
// 导致瞬时 active_slots 冲到 max_slots + 1。
|
||||
slots_counter.fetch_add(1, Ordering::AcqRel);
|
||||
// RAII 守卫仅负责 drop 时 -1(含 panic 展栈),杜绝 spawned future
|
||||
// panic 导致的活动 slot 永久泄漏。
|
||||
let slot_guard = SlotGuard { counter: slots_counter.clone() };
|
||||
|
||||
tokio::spawn(async move {
|
||||
// 把守卫移入 future,确保任务结束(含 panic)时归还 slot。
|
||||
let _slot_guard = slot_guard;
|
||||
let slot_work_dir = work_dir.join(format!("task_{}", task.task_id));
|
||||
let res =
|
||||
execute_task(&client, &server_url, &runtime, &work_dir, &task)
|
||||
execute_task(&client, &server_url, &runtime, &work_dir, &task, Some(shutdown.clone()))
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
|
||||
@@ -353,7 +460,7 @@ impl NodeWorker {
|
||||
);
|
||||
}
|
||||
}
|
||||
slots_counter.fetch_sub(1, Ordering::AcqRel);
|
||||
// _slot_guard 在此作用域结束时 drop,归还活动 slot 计数。
|
||||
});
|
||||
}
|
||||
Ok(ClaimOutcome::Empty) => {
|
||||
@@ -432,7 +539,15 @@ impl NodeWorker {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if status.is_server_error() {
|
||||
// 服务端 5xx(DB 故障 / 内部异常)≠「暂无任务」。归一化为 Empty 会让节点静默
|
||||
// 空转且日志看不出"服务端故障",队列里有任务却不出。走 Err 触发 was_disconnected
|
||||
// 退避路径,日志里能看到"请求领用任务出错",便于运维定位。
|
||||
anyhow::bail!("服务端临时故障 (HTTP {})", status);
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
// 其余 4xx(如 429 限流)按 Empty 处理,下次轮询重试。
|
||||
return Ok(ClaimOutcome::Empty);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user