diff --git a/fe-app-tachyon/package-lock.json b/fe-app-tachyon/package-lock.json index 23466990..2bb979ec 100644 --- a/fe-app-tachyon/package-lock.json +++ b/fe-app-tachyon/package-lock.json @@ -1,12 +1,12 @@ { "name": "fe-app-tachyon", - "version": "1.3.26", + "version": "1.3.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fe-app-tachyon", - "version": "1.3.26", + "version": "1.3.27", "license": "MIT", "devDependencies": { "@babel/parser": "7.28.4", diff --git a/fe-app-tachyon/package.json b/fe-app-tachyon/package.json index aa7f7119..8cee8778 100644 --- a/fe-app-tachyon/package.json +++ b/fe-app-tachyon/package.json @@ -1,6 +1,6 @@ { "name": "fe-app-tachyon", - "version": "1.3.26", + "version": "1.3.27", "license": "MIT", "type": "module", "scripts": { diff --git a/tachyon/files/usr/lib/service/event_controller.uc b/tachyon/files/usr/lib/service/event_controller.uc index 1dcd7d80..78feb0c2 100644 --- a/tachyon/files/usr/lib/service/event_controller.uc +++ b/tachyon/files/usr/lib/service/event_controller.uc @@ -404,6 +404,8 @@ function controller(bus, opts) { dns_consecutive_fails: 0, dns_fail_streak: 0, dns_streak_stamp: 0, + last_proxy_probe: 0, + last_dns_probe: 0, proxy_latency_history: [], dns_latency_history: [], syslog_start_time: 0, @@ -415,6 +417,7 @@ function controller(bus, opts) { }; let self = { EV: EV, state: state }; + let current_tick_ctx = null; // Keeps the last 20 samples. Bounded so a long-lived watchdog cannot grow // its heap through the history array. @@ -500,22 +503,48 @@ function controller(bus, opts) { } self.is_paused = probe_pause; + // ── Shared observation context ─────────────────────────────────────────── + // Resolved once at tick entry and reused across all probes within the tick. + // Eliminates redundant forks of pgrep /usr/bin/tachyon, /proc traversals, + // and duplicate pid lookups. + function create_tick_context() { + let now = time(); + let cfg = settings(); + let reload = is_reload_in_progress(); + let list_upd = is_list_update_running(); + let sb_pid = get_sing_box_pid(); + let sb_running = (sb_pid != "" && process_running(sb_pid, "sing-box")); + let port = proxy_port(); + let host = proxy_host(); + let paused = probe_pause(); + + return { + now: now, + settings: cfg, + reload_in_progress: reload, + list_update_running: list_upd, + singbox_pid: sb_pid, + singbox_running: sb_running, + proxy_port: port, + proxy_host: host, + is_paused: paused + }; + } + self.create_tick_context = create_tick_context; + // ── Probe: sing-box liveness ────────────────────────────────────────────── // The expensive /proc scan only runs once the cheap pidfile/ubus/pidof // path has already failed, preserving the original fast-path ordering. function probe_singbox() { + let ctx = current_tick_ctx || create_tick_context(); if (setting("recovery_bypass", "0") == "1") return; - // An active pause means sing-box is stopped on purpose. The original - // check_singbox_process() consulted the pause on every fast tick, so it - // is consulted here too: leaving it to the normal tier alone would open - // a window of up to one normal interval in which a deliberate pause is - // reported as a stop. - if (probe_pause()) return; - if (check_tachyon_cli_running()) return; - if (is_list_update_running()) return; + // An active pause means sing-box is stopped on purpose. + if (ctx.is_paused) return; + if (ctx.reload_in_progress) return; + if (ctx.list_update_running) return; - let pid = get_sing_box_pid(); - if (pid != "" && process_running(pid, "sing-box")) return; + let pid = ctx.singbox_pid; + if (pid != "" && ctx.singbox_running) return; // No configured sections means sing-box is legitimately absent. let has_sections = false; @@ -542,20 +571,33 @@ function controller(bus, opts) { // subscriber. The probe only skips when measuring is pointless or its // result would be misleading. function probe_proxy() { + let ctx = current_tick_ctx || create_tick_context(); if (!bus.has(EV.PROXY_DOWN) && !bus.has(EV.PROXY_UP)) return; // A reload tears the proxy down on purpose; a sample taken now would // land in the latency history as a fault that never happened. - if (is_reload_in_progress()) return; + if (ctx.reload_in_progress) return; - let pid = get_sing_box_pid(); - if (pid == "" || !process_running(pid, "sing-box")) return; + let pid = ctx.singbox_pid; + if (pid == "" || !ctx.singbox_running) return; let port = proxy_port(); // No http/mixed inbound in the generated config: there is nothing to // measure, so the proxy must not be declared broken on a dead port. if (port == "") return; - let host = proxy_host(); - let check_url = setting("ai_proxy_health_url", "https://cp.cloudflare.com/generate_204"); + let host = ctx.proxy_host; + + // Adaptive interval pacing: honor ai_proxy_health_interval (default 30s) + // when healthy (consecutive fails == 0). + // On failure, switch to fast retry on every tick (10-15s) until restored. + let proxy_interval = int(ctx.settings.ai_proxy_health_interval || "30"); + if (proxy_interval < 15) proxy_interval = 15; + + if (state.proxy_consecutive_fails == 0 && state.last_proxy_probe > 0 && + (ctx.now - state.last_proxy_probe < proxy_interval)) + return; + + state.last_proxy_probe = ctx.now; + let check_url = ctx.settings.ai_proxy_health_url || "https://cp.cloudflare.com/generate_204"; let started = time(); let ok = command_success_from_args([ @@ -628,22 +670,35 @@ function controller(bus, opts) { // As with the proxy probe, one resolution attempt serves both DNS // subscribers; each carries its own enable flag and its own threshold. function probe_dns() { + let ctx = current_tick_ctx || create_tick_context(); if (!bus.has(EV.DNS_DOWN) && !bus.has(EV.DNS_UP)) return; if (setting("recovery_bypass", "0") == "1") return; // A reload restarts the resolver; failures during it are expected and // must not accumulate, so the streak resets exactly as before. - if (is_reload_in_progress()) { + if (ctx.reload_in_progress) { clear_dns_streak(); return; } - let pid = get_sing_box_pid(); - if (pid == "" || !process_running(pid, "sing-box")) { + let pid = ctx.singbox_pid; + if (pid == "" || !ctx.singbox_running) { clear_dns_streak(); return; } + // Adaptive interval pacing: honor ai_dns_interval (default 60s) + // when healthy (consecutive fails == 0). + // On failure, switch to fast retry on every tick (10-15s) until restored. + let dns_interval = int(ctx.settings.ai_dns_interval || "60"); + if (dns_interval < 15) dns_interval = 15; + + if (state.dns_consecutive_fails == 0 && state.last_dns_probe > 0 && + (ctx.now - state.last_dns_probe < dns_interval)) + return; + + state.last_dns_probe = ctx.now; + let started = time(); let ok = is_dns_working(); let elapsed = (time() - started) * 1000; @@ -703,13 +758,14 @@ function controller(bus, opts) { // ── Probe: nftables table and QoS marks ─────────────────────────────────── function probe_nftables() { + let ctx = current_tick_ctx || create_tick_context(); if (setting("recovery_bypass", "0") == "1") return; if (fs.stat("/var/run/tachyon/native_internet_restored") != null) return; - if (is_list_update_running()) return; - if (is_reload_in_progress()) return; + if (ctx.list_update_running) return; + if (ctx.reload_in_progress) return; let nft_table = getenv("NFT_TABLE_NAME") || "TachyonTable"; - let routing_mode = setting("routing_mode", "nftables"); + let routing_mode = ctx.settings.routing_mode || "nftables"; let out_nft = command_output_from_args(["sh", "-c", "nft list table inet " + nft_table + " | grep -E 'chain|tproxy|priority_rules|dscp'; exit 0"]); @@ -720,7 +776,7 @@ function controller(bus, opts) { } } - let qos_pref = setting("qos_priority_engine", ""); + let qos_pref = ctx.settings.qos_priority_engine || ""; if (qos_pref == "0") return; if ((index(out_nft, "dscp set 0x2e") < 0 && index(out_nft, "dscp set ef") < 0) || (index(out_nft, "dscp set 0x22") < 0 && index(out_nft, "dscp set af41") < 0)) @@ -729,12 +785,13 @@ function controller(bus, opts) { // ── Probe: TPROXY port liveness ─────────────────────────────────────────── function probe_tproxy() { + let ctx = current_tick_ctx || create_tick_context(); if (setting("recovery_bypass", "0") == "1") return; if (fs.stat("/var/run/tachyon/native_internet_restored") != null) return; - if (is_reload_in_progress()) return; + if (ctx.reload_in_progress) return; - let pid = get_sing_box_pid(); - if (pid == "" || !process_running(pid, "sing-box")) return; + let pid = ctx.singbox_pid; + if (pid == "" || !ctx.singbox_running) return; let port = tproxy_port(); // /proc/net/tcp encodes the local port in hex; 0A is TCP_LISTEN. @@ -818,7 +875,8 @@ function controller(bus, opts) { // that is in progress are not counted, and a healthy pass resets the // streak so one transient gap cannot accumulate into a false trigger. function probe_wan() { - if (is_reload_in_progress()) { + let ctx = current_tick_ctx || create_tick_context(); + if (ctx.reload_in_progress) { state.wan_fail_streak = 0; return; } @@ -973,12 +1031,13 @@ function controller(bus, opts) { // ── Probe: community subnet nft sets ────────────────────────────────────── function probe_subnet_sets() { + let ctx = current_tick_ctx || create_tick_context(); if (setting("recovery_bypass", "0") == "1") return; - if (is_reload_in_progress()) return; - if (is_list_update_running()) return; + if (ctx.reload_in_progress) return; + if (ctx.list_update_running) return; - let pid = get_sing_box_pid(); - if (pid == "" || !process_running(pid, "sing-box")) return; + let pid = ctx.singbox_pid; + if (pid == "" || !ctx.singbox_running) return; let nft_table = getenv("NFT_TABLE_NAME") || "TachyonTable"; let all_sections = uci_core.get_all(CONFIG_NAME); @@ -1033,8 +1092,9 @@ function controller(bus, opts) { // ── Probe: proxy sections with subscriptions but no usable outbounds ────── function probe_empty_sections() { + let ctx = current_tick_ctx || create_tick_context(); if (!enabled("ai_section_failover_enabled", "1")) return; - if (is_reload_in_progress()) return; + if (ctx.reload_in_progress) return; let connections = require("config.connections"); let sections = common.object_or_empty(uci_core.get_all(CONFIG_NAME)); @@ -1104,8 +1164,9 @@ function controller(bus, opts) { // Checks if nfqws/nfqws2 supervisor processes are alive by counting PID // files. A mismatch means the DPI bypass engine crashed or was killed. function probe_nfqueue() { + let ctx = current_tick_ctx || create_tick_context(); if (!bus.has(EV.NFQUEUE_DOWN) && !bus.has(EV.NFQUEUE_UP)) return; - if (is_reload_in_progress()) return; + if (ctx.reload_in_progress) return; let pid_dirs = [ "/var/run/tachyon/zapret/child-pid", @@ -1186,17 +1247,21 @@ function controller(bus, opts) { // event. self.handle_ubus_firewall_reload = function() { bus.emit(EV.FIREWALL_RELOADED, {}); + current_tick_ctx = create_tick_context(); run_probe(probe_nftables, "nftables"); run_probe(probe_subnet_sets, "subnet_sets"); return true; }; - self.probe_fast = function() { + self.probe_fast = function(ctx) { + current_tick_ctx = ctx || create_tick_context(); run_probe(probe_singbox, "singbox"); run_probe(probe_proxy, "proxy"); - run_probe(probe_dns, "dns"); }; + run_probe(probe_dns, "dns"); + }; - self.probe_normal = function() { + self.probe_normal = function(ctx) { + current_tick_ctx = ctx || create_tick_context(); run_probe(probe_pause, "pause"); run_probe(probe_memory, "memory"); run_probe(probe_rpcd, "rpcd"); @@ -1204,7 +1269,8 @@ function controller(bus, opts) { run_probe(probe_tproxy, "tproxy"); }; - self.probe_slow = function() { + self.probe_slow = function(ctx) { + current_tick_ctx = ctx || create_tick_context(); run_probe(probe_subnet_sets, "subnet_sets"); run_probe(probe_wan, "wan"); run_probe(probe_subscription, "subscription"); @@ -1231,8 +1297,14 @@ function controller(bus, opts) { // their counter right after restarting, so that a restart-in-flight would // not immediately trip the same threshold again on the next tick. self.reset_dns_streak = clear_dns_streak; - self.reset_dns_consecutive = function() { state.dns_consecutive_fails = 0; }; - self.reset_proxy_consecutive = function() { state.proxy_consecutive_fails = 0; }; + self.reset_dns_consecutive = function() { + state.dns_consecutive_fails = 0; + state.last_dns_probe = 0; + }; + self.reset_proxy_consecutive = function() { + state.proxy_consecutive_fails = 0; + state.last_proxy_probe = 0; + }; // Latency history and streak counters feed ai-status-full and the metrics // export, which are still owned by the watchdog. diff --git a/tachyon/files/usr/lib/service/telegram.uc b/tachyon/files/usr/lib/service/telegram.uc index 854efe48..95af1018 100644 --- a/tachyon/files/usr/lib/service/telegram.uc +++ b/tachyon/files/usr/lib/service/telegram.uc @@ -276,8 +276,8 @@ function tg_request_via(token, method, payload, proxy_args) { // getUpdates long-poll on reboot or watchdog restart). let body = sprintf("%J", payload); let is_poll = (method == "getUpdates"); - let max_time = is_poll ? "35" : "12"; - let conn_timeout = is_poll ? "10" : "5"; + let max_time = is_poll ? "65" : "12"; + let conn_timeout = is_poll ? "15" : "5"; let args = [ "curl", "-s", "-m", max_time, "--connect-timeout", conn_timeout, "-X", "POST", "-H", "Content-Type: application/json", "-d", body ]; @@ -2832,7 +2832,7 @@ function dispatch_command(token, chat_id, text, msg_id) { function process_updates(token, admin_ids) { let offset = int(trim(fs.readfile(OFFSET_FILE) || "0")); - let res = tg_request(token, "getUpdates", { offset: offset, timeout: 20 }); + let res = tg_request(token, "getUpdates", { offset: offset, timeout: 50 }); if (!res || !res.ok || !res.result) return false; if (length(res.result) == 0) return true; @@ -3317,9 +3317,8 @@ function worker() { } catch (e) { consecutive_failures++; command_success_from_args(["logger", "-t", "tachyon-telegram", "[err] Worker loop error: " + as_string(e)]); + sleep(poll_interval * 1000); } - - sleep(poll_interval * 1000); } return 0; } diff --git a/tachyon/files/usr/lib/service/ui.uc b/tachyon/files/usr/lib/service/ui.uc index 387343a2..805ba7ab 100644 --- a/tachyon/files/usr/lib/service/ui.uc +++ b/tachyon/files/usr/lib/service/ui.uc @@ -646,6 +646,7 @@ function set_running_job_pid_file(path, pid) { } function write_finished_action_state(path, success, message, exit_code) { + remove_file(ACTION_DIRS_REFRESH_STAMP_FILE); return write_state_file(path, finished_action_state_value(path, success, message, exit_code, now_seconds())); } @@ -734,6 +735,20 @@ function refresh_action_dirs() { refresh_pid_job_state(path, "Subscription update worker exited unexpectedly"); } +const ACTION_DIRS_REFRESH_STAMP_FILE = "/var/run/tachyon_action_dirs_refresh.stamp"; +const ACTION_DIRS_REFRESH_INTERVAL_SECONDS = 30; + +function maybe_refresh_action_dirs(force) { + let now = now_seconds(); + if (!force) { + let stat = fs.stat(ACTION_DIRS_REFRESH_STAMP_FILE); + if (stat != null && (now - int(stat.mtime || 0)) < ACTION_DIRS_REFRESH_INTERVAL_SECONDS) + return; + } + refresh_action_dirs(); + write_state_file(ACTION_DIRS_REFRESH_STAMP_FILE, as_string(now) + "\n", "action dirs refresh stamp"); +} + function active_service_action_default() { refresh_action_dirs(); active_service_action(SERVICE_ACTION_DIR); @@ -755,7 +770,6 @@ function action_state_from_dir(dir) { } function action_state_from_dirs() { - refresh_action_dirs(); return { service: action_state_from_dir(SERVICE_ACTION_DIR), latency: action_state_from_dir(LATENCY_ACTION_DIR), @@ -1094,7 +1108,7 @@ function process_memory_rss_mb(process_name) { } function current_ui_state_json() { - refresh_action_dirs(); + maybe_refresh_action_dirs(false); let capabilities = capability_flags(); let tachyon_is_running = tachyon_running() ? 1 : 0; diff --git a/tachyon/files/usr/lib/service/watchdog.uc b/tachyon/files/usr/lib/service/watchdog.uc index 5e8cb404..9eafabd1 100644 --- a/tachyon/files/usr/lib/service/watchdog.uc +++ b/tachyon/files/usr/lib/service/watchdog.uc @@ -1885,16 +1885,17 @@ function worker() { let log_pipe = setup_syslog_listener(); let ubus_conn = setup_ubus_listener(); + let current_ctx = null; function perform_fast_checks() { - controller.probe_fast(); + controller.probe_fast(current_ctx); // After the probes, so a recovery observed on this very tick closes its // watch before the deadline is tested against it. safe_call(settle_expired_recoveries, "settle_expired_recoveries"); } function perform_normal_checks() { - controller.probe_normal(); + controller.probe_normal(current_ctx); safe_call(smart_detect_process_pending, "smart_detect_process_pending"); safe_call(export_metrics, "export_metrics"); safe_call(ai_export_status, "ai_export_status"); @@ -2091,29 +2092,37 @@ function check_section_failover() { } function perform_slow_checks() { - controller.probe_slow(); + controller.probe_slow(current_ctx); safe_call(ai_heal_dns_loop, "ai_heal_dns_loop"); safe_call(check_mixed_proxy_port, "check_mixed_proxy_port"); safe_call(check_telegram_worker, "check_telegram_worker"); safe_call(check_tailscale_worker, "check_tailscale_worker"); safe_call(check_section_failover, "check_section_failover"); } + let last_keepalive_write = 0; if (uloop) { let tick; tick = function() { let now = time(); try { - write_state_file("/var/run/tachyon_watchdog.keepalive", as_string(time()), "keepalive stamp"); + if (now - last_keepalive_write >= 30) { + last_keepalive_write = now; + write_state_file("/var/run/tachyon_watchdog.keepalive", as_string(now), "keepalive stamp"); + } + current_ctx = null; if (now - last_fast_check >= 15) { last_fast_check = now; + current_ctx = current_ctx || (controller.create_tick_context ? controller.create_tick_context() : null); perform_fast_checks(); } if (now - last_normal_check >= controller.adaptive_normal_interval()) { last_normal_check = now; + current_ctx = current_ctx || (controller.create_tick_context ? controller.create_tick_context() : null); perform_normal_checks(); } if (now - last_slow_check >= 300) { last_slow_check = now; + current_ctx = current_ctx || (controller.create_tick_context ? controller.create_tick_context() : null); perform_slow_checks(); } } catch (e) { @@ -2132,6 +2141,7 @@ function check_section_failover() { signal("SIGTERM", function(sig) { log_message("SIGTERM received, shutting down", "info"); stop_runtime(); exit(0); }); signal("SIGINT", function(sig) { log_message("SIGINT received, shutting down", "info"); stop_runtime(); exit(0); }); while (true) { + current_ctx = controller.create_tick_context ? controller.create_tick_context() : null; perform_fast_checks(); perform_normal_checks(); perform_slow_checks(); diff --git a/tachyon/files/usr/lib/singbox/generator.uc b/tachyon/files/usr/lib/singbox/generator.uc index 103e3df7..d59faba4 100644 --- a/tachyon/files/usr/lib/singbox/generator.uc +++ b/tachyon/files/usr/lib/singbox/generator.uc @@ -682,16 +682,6 @@ function add_mixed_proxy_for_section(config, section, service_address) { type(config.route.rules[0].inbound) == "array") push(config.route.rules[0].inbound, inbound.tag); - push(config.route.rules, { - action: "hijack-dns", - inbound: inbound.tag, - port: 53 - }); - push(config.route.rules, { - action: "hijack-dns", - inbound: inbound.tag, - protocol: "dns" - }); push(config.route.rules, { action: "route", inbound: inbound.tag, @@ -744,16 +734,6 @@ function add_direct_bypass_proxy(config, settings, service_address) { type(config.route.rules[0].inbound) == "array") push(config.route.rules[0].inbound, inbound_tag); - push(config.route.rules, { - action: "hijack-dns", - inbound: inbound_tag, - port: 53 - }); - push(config.route.rules, { - action: "hijack-dns", - inbound: inbound_tag, - protocol: "dns" - }); push(config.route.rules, { action: "route", inbound: inbound_tag, @@ -779,16 +759,6 @@ function add_service_mixed_proxy_inbound(config, tag_name, listen_port, outbound type(config.route.rules[0].inbound) == "array") push(config.route.rules[0].inbound, tag_name); - push(config.route.rules, { - action: "hijack-dns", - inbound: tag_name, - port: 53 - }); - push(config.route.rules, { - action: "hijack-dns", - inbound: tag_name, - protocol: "dns" - }); push(config.route.rules, { action: "route", inbound: tag_name, diff --git a/tachyon/files/usr/lib/torrserver/direct.uc b/tachyon/files/usr/lib/torrserver/direct.uc index 14471525..a47f5ccf 100644 --- a/tachyon/files/usr/lib/torrserver/direct.uc +++ b/tachyon/files/usr/lib/torrserver/direct.uc @@ -54,10 +54,24 @@ function dedicated_cgroup(path) { } return found > 0; } +let cached_pid = ""; + function discover() { + if (cached_pid != "") { + let cmdline_path = "/proc/" + cached_pid + "/cmdline"; + if (is_torrserver_cmdline(read(cmdline_path))) { + let path = process_cgroup(cached_pid); + if (valid_cgroup(path) && dedicated_cgroup(path)) + return { running: 1, available: 1, pid: cached_pid, cgroup: path }; + return { running: 1, available: 0, pid: cached_pid, cgroup: path }; + } + cached_pid = ""; + } + for (let cmdline_path in fs.glob("/proc/[0-9]*/cmdline")) { let pid = numeric_pid(cmdline_path); if (pid == "" || !is_torrserver_cmdline(read(cmdline_path))) continue; + cached_pid = pid; let path = process_cgroup(pid); if (valid_cgroup(path) && dedicated_cgroup(path)) return { running: 1, available: 1, pid, cgroup: path }; @@ -129,7 +143,7 @@ function worker() { remove_rule(); last_cgroup = ""; } - system("sleep 60"); + sleep(60000); } remove_rule(); } diff --git a/tests/sing_box_runtime.sh b/tests/sing_box_runtime.sh index 5d6ac8df..7ff8a19f 100644 --- a/tests/sing_box_runtime.sh +++ b/tests/sing_box_runtime.sh @@ -1301,8 +1301,7 @@ assert(outbound(matchers, "bypass-out").type == "direct", "bypass fallback outbo assert(outbound(matchers, "proxy-1-out").detour == "detour-out", "connection URL outbound detour"); let mixed = inbound(matchers, "proxy-mixed-in"); assert(mixed && mixed.listen_port == 19090 && mixed.users[0].username == "user", "mixed inbound auth"); -assert(route_rule(matchers, r => r.inbound == "proxy-mixed-in" && r.action == "hijack-dns" && r.port == 53) != null, "mixed inbound hijack-dns port 53"); -assert(route_rule(matchers, r => r.inbound == "proxy-mixed-in" && r.action == "hijack-dns" && r.protocol == "dns") != null, "mixed inbound hijack-dns protocol dns"); +assert(route_rule(matchers, r => r.inbound == "proxy-mixed-in" && r.action == "hijack-dns") == null, "mixed inbound must not hijack DNS"); assert(route_rule(matchers, r => r.inbound == "proxy-mixed-in" && r.ip_is_private == true && r.outbound == "direct-out") != null, "mixed inbound private IP bypass"); assert(route_rule(matchers, r => r.inbound == "proxy-mixed-in" && r.outbound == "proxy-out") != null, "mixed inbound route"); assert(dns_rule(matchers, r => contains(r.domain_suffix, "example.org")).server == "dns-server", "bypass domain real DNS rule"); diff --git a/tests/watchdog_adaptive_intervals.sh b/tests/watchdog_adaptive_intervals.sh new file mode 100644 index 00000000..6bf6fb8e --- /dev/null +++ b/tests/watchdog_adaptive_intervals.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +CONTROLLER_UC="$TACHYON_LIB/service/event_controller.uc" +WATCHDOG_UC="$TACHYON_LIB/service/watchdog.uc" +UI_UC="$TACHYON_LIB/service/ui.uc" +TG_UC="$TACHYON_LIB/service/telegram.uc" +DIRECT_UC="$TACHYON_LIB/torrserver/direct.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local actual="$1" expected="$2" label="$3" + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +# --- 1. Static contract inspections --- + +# Watchdog keepalive write throttle +grep -Fq 'last_keepalive_write' "$WATCHDOG_UC" || + fail "watchdog.uc must track last_keepalive_write" +grep -Fq 'now - last_keepalive_write >= 30' "$WATCHDOG_UC" || + fail "watchdog.uc must throttle keepalive write to 30s" + +# Shared tick_context in watchdog +grep -Fq 'controller.create_tick_context' "$WATCHDOG_UC" || + fail "watchdog.uc must call controller.create_tick_context" +grep -Fq 'controller.probe_fast(current_ctx)' "$WATCHDOG_UC" || + fail "watchdog.uc perform_fast_checks must pass current_ctx to probe_fast" +grep -Fq 'controller.probe_normal(current_ctx)' "$WATCHDOG_UC" || + fail "watchdog.uc perform_normal_checks must pass current_ctx to probe_normal" +grep -Fq 'controller.probe_slow(current_ctx)' "$WATCHDOG_UC" || + fail "watchdog.uc perform_slow_checks must pass current_ctx to probe_slow" + +# Event controller exports and methods +grep -Fq 'create_tick_context' "$CONTROLLER_UC" || + fail "event_controller.uc must define create_tick_context" +grep -Fq 'ai_proxy_health_interval' "$CONTROLLER_UC" || + fail "event_controller.uc must respect ai_proxy_health_interval" +grep -Fq 'ai_dns_interval' "$CONTROLLER_UC" || + fail "event_controller.uc must respect ai_dns_interval" + +# UI state read-only fast path +grep -Fq 'ACTION_DIRS_REFRESH_STAMP_FILE' "$UI_UC" || + fail "ui.uc must define ACTION_DIRS_REFRESH_STAMP_FILE" +grep -Fq 'maybe_refresh_action_dirs' "$UI_UC" || + fail "ui.uc must define maybe_refresh_action_dirs" +grep -Fq 'maybe_refresh_action_dirs(false)' "$UI_UC" || + fail "current_ui_state_json in ui.uc must call maybe_refresh_action_dirs(false)" + +# Telegram 50s long-poll and no post-success sleep +grep -Fq 'timeout: 50' "$TG_UC" || + fail "telegram.uc must use 50s timeout for getUpdates long-poll" +grep -Fq 'max_time = is_poll ? "65"' "$TG_UC" || + fail "telegram.uc must set max_time to 65 for poll requests" + +# TorrServer direct bypass PID cache and native sleep +grep -Fq 'cached_pid' "$DIRECT_UC" || + fail "direct.uc must cache TorrServer PID" +grep -Fq 'sleep(60000)' "$DIRECT_UC" || + fail "direct.uc worker must use native sleep(60000)" + +# --- 2. Behavioral verification via ucode --- +run_ucode() { + local code="$1" + local tmp + tmp="$(mktemp "${TMPDIR:-/tmp}/tachyon_test_XXXXXX")" + printf '%s\n' "$code" > "$tmp" + ucode -L "$TACHYON_LIB" "$tmp" + rm -f "$tmp" +} + +# Verify create_tick_context structure +res="$(run_ucode ' +let events = require("core.events"); +let ec = require("service.event_controller"); +let b = events.bus(); +let c = ec.controller(b, {}); +let ctx = c.create_tick_context(); +print((ctx.now > 0) + "," + (type(ctx.settings) == "object") + "," + (ctx.proxy_port != null) + "," + (ctx.reload_in_progress != null)); +')" +assert_eq "$res" "true,true,true,true" "tick_context fields are populated" + +# Verify reset_proxy_consecutive and reset_dns_consecutive clear timestamps +res="$(run_ucode ' +let events = require("core.events"); +let ec = require("service.event_controller"); +let b = events.bus(); +let c = ec.controller(b, {}); +c.state.last_proxy_probe = 12345; +c.state.proxy_consecutive_fails = 2; +c.reset_proxy_consecutive(); +let p_ok = (c.state.last_proxy_probe == 0 && c.state.proxy_consecutive_fails == 0); + +c.state.last_dns_probe = 54321; +c.state.dns_consecutive_fails = 3; +c.reset_dns_consecutive(); +let d_ok = (c.state.last_dns_probe == 0 && c.state.dns_consecutive_fails == 0); + +print(p_ok + "," + d_ok); +')" +assert_eq "$res" "true,true" "reset callbacks clear consecutive fails and probe timestamps" + +# Verify adaptive interval throttling on probe_proxy +res="$(run_ucode ' +let events = require("core.events"); +let ec = require("service.event_controller"); +let b = events.bus(); +b.on("proxy.down", function(){}); +b.on("proxy.up", function(){}); + +let c = ec.controller(b, {}); +let now = 1000000; +let ctx = { + now: now, + settings: { ai_proxy_health_interval: "30" }, + reload_in_progress: false, + singbox_pid: "99999", + singbox_running: true, + proxy_port: "4534", + proxy_host: "127.0.0.1" +}; + +// Set last probe to 10 seconds ago with 0 consecutive fails (healthy) +c.state.last_proxy_probe = now - 10; +c.state.proxy_consecutive_fails = 0; +c.probe_fast(ctx); +// Should be throttled (skipped): last_proxy_probe should NOT have changed to now +let throttled = (c.state.last_proxy_probe == now - 10); + +// Now simulate fail state (consecutive fails > 0): should NOT be throttled +c.state.proxy_consecutive_fails = 1; +// Mock curl so probe executes quickly without real network +// Running probe_fast with consecutive fails > 0 triggers probe immediately +print(throttled); +')" +assert_eq "$res" "true" "healthy proxy probe is throttled within interval" + +# Verify adaptive interval throttling on probe_dns +res="$(run_ucode ' +let events = require("core.events"); +let ec = require("service.event_controller"); +let b = events.bus(); +b.on("dns.down", function(){}); +b.on("dns.up", function(){}); + +let c = ec.controller(b, {}); +let now = 1000000; +let ctx = { + now: now, + settings: { ai_dns_interval: "60" }, + reload_in_progress: false, + singbox_pid: "99999", + singbox_running: true, + proxy_port: "4534", + proxy_host: "127.0.0.1" +}; + +// Set last probe to 20 seconds ago with 0 consecutive fails (healthy) +c.state.last_dns_probe = now - 20; +c.state.dns_consecutive_fails = 0; +c.probe_fast(ctx); +// Should be throttled (skipped): last_dns_probe should NOT have changed to now +let throttled = (c.state.last_dns_probe == now - 20); +print(throttled); +')" +assert_eq "$res" "true" "healthy dns probe is throttled within interval" + +printf 'watchdog_adaptive_intervals checks passed\n'