refactor(watchdog): оптимизация и устранение дублирования кода

- watchdog: удалён дублирующий check_tachyon_cli_running(), везде используется controller.check_tachyon_cli_running()
- watchdog: smart_detect_process_pending использует controller.proxy_port() вместо повторного парсинга config.json
- watchdog: удалена однострочная обёртка is_dns_dead() — заменена is_dns_working() напрямую
- watchdog: heal_proxy_connectivity требует streak >= 2 перед очисткой cache.db — устраняет ложные ночные уведомления при кратких просадках прокси-ноды
- event_controller: push_history переведён на slice() вместо ручного цикла
- event_controller: direct_ok использует тот же check_url что и proxy check — корректность при кастомном ai_proxy_health_url
- event_controller: добавлен subscription_fail_streak — одиночный таймаут сети больше не триггерит Telegram
- telegram: tg_request больше не создаёт временные файлы в /tmp — JSON передаётся напрямую через curl -d, устранена утечка tg_payload_*.json при kill процесса
- telegram: worker() при старте удаляет оставшиеся tg_payload_*.json от предыдущих сессий
- tests: исправлен шаблон mktemp в background_spawn_fds.sh — busybox не поддерживает двойное расширение (.XXXXXX.uc)
This commit is contained in:
Dushnilin
2026-08-11 08:47:47 +03:00
parent c88acdffa5
commit 7ad428ddc8
6 changed files with 60 additions and 77 deletions
@@ -358,7 +358,8 @@ function controller(bus, opts) {
syslog_start_time: 0,
logread_pipe_fd: -1,
last_oom_time: 0,
healthy_streak: 0
healthy_streak: 0,
subscription_fail_streak: 0
};
let self = { EV: EV, state: state };
@@ -368,10 +369,7 @@ function controller(bus, opts) {
function push_history(history, entry) {
push(history, entry);
if (length(history) > 20) {
let trimmed = [];
for (let i = length(history) - 20; i < length(history); i++)
push(trimmed, history[i]);
return trimmed;
return slice(history, length(history) - 20);
}
return history;
}
@@ -525,9 +523,12 @@ function controller(bus, opts) {
// Distinguish "proxy is broken" from "the whole uplink is down": only
// the former is worth restarting sing-box over.
// Use the same URL as the proxy check so the two measurements are
// comparable: if the user changed ai_proxy_health_url, direct_ok
// must probe the same target, not always fall back to Cloudflare.
let direct_ok = command_success_from_args([
"curl", "-s", "-I", "--connect-timeout", "3", "--max-time", "5",
"https://cp.cloudflare.com/generate_204"
check_url
]);
bus.emit(EV.PROXY_DOWN, {
@@ -765,9 +766,16 @@ function controller(bus, opts) {
let res = command_capture(
"curl -s -o /dev/null -w %{http_code} --connect-timeout 10 " + shell_quote(sub_url) + " 2>&1");
let code = int(res.output);
if (res.status == 0 && code >= 200 && code < 400) return;
if (res.status == 0 && code >= 200 && code < 400) {
state.subscription_fail_streak = 0;
return;
}
// Two consecutive failures before alerting: a single network hiccup
// (provider momentarily unreachable) must not trigger a Telegram message.
state.subscription_fail_streak++;
if (state.subscription_fail_streak < 2) return;
bus.emit(EV.SUBSCRIPTION_UNREACHABLE, { url: sub_url, code: code });
bus.emit(EV.SUBSCRIPTION_UNREACHABLE, { url: sub_url, code: code, streak: state.subscription_fail_streak });
}
// ── Probe: UCI config integrity ───────────────────────────────────────────
+24 -16
View File
@@ -132,22 +132,18 @@ function get_proxy_args() {
function tg_request(token, method, payload) {
if (!token) return null;
let url = "https://api.telegram.org/bot" + token + "/" + method;
let payload_path = "/tmp/tg_payload_" + method + "_" + time() + "_" + clock()[1] + ".json";
let res = null;
try {
fs.writefile(payload_path, sprintf("%J", payload));
let args = [ "curl", "-s", "-m", "35", "--connect-timeout", "10", "-X", "POST", "-H", "Content-Type: application/json", "-d", "@" + payload_path ];
let proxy = get_proxy_args();
for (let p in proxy) push(args, p);
push(args, url);
res = command_capture(command_from_args(args));
} catch (e) {
try { fs.unlink(payload_path); } catch(err) {}
return null;
}
try { fs.unlink(payload_path); } catch(err) {}
// Pass the JSON body directly to curl's -d argument so there is no temp
// file to leak: the old /tmp/tg_payload_*.json files were left behind
// whenever the process was killed mid-request (e.g. during the 20-second
// getUpdates long-poll on reboot or watchdog restart).
let body = sprintf("%J", payload);
let args = [ "curl", "-s", "-m", "35", "--connect-timeout", "10",
"-X", "POST", "-H", "Content-Type: application/json",
"-d", body ];
let proxy = get_proxy_args();
for (let p in proxy) push(args, p);
push(args, url);
let res = command_capture(command_from_args(args));
if (!res || res.status != 0 || res.output == "") return null;
try { return json(res.output); } catch (e) { return null; }
}
@@ -2645,6 +2641,18 @@ function worker() {
];
tg_request(cfg.bot_token, "setMyCommands", { commands: commands });
// Clean up leftover payload temp-files from previous runs that were
// interrupted (e.g. killed during a 20-second getUpdates long-poll).
let tmp = fs.opendir("/tmp");
if (tmp) {
let entry;
while ((entry = tmp.read()) != null) {
if (index(entry, "tg_payload_") == 0 && substr(entry, -5) == ".json")
try { fs.unlink("/tmp/" + entry); } catch(e) {}
}
tmp.close();
}
let poll_interval = int(cfg.poll_interval || "5");
if (poll_interval < 1) poll_interval = 1;
+12 -46
View File
@@ -381,26 +381,6 @@ function dns_consecutive_fails() { return controller.dns_consecutive_fails(); }
function proxy_latency_history() { return controller.proxy_latency_history(); }
function dns_latency_history() { return controller.dns_latency_history(); }
function check_tachyon_cli_running() {
let running = false;
let proc = fs.opendir("/proc");
if (proc) {
let entry;
while ((entry = proc.read()) != null) {
if (match(entry, /^[0-9]+$/)) {
let cmdline = fs.readfile("/proc/" + entry + "/cmdline") || "";
if (index(cmdline, "/usr/bin/tachyon") >= 0) {
if (index(cmdline, "start") >= 0 || index(cmdline, "restart") >= 0 || index(cmdline, "reload") >= 0 || index(cmdline, "stop") >= 0) {
running = true;
break;
}
}
}
}
proc.close();
}
return running;
}
// ─── Root-cause suppression ───────────────────────────────────────────────────
// A dead WAN takes the proxy, DNS and the health check down with it. Each of
@@ -535,7 +515,7 @@ function heal_singbox_stopped(ev) {
let cfg = settings();
if (cfg.recovery_bypass == "1") return;
if (check_auto_resume_pause()) return;
if (check_tachyon_cli_running()) return;
if (controller.check_tachyon_cli_running()) return;
let list_update_pid = trim(fs.readfile("/var/run/tachyon_list_update.pid") || "");
if (process_running(list_update_pid, "ucode")) return;
@@ -678,7 +658,7 @@ function note_dns_recovered(ev) {
// Guard: skip if a tachyon reload is already in progress (prevents concurrent reload_firewall races)
function is_reload_in_progress() {
return fs.stat("/var/run/tachyon.reload.lock") != null
|| check_tachyon_cli_running();
|| controller.check_tachyon_cli_running();
}
// `force_level` pins the rung instead of consulting the ladder. Only
@@ -808,6 +788,11 @@ function heal_dns_stall(ev) {
function heal_proxy_connectivity(ev) {
if (settings().recovery_bypass == "1") return;
if (!ev.payload.direct_ok) return;
// Two consecutive failures before acting: a single brief proxy-node timeout
// (e.g. URLTest selecting a slow node for 5–10s) must not wipe cache.db and
// restart sing-box. The heal_proxy_health subscriber handles single-failure
// recovery via the threshold it already carries.
if (int(ev.payload.streak) < 2) return;
if (suppressed_by_root_cause("heal_proxy_connectivity", PRIORITY_PROXY)) return;
let proxy_addr = "127.0.0.1:" + as_string(ev.payload.port);
@@ -1065,9 +1050,6 @@ function heal_empty_sections(ev) {
let DNS_RECOVERY_STATE_FILE = "/var/run/tachyon/dns-detour-recovery.json";
let dns_recovery_active = false;
function is_dns_dead() {
return !is_dns_working();
}
function read_dns_recovery_state() {
let data = common.read_json_file(DNS_RECOVERY_STATE_FILE);
@@ -1099,8 +1081,8 @@ function ai_heal_dns_loop() {
let detour_section = common.option(cfg, "dns_detour_section", "");
if (detour_section == "") return;
// Check if DNS is dead
if (!is_dns_dead()) {
// Check if DNS is working
if (is_dns_working()) {
// DNS works — if we were in recovery, try to restore
let recovery = read_dns_recovery_state();
if (recovery.phase == "detour_disabled") {
@@ -1344,25 +1326,9 @@ function smart_detect_process_pending() {
let sections = smart_detect_get_proxy_sections();
if (length(sections) == 0) return;
let proxy_addr = "127.0.0.1:4534";
let sb_cfg_data = fs.readfile("/etc/sing-box/config.json");
if (sb_cfg_data) {
try {
let sb_cfg = json(sb_cfg_data);
if (sb_cfg.inbounds) {
for (let inb in sb_cfg.inbounds) {
if (inb.type == "http" || inb.type == "mixed") {
proxy_addr = "127.0.0.1:" + as_string(inb.listen_port || 4534);
break;
}
}
}
}
catch (e) {
// Falls back to the default 4534 set above. The controller logs the
// unparseable config once per process, so this does not repeat it.
}
}
// Reuse the controller's cached port value: re-parsing config.json here
// would duplicate the work event_controller already does (and caches).
let proxy_addr = "127.0.0.1:" + controller.proxy_port();
let detect_sections = [];
let raw_list = cfg.smart_detect_sections;