fix(errors): перестать глотать сбои записи состояния в пустых catch

Пустых catch в кодовой базе около полусотни, и они не одинаковые. Большинство
выражает намерение: fs.unlink бросает на ENOENT, а вызывающий говорит «пусть
этого файла не будет», и отсутствующий файл это условие уже выполняет; разбор
json в цикле пропускает мусорную строку и берёт следующую. Такие места теперь
несут пояснение, почему пустое тело здесь и есть правильный ответ.

Семь мест к этому классу не относились: там сбой означал потерю состояния, а
дальше код шёл так, будто всё получилось.

Watchdog писал через пустой catch файл лока перезапуска прокси, шкалу
GOMEMLIMIT после OOM, бэкап конфига и метку keepalive. Не лёгший лок означает,
что следующий тик перезапустит прокси заново, а счётчик rate-limit об этом не
узнает; не записанная метка keepalive заставляет init.d считать watchdog мёртвым
и перезапускать его. Все четыре теперь идут через write_state_file, который
логирует отказ. Метка пишется на каждом тике uloop, поэтому безусловная строка
залила бы syslog именно при переполненной файловой системе — там, где лог нужнее
всего; вместо этого каждый путь сообщает о первом отказе и молчит до следующего
успеха, что заодно делает видимым восстановление.

Контроллер разбирал /etc/sing-box/config.json ради портов http и tproxy и при
неудаче молча брал 4534 и 4530. Watchdog после этого опрашивает не тот порт,
вечно считает прокси нерабочим и лечит его по кругу — при том, что в логе нет
ничего, что связало бы это с конфигом. Разбор вынесен в общую функцию, которая
сообщает о непарсящемся конфиге один раз на процесс.

Telegram молча терял карту callback-токенов: токен уже вшит в клавиатуру к
моменту записи, поэтому провал отдаёт пользователю кнопку, которая на нажатие
ответит «кнопка устарела». Обновление списков молча теряло маркер отложенного
применения nft — списки обновлены на диске и до nftables не доехали никогда,
а строкой выше в лог написано, что маркер сохраняется. urltest молча выкидывал
битое регулярное выражение из конфига пользователя, отчего группа тихо
подхватывала меньше outbound-ов, чем сказано в конфиге.

tests/heal_escalation.sh проверял порядок строк по литералу fs.writefile для
лока и обновлён под новый вызов.
This commit is contained in:
Dushnilin
2026-08-08 22:32:09 +03:00
parent feec8c6d35
commit e6e3853f05
24 changed files with 320 additions and 50 deletions
@@ -194,6 +194,36 @@ function is_reload_in_progress() {
return fs.stat("/var/run/tachyon.reload.lock") != null || check_tachyon_cli_running();
}
// Reads a port out of the generated sing-box config. Both callers fall back to
// a compiled-in default when the file cannot be parsed, and that fallback is
// worth a log line: a watchdog probing 4534 while sing-box listens elsewhere
// reports the proxy as down forever and heals it in a loop. The failure used to
// be swallowed by an empty catch, so the symptom was a permanently unhealthy
// proxy with nothing in the log to connect it to the config.
//
// Logged once per process — these are called on every tick, and a config that
// fails to parse fails to parse every time.
let sb_config_parse_reported = false;
function parse_singbox_config() {
let data = fs.readfile("/etc/sing-box/config.json");
if (data == null)
return null;
try {
return json(data);
}
catch (e) {
if (!sb_config_parse_reported) {
sb_config_parse_reported = true;
command_success_from_args([ "logger", "-t", "tachyon",
"[err] Watchdog: /etc/sing-box/config.json is unparseable (" + as_string(e) +
"); falling back to default inbound ports, proxy checks may probe the wrong port" ]);
}
return null;
}
}
// Reads the http/mixed inbound port from the generated sing-box config.
// Cached because the config only changes across a reload.
let cached_proxy_port = null;
@@ -202,19 +232,14 @@ function proxy_port() {
return as_string(cached_proxy_port);
let port = "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") {
port = as_string(inb.listen_port || 4534);
break;
}
}
let sb_cfg = parse_singbox_config();
if (sb_cfg && sb_cfg.inbounds) {
for (let inb in sb_cfg.inbounds) {
if (inb.type == "http" || inb.type == "mixed") {
port = as_string(inb.listen_port || 4534);
break;
}
} catch(e) {}
}
}
cached_proxy_port = port;
return port;
@@ -222,23 +247,19 @@ function proxy_port() {
function forget_proxy_port() {
cached_proxy_port = null;
sb_config_parse_reported = false;
}
function tproxy_port() {
let port = 4530;
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 == "tproxy") {
port = int(inb.listen_port || port);
break;
}
}
let sb_cfg = parse_singbox_config();
if (sb_cfg && sb_cfg.inbounds) {
for (let inb in sb_cfg.inbounds) {
if (inb.type == "tproxy") {
port = int(inb.listen_port || port);
break;
}
} catch(e) {}
}
}
return port;
}
@@ -816,6 +837,7 @@ function controller(bus, opts) {
if (scale_data == null) return;
let current_scale = double(trim(as_string(scale_data)));
if (current_scale >= 1.0) {
// Absent file already satisfies the caller; fs.unlink throws on ENOENT.
try { fs.unlink(scale_path); } catch (e) {}
return;
}