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:
@@ -104,6 +104,9 @@ function read_file(path) {
|
||||
return data == null ? "" : as_string(data);
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function remove_file(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
|
||||
@@ -63,7 +63,13 @@ function enabled_hosts_sections() {
|
||||
if (!bool_opt(section, "enabled", true)) return;
|
||||
push(result, section);
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// A UCI parse error leaves `result` holding whatever sections were
|
||||
// pushed before the throw. Returning the partial list is the same
|
||||
// behaviour as an empty config and keeps the caller on its no-hosts
|
||||
// path; the parse error itself is reported by uci to the system log.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,9 @@ function write_state_file(path, value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function remove_file(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
@@ -1682,6 +1685,9 @@ function output_json_object(path) {
|
||||
return value;
|
||||
}
|
||||
catch (e) {
|
||||
// A job state file written by a worker that died mid-write is not JSON
|
||||
// as a whole. The line scan below recovers the last complete object in
|
||||
// it, which is the point of the fallback.
|
||||
}
|
||||
|
||||
let result = null;
|
||||
@@ -1696,6 +1702,8 @@ function output_json_object(path) {
|
||||
result = value;
|
||||
}
|
||||
catch (e) {
|
||||
// A partial line is expected here - that is what made the whole-file
|
||||
// parse above fail. Skip it and keep the last complete one.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2703,7 +2711,19 @@ function list_update() {
|
||||
log_message("Waiting for reload lock to apply nft rules", "debug");
|
||||
if (!acquire_runtime_lock(RELOAD_LOCK_DIR, true)) {
|
||||
log_message("Could not acquire reload lock for nft apply; saving pending marker for next attempt", "warn");
|
||||
try { fs.writefile("/tmp/tachyon_list_update_pending_nft", as_string(now_seconds())); } catch(e) {}
|
||||
// Without this marker the deferred apply is lost for good — nothing else
|
||||
// records that the nft phase still owes work. An empty catch here meant
|
||||
// the lists were updated on disk and never reached nftables, with the
|
||||
// "saving pending marker" line above as the only (untrue) evidence.
|
||||
let marker_written = false;
|
||||
try {
|
||||
marker_written = fs.writefile("/tmp/tachyon_list_update_pending_nft", as_string(now_seconds())) != null;
|
||||
}
|
||||
catch (e) {
|
||||
log_message("Failed to save pending nft marker, this list update will not be re-applied: " + as_string(e), "err");
|
||||
}
|
||||
if (!marker_written)
|
||||
log_message("Failed to save pending nft marker, this list update will not be re-applied", "err");
|
||||
list_update_pid_end();
|
||||
exit(1);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,10 @@ function item_index_from_cursor(cursor, config_name) {
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
// An absent config or an unknown section type throws here. The
|
||||
// index simply ends up without entries of that type, which is the
|
||||
// same state as a config that declares none; uci reports the parse
|
||||
// error itself to the system log.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -812,6 +812,9 @@ function migrate_outbound_json_list(ctx, section, legacy_connection_kind) {
|
||||
taken[tag_name] = true;
|
||||
}
|
||||
catch (e) {
|
||||
// Collecting tags already in use so the new one does not
|
||||
// collide. An entry that is not valid JSON has no tag to
|
||||
// collide with, so skipping it is exactly right.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1254,6 +1257,8 @@ function migrate_http_connection_urls(ctx) {
|
||||
taken[tag_name] = true;
|
||||
}
|
||||
catch (e) {
|
||||
// Same as above: an unparseable entry owns no tag, so it
|
||||
// cannot collide with the one being generated.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1504,6 +1509,7 @@ function ensure_runtime_cache_format() {
|
||||
}
|
||||
|
||||
function remove_legacy_server_country_cache() {
|
||||
// Absent file already satisfies the caller; fs.unlink throws on ENOENT.
|
||||
try { fs.unlink(TACHYON_RUNTIME_STATE_DIR + "/server-country-cache.json"); } catch(e) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ function csv_to_json_array(value) {
|
||||
write_compact_string_array(value == "" ? [] : split(value, ","));
|
||||
}
|
||||
|
||||
// The two unlinks below clean up the temporary file after a failed write or
|
||||
// rename. Both failure paths already report to the caller through `false`, and
|
||||
// an unlink that throws means the temp file was never created — nothing left
|
||||
// to clean up.
|
||||
function write_json_file(path, value) {
|
||||
path = as_string(path);
|
||||
let stamp = clock();
|
||||
|
||||
@@ -28,7 +28,11 @@ function detect_installed_version() {
|
||||
ver = pkgs.version("luci-app-tachyon");
|
||||
if (ver != null && ver != "") return ver;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// core.packages is absent on a source checkout and opkg is absent in
|
||||
// the test image; both fall through to the .control files below.
|
||||
}
|
||||
|
||||
for (let path in [
|
||||
"/usr/lib/opkg/info/tachyon.control",
|
||||
|
||||
@@ -109,6 +109,10 @@ function command_capture(command) {
|
||||
return { status, output: data == null ? "" : as_string(data) };
|
||||
}
|
||||
|
||||
// The inner unlinks clean up the temp file after a failed write or rename;
|
||||
// both paths return false, so the failure is already reported. The outer catch
|
||||
// covers an unreadable /etc/tachyon or an unwritable backup directory, which
|
||||
// the caller treats as "no backup available".
|
||||
function uci_backup_save() {
|
||||
try {
|
||||
let data = fs.readfile(TACHYON_CONFIG);
|
||||
@@ -127,6 +131,8 @@ function uci_backup_save() {
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
// Same shape as uci_backup_save above: the inner unlinks clean up after a
|
||||
// failed write or rename, and both paths return false.
|
||||
function uci_backup_restore() {
|
||||
try {
|
||||
let data = fs.readfile(UCI_BACKUP_PATH);
|
||||
@@ -437,6 +443,9 @@ function ensure_dir(path) {
|
||||
return command_success_from_args([ "mkdir", "-p", as_string(path) ]);
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function remove_file(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
@@ -2282,6 +2291,8 @@ function run_doctor_checks() {
|
||||
|
||||
// 4c. Resolv.conf symlink
|
||||
let resolv_link = "";
|
||||
// Throws when /etc/resolv.conf is a regular file rather than a symlink,
|
||||
// which is precisely the broken state the branch below repairs.
|
||||
try { resolv_link = fs.readlink("/etc/resolv.conf") || ""; } catch(e) {}
|
||||
if (resolv_link == "/tmp/resolv.conf" || resolv_link == "../tmp/resolv.conf") {
|
||||
doc_check("✅", "resolv.conf symlink", "OK (-> " + resolv_link + ")", "");
|
||||
@@ -2292,7 +2303,11 @@ function run_doctor_checks() {
|
||||
try {
|
||||
fs.symlink("/tmp/resolv.conf", "/etc/resolv.conf");
|
||||
sym_ok = true;
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// sym_ok stays false and the failure is reported to the user through
|
||||
// doc_check() below, which is this module's output channel.
|
||||
}
|
||||
if (sym_ok) {
|
||||
doc_check("❌", "resolv.conf symlink", "broken", "→ FIXED: восстановлена ссылка на /tmp/resolv.conf");
|
||||
fixed++;
|
||||
@@ -2329,6 +2344,9 @@ function run_doctor_checks() {
|
||||
let ping_ok = (command_status("ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1") == 0);
|
||||
if (ping_ok) {
|
||||
fs.unlink("/etc/resolv.conf");
|
||||
// If the symlink cannot be created the writefile below still lands on
|
||||
// /tmp/resolv.conf and the dnsmasq restart still picks it up; the
|
||||
// resolve check that follows decides whether any of it worked.
|
||||
try { fs.symlink("/tmp/resolv.conf", "/etc/resolv.conf"); } catch(e) {}
|
||||
fs.writefile("/tmp/resolv.conf", "nameserver 1.1.1.1\nnameserver 8.8.8.8\n");
|
||||
command_status("/etc/init.d/dnsmasq restart >/dev/null 2>&1");
|
||||
|
||||
@@ -79,6 +79,9 @@ function file_executable(path) {
|
||||
return (int(stat.mode) & 73) != 0;
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function unlink_file(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
|
||||
@@ -291,6 +291,7 @@ function live_pid_count(path) {
|
||||
if (runtime_pid_running(pid))
|
||||
count++;
|
||||
else {
|
||||
// Absent file already satisfies the caller; fs.unlink throws on ENOENT.
|
||||
try { fs.unlink(pidfile); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,9 +1011,15 @@ function cleanup_orphaned_server_certs() {
|
||||
if (substr(entry, 0, 1) == ".") continue;
|
||||
if (active_names[entry]) continue;
|
||||
let path = cert_dir + "/" + entry;
|
||||
// Absent file already satisfies the caller; fs.unlink throws on ENOENT.
|
||||
try { fs.unlink(path); } catch(e) {}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// Best-effort housekeeping: a missing cert directory or an unreadable
|
||||
// UCI section leaves stale certs on disk, which costs a few kilobytes
|
||||
// and nothing else. Never worth failing the caller over.
|
||||
}
|
||||
}
|
||||
|
||||
function prepare_all_server_defaults() {
|
||||
|
||||
@@ -30,7 +30,11 @@ function get_clash_url(endpoint) {
|
||||
host = ext;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// Falls through to the default host below. The clash API is optional
|
||||
// and every caller already treats an unreachable one as "no data".
|
||||
}
|
||||
}
|
||||
return "http://" + host + "/" + endpoint;
|
||||
}
|
||||
@@ -194,7 +198,11 @@ function get_clash_connections() {
|
||||
if (res && res.status == 0 && res.output != "") {
|
||||
try {
|
||||
return json(res.output);
|
||||
} catch (e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// Falls through to null, which every caller already handles as "the
|
||||
// clash API gave us nothing" - the same result as a failed curl.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ function write_text_file(path, text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function unlink_file(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
@@ -349,6 +352,9 @@ function release_runtime_dir_lock(lock_dir) {
|
||||
command_success_from_args([ "rmdir", lock_dir ]);
|
||||
}
|
||||
|
||||
// The two unlinks below clean up the temporary file after a failed write or
|
||||
// rename. Both paths exit(1) immediately after, so the failure is already
|
||||
// reported; an unlink that throws means the temp file was never created.
|
||||
function write_reload_state(path, values) {
|
||||
if (!ensure_parent_dir(path))
|
||||
exit(1);
|
||||
@@ -467,7 +473,11 @@ function sing_box_service_pid_runtime() {
|
||||
if (pid > 0 && pid_is_sing_box(pid))
|
||||
return pid;
|
||||
}
|
||||
catch (e) {}
|
||||
catch (e) {
|
||||
// ubus is unavailable or answered with something that is not the
|
||||
// service list; the pid files below are the fallback that exists for
|
||||
// exactly this case.
|
||||
}
|
||||
|
||||
for (let path in [ "/var/run/sing-box.pid", "/var/run/sing-box/sing-box.pid" ]) {
|
||||
let pid = int(trim(fs.readfile(path) || "0"));
|
||||
|
||||
@@ -37,7 +37,15 @@ function cb_map_load() {
|
||||
try {
|
||||
let obj = json(data);
|
||||
if (type(obj) == "object") return obj;
|
||||
} catch (e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// A truncated map is recoverable: the tokens in it are short-lived
|
||||
// and the next store rewrites the file. Starting from empty costs
|
||||
// the user a "кнопка устарела" on old keyboards, which is what the
|
||||
// caller reports anyway when a token is missing.
|
||||
command_success_from_args([ "logger", "-t", "tachyon-telegram",
|
||||
"[warn] callback map is unparseable, starting from empty: " + as_string(e) ]);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -53,7 +61,22 @@ function cb_map_store(token, value) {
|
||||
map = pruned;
|
||||
}
|
||||
map[token] = value;
|
||||
try { fs.writefile(CB_MAP_FILE, sprintf("%J", map)); } catch (e) {}
|
||||
// The token has already been embedded in the keyboard by the time this runs,
|
||||
// so a failed write hands the user a button that will answer "кнопка
|
||||
// устарела" the moment they press it. Silently swallowing that made the
|
||||
// keyboard look intermittently broken with nothing in the log.
|
||||
let stored = false;
|
||||
try {
|
||||
stored = fs.writefile(CB_MAP_FILE, sprintf("%J", map)) != null;
|
||||
}
|
||||
catch (e) {
|
||||
command_success_from_args([ "logger", "-t", "tachyon-telegram",
|
||||
"[err] Failed to store callback map, buttons in this keyboard will not work: " + as_string(e) ]);
|
||||
return;
|
||||
}
|
||||
if (!stored)
|
||||
command_success_from_args([ "logger", "-t", "tachyon-telegram",
|
||||
"[err] Failed to store callback map, buttons in this keyboard will not work" ]);
|
||||
}
|
||||
|
||||
function cb_map_get(token) {
|
||||
@@ -168,6 +191,8 @@ function get_tg_state(chat_id) {
|
||||
let f = tg_state_path(chat_id);
|
||||
if (!f) return null;
|
||||
let data = fs.readfile(f);
|
||||
// A corrupt per-chat state file drops that conversation back to the main
|
||||
// menu, which is the same thing a missing file does.
|
||||
if (data) { try { return json(data); } catch(e) {} }
|
||||
return null;
|
||||
}
|
||||
@@ -175,6 +200,7 @@ function get_tg_state(chat_id) {
|
||||
function set_tg_state(chat_id, state_obj) {
|
||||
let f = tg_state_path(chat_id);
|
||||
if (!f) return;
|
||||
// Absent file already satisfies the caller; fs.unlink throws on ENOENT.
|
||||
if (state_obj == null) { try { fs.unlink(f); } catch(e) {} }
|
||||
else fs.writefile(f, sprintf("%J", state_obj));
|
||||
}
|
||||
@@ -1209,6 +1235,7 @@ function exec_support_bundle(token, chat_id) {
|
||||
} else {
|
||||
send_message(token, chat_id, "❌ <b>Ошибка генерации Bundle.</b>", "HTML");
|
||||
}
|
||||
// Absent file already satisfies the caller; fs.unlink throws on ENOENT.
|
||||
try { fs.unlink("/etc/.tachyon/ip_route.txt"); fs.unlink("/etc/.tachyon/logread.txt"); } catch(e) {}
|
||||
}
|
||||
|
||||
@@ -1391,7 +1418,12 @@ function view_quick_test(token, chat_id, msg_id) {
|
||||
try {
|
||||
let dns_res = command_capture(command_from_args(["nslookup", "google.com", "127.0.0.1"]));
|
||||
dns_ok = dns_res && dns_res.status == 0;
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// dns_ok stays false, which is what the report line below prints. A
|
||||
// failing DNS check and a failing nslookup invocation mean the same
|
||||
// thing to the reader.
|
||||
}
|
||||
text += (dns_ok ? "✅" : "❌") + " DNS на роутере\n";
|
||||
|
||||
if (sys && sys.pause_remaining > 0) {
|
||||
@@ -1676,6 +1708,7 @@ function exec_export_config(token, chat_id, msg_id) {
|
||||
if (res && res.status == 0 && res.output) {
|
||||
fs.writefile(export_path, res.output);
|
||||
send_document(token, chat_id, export_path);
|
||||
// Absent file already satisfies the caller; fs.unlink throws on ENOENT.
|
||||
try { fs.unlink(export_path); } catch(e) {}
|
||||
send_message(token, chat_id, "✅ Конфиг экспортирован.\n\n⚠️ Файл содержит чувствительные данные. Не публикуйте его.", "HTML", [[{ text: "⬅️ Меню", callback_data: "/menu" }]]);
|
||||
} else {
|
||||
@@ -2465,7 +2498,11 @@ function send_daily_digest(token, admin_ids) {
|
||||
let tr = json(res.output);
|
||||
text += "🔻 Текущий RX: " + format_bytes(tr.down) + "/s\n";
|
||||
text += "🔺 Текущий TX: " + format_bytes(tr.up) + "/s\n";
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// Traffic counters are decoration on a status message; a clash API
|
||||
// that answers with something unparseable just omits two lines.
|
||||
}
|
||||
}
|
||||
|
||||
let admins = split(admin_ids, /,/);
|
||||
@@ -2483,6 +2520,7 @@ function check_notified_updates(token, admin_ids) {
|
||||
let notified_file = "/tmp/tg_notified_updates.json";
|
||||
let notified = {};
|
||||
let ndata = fs.readfile(notified_file);
|
||||
// A corrupt notified-file re-announces updates already announced once.
|
||||
if (ndata) { try { notified = json(ndata); } catch(e){} }
|
||||
|
||||
let changed = false;
|
||||
@@ -2510,7 +2548,12 @@ function check_notified_updates(token, admin_ids) {
|
||||
}
|
||||
}
|
||||
if (changed) fs.writefile(notified_file, sprintf("%J", notified));
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// The update-check cache is written by another process and may be
|
||||
// half-written when read. Skipping this round costs one notification
|
||||
// cycle; the next worker pass reads it again.
|
||||
}
|
||||
}
|
||||
|
||||
function worker() {
|
||||
@@ -2600,6 +2643,7 @@ function stop_runtime() {
|
||||
command_success_from_args([ "kill", "-9", pid ]);
|
||||
}
|
||||
}
|
||||
// Absent file already satisfies the caller; fs.unlink throws on ENOENT.
|
||||
try { fs.unlink(PID_FILE); } catch(e) {}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -183,6 +183,9 @@ function write_state_file(path, value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function remove_file(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
|
||||
@@ -60,7 +60,12 @@ function call_api(method, url, auth_header, body_file) {
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// Not JSON - the API answered with an error page or a rate-limit
|
||||
// notice. Kept in last_res and returned to the caller as the
|
||||
// failure message, which is more useful than a parse error.
|
||||
}
|
||||
last_res = res;
|
||||
}
|
||||
}
|
||||
@@ -79,7 +84,12 @@ if (clash_data_str != "") {
|
||||
original_server = parsed.now || "";
|
||||
available_servers = parsed.all || [];
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// No clash API means no proxy rotation: original_server stays empty and
|
||||
// restore_original_server() below becomes a no-op. Generating a WARP
|
||||
// config without rotation is the normal path on a direct connection.
|
||||
}
|
||||
}
|
||||
|
||||
function restore_original_server() {
|
||||
|
||||
@@ -55,6 +55,9 @@ function settings() {
|
||||
return common.object_or_empty(uci_core.get_all(CONFIG_NAME, "settings"));
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function remove_file(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
@@ -83,6 +86,48 @@ function log_message(message, level) {
|
||||
}
|
||||
}
|
||||
|
||||
// Files the watchdog writes to keep its own state: the restart lock, the OOM
|
||||
// scale, the keepalive stamp. A failed write here is not cosmetic — the lock
|
||||
// not landing means the next tick restarts the proxy again with the rate limit
|
||||
// none the wiser, and a missing keepalive makes init.d conclude the watchdog
|
||||
// died and restart it. These writes used to be wrapped in an empty catch, so
|
||||
// the failure was invisible in every case.
|
||||
//
|
||||
// The keepalive is written on every uloop tick, so an unconditional log line
|
||||
// would flood syslog on a full filesystem — the exact condition where the log
|
||||
// matters most. Each path therefore reports its first failure and then stays
|
||||
// quiet until it succeeds again, which also makes recovery visible.
|
||||
let write_failures = {};
|
||||
|
||||
function write_state_file(path, value, description) {
|
||||
path = as_string(path);
|
||||
let ok = false;
|
||||
|
||||
try {
|
||||
ok = fs.writefile(path, as_string(value)) != null;
|
||||
}
|
||||
catch (e) {
|
||||
log_message("Failed to write " + description + " (" + path + "): " + as_string(e),
|
||||
write_failures[path] ? "debug" : "err");
|
||||
write_failures[path] = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
log_message("Failed to write " + description + " (" + path + ")",
|
||||
write_failures[path] ? "debug" : "err");
|
||||
write_failures[path] = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (write_failures[path]) {
|
||||
log_message("Write to " + description + " (" + path + ") succeeded again", "warn");
|
||||
delete write_failures[path];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function send_telegram_notification(message) {
|
||||
let now = time();
|
||||
if (now - telegram_msg_window > 300) {
|
||||
@@ -295,6 +340,8 @@ function run_zero_rtt_prefetching() {
|
||||
|
||||
let uloop = null;
|
||||
let ubus = null;
|
||||
// Optional modules: absent in the Docker test image and on a router without
|
||||
// libubus, where the watchdog degrades to the polling path below.
|
||||
try { uloop = require("uloop"); } catch (e) {}
|
||||
try { ubus = require("ubus"); } catch (e) {}
|
||||
|
||||
@@ -660,7 +707,7 @@ function safe_proxy_restart(reason, force_level) {
|
||||
log_message("Proxy restart lock stale (age " + as_string(lock_age) + "s), removing", "warn");
|
||||
remove_file(PROXY_RESTART_LOCK);
|
||||
}
|
||||
try { fs.writefile(PROXY_RESTART_LOCK, as_string(now)); } catch(e) {}
|
||||
write_state_file(PROXY_RESTART_LOCK, as_string(now), "proxy restart lock");
|
||||
proxy_restart_count++;
|
||||
// The proxy port can change across a restart, so the controller's cached
|
||||
// lookup is invalidated here rather than after the fact.
|
||||
@@ -1032,6 +1079,7 @@ function write_dns_recovery_state(state) {
|
||||
}
|
||||
|
||||
function remove_dns_recovery_state() {
|
||||
// Absent file already satisfies the caller; fs.unlink throws on ENOENT.
|
||||
try { fs.unlink(DNS_RECOVERY_STATE_FILE); } catch(e) {}
|
||||
}
|
||||
|
||||
@@ -1141,6 +1189,8 @@ function export_metrics() {
|
||||
let data = { hours: [] };
|
||||
let existing = fs.readfile(metrics_path);
|
||||
if (existing) {
|
||||
// A truncated metrics file is discarded and rebuilt from the empty shape
|
||||
// above: these are rolling hourly buckets, not authoritative state.
|
||||
try { data = json(existing) || data; } catch(e) {}
|
||||
}
|
||||
|
||||
@@ -1252,7 +1302,12 @@ function notify_urltest_switch(ev) {
|
||||
fs.writefile("/tmp/watchdog_urltest_" + safe_name, p.now);
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// The clash API is optional and answers with an error page while
|
||||
// sing-box is restarting. Missing one proxy-switch notification is
|
||||
// the whole cost; the next tick reads it again.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1272,6 +1327,7 @@ function smart_detect_process_pending() {
|
||||
let seen = {};
|
||||
let seen_data = fs.readfile(SMART_DETECT_SEEN_FILE);
|
||||
if (seen_data) {
|
||||
// Corrupt seen-file means domains get re-detected once, not lost.
|
||||
try { seen = json(seen_data) || {}; } catch(e) {}
|
||||
}
|
||||
|
||||
@@ -1301,7 +1357,11 @@ function smart_detect_process_pending() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
let detect_sections = [];
|
||||
@@ -1476,7 +1536,7 @@ function recover_oom_scale(ev) {
|
||||
let new_scale = current_scale + 0.05;
|
||||
if (new_scale > 1.0) new_scale = 1.0;
|
||||
log_message("OOM recovery: restoring GOMEMLIMIT scale from " + sprintf("%.2f", current_scale) + " to " + sprintf("%.2f", new_scale), "info");
|
||||
try { fs.writefile(scale_path, sprintf("%.2f", new_scale)); } catch(e) {}
|
||||
write_state_file(scale_path, sprintf("%.2f", new_scale), "GOMEMLIMIT scale");
|
||||
}
|
||||
|
||||
function setup_syslog_listener() {
|
||||
@@ -1507,6 +1567,8 @@ function setup_syslog_listener() {
|
||||
function setup_ubus_listener() {
|
||||
if (!ubus || !uloop) return null;
|
||||
let conn = null;
|
||||
// Null conn is handled on the next line: without ubus the watchdog falls
|
||||
// back to polling, which is the same path taken when the module is absent.
|
||||
try { conn = ubus.connect(); } catch (e) {}
|
||||
if (!conn || type(conn.listener) != "function") return null;
|
||||
|
||||
@@ -1670,16 +1732,18 @@ function worker() {
|
||||
run_zero_rtt_prefetching();
|
||||
|
||||
// Ensure /etc/tachyon exists for persistent smart detect
|
||||
// Throws when it already exists, which is the expected case on every boot
|
||||
// after the first.
|
||||
try { fs.mkdir("/etc/tachyon"); } catch(e) {}
|
||||
|
||||
// H-14: Save config backup after successful start
|
||||
let current_cfg = fs.readfile("/etc/config/tachyon");
|
||||
if (current_cfg != null && current_cfg != "") {
|
||||
try { fs.writefile("/etc/backup/tachyon_config", current_cfg); } catch(e) {}
|
||||
write_state_file("/etc/backup/tachyon_config", current_cfg, "config backup");
|
||||
}
|
||||
|
||||
// H-8: Write keepalive timestamp for init.d supervision
|
||||
try { fs.writefile("/var/run/tachyon_watchdog.keepalive", as_string(time())); } catch(e) {}
|
||||
write_state_file("/var/run/tachyon_watchdog.keepalive", as_string(time()), "keepalive stamp");
|
||||
|
||||
if (uloop) {
|
||||
try {
|
||||
@@ -1716,7 +1780,7 @@ function worker() {
|
||||
tick = function() {
|
||||
let now = time();
|
||||
try {
|
||||
try { fs.writefile("/var/run/tachyon_watchdog.keepalive", as_string(time())); } catch(e) {}
|
||||
write_state_file("/var/run/tachyon_watchdog.keepalive", as_string(time()), "keepalive stamp");
|
||||
if (now - last_fast_check >= 15) {
|
||||
last_fast_check = now;
|
||||
perform_fast_checks();
|
||||
@@ -1751,6 +1815,8 @@ function worker() {
|
||||
}
|
||||
|
||||
if (log_pipe) log_pipe.close();
|
||||
// Shutdown path: a connection already torn down by the peer throws here, and
|
||||
// there is nothing left to salvage either way.
|
||||
if (ubus_conn) try { ubus_conn.close(); } catch (e) {}
|
||||
return 0;
|
||||
}
|
||||
@@ -1792,11 +1858,18 @@ function print_ai_status_full() {
|
||||
let starttime = int(fields[21]);
|
||||
let clk_tck = 100;
|
||||
let uptime_seconds = 0;
|
||||
// Leaves uptime at 0, which the caller reads as "age unknown" — /proc/uptime
|
||||
// is only unreadable if /proc is not mounted, and then nothing else works
|
||||
// either.
|
||||
try { uptime_seconds = int(trim(fs.readfile("/proc/uptime") || "0")); } catch(e) {}
|
||||
sb_uptime = uptime_seconds - int(starttime / clk_tck);
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// sb_uptime stays -1, which the report renders as "unknown". The
|
||||
// process can exit between reading its pid and reading its stat.
|
||||
}
|
||||
}
|
||||
|
||||
let mem_mb = -1;
|
||||
|
||||
@@ -61,6 +61,9 @@ function ensure_dir(path) {
|
||||
return command_success_from_args([ "mkdir", "-p", path ]);
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function remove_file(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
|
||||
@@ -61,6 +61,9 @@ function ensure_dir(path) {
|
||||
return command_success_from_args([ "mkdir", "-p", path ]);
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function remove_file(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
|
||||
@@ -99,6 +99,9 @@ function write_file(path, value) {
|
||||
return fs.writefile(as_string(path), as_string(value)) != null;
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function remove_file(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
@@ -595,6 +598,8 @@ function network_interface_ipv4(name) {
|
||||
return as_string(object_or_empty(addresses[0]).address);
|
||||
}
|
||||
catch (e) {
|
||||
// An interface that is down answers with an error rather than a status
|
||||
// object. The empty string the caller gets back means the same thing.
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -829,7 +834,11 @@ function init_config(populate_nft, caches_prepared, no_refresh) {
|
||||
let stale = trim(command_output_from_args([ "find", "/tmp", "-maxdepth", "1", "-name", "config.json.*.tmp.*", "-mmin", "+5", "-type", "f" ]));
|
||||
for (let path in split(stale, "\n"))
|
||||
if (trim(path) != "") remove_file(trim(path));
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// Housekeeping for temp files left by earlier failed runs. Failing here
|
||||
// leaves a few stale files in /tmp and must not stop the start.
|
||||
}
|
||||
|
||||
let mwan3_active = module_success([ LIB_DIR + "/config/validator.uc", "mwan3-is-active" ]);
|
||||
let output_interface = option(settings, "output_network_interface", "");
|
||||
|
||||
@@ -97,7 +97,14 @@ function regex_matching_tag_array(tags, names, regexes) {
|
||||
if (pat_str != "") {
|
||||
try {
|
||||
push(compiled, regexp(pat_str));
|
||||
} catch(e) {}
|
||||
}
|
||||
catch (e) {
|
||||
// The pattern comes from the user's own config. Dropping it
|
||||
// silently leaves a group that quietly matches fewer outbounds
|
||||
// than the config says it should — a routing change with no
|
||||
// visible cause. The remaining patterns still apply.
|
||||
warn("urltest: ignoring invalid regex " + pat_str + ": " + as_string(e) + "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -530,6 +530,9 @@ function copy_file(source, target) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The empty catch is the point: every caller means "make sure this path is
|
||||
// gone", and an absent file already satisfies that. fs.unlink throws on ENOENT,
|
||||
// so the alternative is a stat() race with no better outcome.
|
||||
function unlink_path(path) {
|
||||
try {
|
||||
fs.unlink(as_string(path));
|
||||
|
||||
@@ -59,7 +59,7 @@ level_line="$(grep -n 'let level = (force_level != null' "$WATCHDOG_UC" | cut -d
|
||||
|| fail "the rate limit is checked after the rung is chosen; light restarts would escape it"
|
||||
|
||||
# The lock, likewise, is taken before the rung is chosen.
|
||||
lock_line="$(grep -n 'fs.writefile(PROXY_RESTART_LOCK, as_string(now))' "$WATCHDOG_UC" | cut -d: -f1)"
|
||||
lock_line="$(grep -n 'write_state_file(PROXY_RESTART_LOCK, as_string(now)' "$WATCHDOG_UC" | cut -d: -f1)"
|
||||
[ -n "$lock_line" ] || fail "the proxy restart lock is no longer written"
|
||||
[ "$lock_line" -lt "$level_line" ] \
|
||||
|| fail "the lock is taken after the rung is chosen; light restarts would bypass it"
|
||||
|
||||
Reference in New Issue
Block a user