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
@@ -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));
+7 -1
View File
@@ -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;
}
+21 -1
View File
@@ -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) {}
}
+4
View File
@@ -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();
+5 -1
View File
@@ -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",
+19 -1
View File
@@ -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");
+3
View File
@@ -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) {}
}
}
+7 -1
View File
@@ -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() {
+10 -2
View File
@@ -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;
}
+11 -1
View File
@@ -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"));
+49 -5
View File
@@ -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;
}
+3
View File
@@ -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() {
+81 -8
View File
@@ -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));
+10 -1
View File
@@ -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", "");
+8 -1
View File
@@ -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));
+1 -1
View File
@@ -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"