release: 1.3.27 — Runtime performance, watchdog adaptive probes & mixed proxy DNS fix

- Perf(watchdog): Adaptive external health checks for proxy (30s) and DNS (60s)
  * Honor UCI ai_proxy_health_interval and ai_dns_interval while healthy
  * Adapt to fast retry (10-15s) upon consecutive failures, instant post-heal reset
  * Single shared observation context (tick_context) per tick eliminating redundant pgrep/proc scans
  * Throttle watchdog keepalive file writes from 5s to 30s
- Perf(ui): Read-only fast path for get_ui_state
  * Decouple refresh_action_dirs() filesystem GC from 500ms polling requests
  * Throttle action directory cleanup to once per 30s via /var/run stamp
  * Trigger immediate GC sweep on action job completion
- Perf(telegram): Long-poll 50s and eliminate redundant sleep after success
  * Increase getUpdates long-poll timeout to 50s (curl timeout 65s)
  * Remove redundant sleep after successful poll for instant command responsiveness
- Perf(torrserver): Cache detected PID and replace shell sleep with native ucode sleep(60000)
- Fix(singbox): Remove harmful hijack-dns rules from mixed proxy inbounds
  * Eliminate hijack-dns (port 53 / protocol dns) from section, direct bypass and service mixed proxies
  * Resolve 4-5s curl timeouts on component updates (api.github.com) and slow LAN DNS resolution
  * Maintain ip_is_private: true direct LAN routing bypass
- Test: Add tests/watchdog_adaptive_intervals.sh and update tests/sing_box_runtime.sh
- Version: Bump fe-app-tachyon to 1.3.27
This commit is contained in:
Dushnilin
2026-09-15 08:20:35 +03:00
parent bc0928c277
commit 1c7feb4aac
10 changed files with 337 additions and 85 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "fe-app-tachyon", "name": "fe-app-tachyon",
"version": "1.3.26", "version": "1.3.27",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fe-app-tachyon", "name": "fe-app-tachyon",
"version": "1.3.26", "version": "1.3.27",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/parser": "7.28.4", "@babel/parser": "7.28.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fe-app-tachyon", "name": "fe-app-tachyon",
"version": "1.3.26", "version": "1.3.27",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
+110 -38
View File
@@ -404,6 +404,8 @@ function controller(bus, opts) {
dns_consecutive_fails: 0, dns_consecutive_fails: 0,
dns_fail_streak: 0, dns_fail_streak: 0,
dns_streak_stamp: 0, dns_streak_stamp: 0,
last_proxy_probe: 0,
last_dns_probe: 0,
proxy_latency_history: [], proxy_latency_history: [],
dns_latency_history: [], dns_latency_history: [],
syslog_start_time: 0, syslog_start_time: 0,
@@ -415,6 +417,7 @@ function controller(bus, opts) {
}; };
let self = { EV: EV, state: state }; let self = { EV: EV, state: state };
let current_tick_ctx = null;
// Keeps the last 20 samples. Bounded so a long-lived watchdog cannot grow // Keeps the last 20 samples. Bounded so a long-lived watchdog cannot grow
// its heap through the history array. // its heap through the history array.
@@ -500,22 +503,48 @@ function controller(bus, opts) {
} }
self.is_paused = probe_pause; 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 ────────────────────────────────────────────── // ── Probe: sing-box liveness ──────────────────────────────────────────────
// The expensive /proc scan only runs once the cheap pidfile/ubus/pidof // The expensive /proc scan only runs once the cheap pidfile/ubus/pidof
// path has already failed, preserving the original fast-path ordering. // path has already failed, preserving the original fast-path ordering.
function probe_singbox() { function probe_singbox() {
let ctx = current_tick_ctx || create_tick_context();
if (setting("recovery_bypass", "0") == "1") return; if (setting("recovery_bypass", "0") == "1") return;
// An active pause means sing-box is stopped on purpose. The original // An active pause means sing-box is stopped on purpose.
// check_singbox_process() consulted the pause on every fast tick, so it if (ctx.is_paused) return;
// is consulted here too: leaving it to the normal tier alone would open if (ctx.reload_in_progress) return;
// a window of up to one normal interval in which a deliberate pause is if (ctx.list_update_running) return;
// reported as a stop.
if (probe_pause()) return;
if (check_tachyon_cli_running()) return;
if (is_list_update_running()) return;
let pid = get_sing_box_pid(); let pid = ctx.singbox_pid;
if (pid != "" && process_running(pid, "sing-box")) return; if (pid != "" && ctx.singbox_running) return;
// No configured sections means sing-box is legitimately absent. // No configured sections means sing-box is legitimately absent.
let has_sections = false; let has_sections = false;
@@ -542,20 +571,33 @@ function controller(bus, opts) {
// subscriber. The probe only skips when measuring is pointless or its // subscriber. The probe only skips when measuring is pointless or its
// result would be misleading. // result would be misleading.
function probe_proxy() { function probe_proxy() {
let ctx = current_tick_ctx || create_tick_context();
if (!bus.has(EV.PROXY_DOWN) && !bus.has(EV.PROXY_UP)) return; 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 // A reload tears the proxy down on purpose; a sample taken now would
// land in the latency history as a fault that never happened. // 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(); let pid = ctx.singbox_pid;
if (pid == "" || !process_running(pid, "sing-box")) return; if (pid == "" || !ctx.singbox_running) return;
let port = proxy_port(); let port = proxy_port();
// No http/mixed inbound in the generated config: there is nothing to // 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. // measure, so the proxy must not be declared broken on a dead port.
if (port == "") return; if (port == "") return;
let host = proxy_host(); let host = ctx.proxy_host;
let check_url = setting("ai_proxy_health_url", "https://cp.cloudflare.com/generate_204");
// 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 started = time();
let ok = command_success_from_args([ 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 // As with the proxy probe, one resolution attempt serves both DNS
// subscribers; each carries its own enable flag and its own threshold. // subscribers; each carries its own enable flag and its own threshold.
function probe_dns() { function probe_dns() {
let ctx = current_tick_ctx || create_tick_context();
if (!bus.has(EV.DNS_DOWN) && !bus.has(EV.DNS_UP)) return; if (!bus.has(EV.DNS_DOWN) && !bus.has(EV.DNS_UP)) return;
if (setting("recovery_bypass", "0") == "1") return; if (setting("recovery_bypass", "0") == "1") return;
// A reload restarts the resolver; failures during it are expected and // A reload restarts the resolver; failures during it are expected and
// must not accumulate, so the streak resets exactly as before. // must not accumulate, so the streak resets exactly as before.
if (is_reload_in_progress()) { if (ctx.reload_in_progress) {
clear_dns_streak(); clear_dns_streak();
return; return;
} }
let pid = get_sing_box_pid(); let pid = ctx.singbox_pid;
if (pid == "" || !process_running(pid, "sing-box")) { if (pid == "" || !ctx.singbox_running) {
clear_dns_streak(); clear_dns_streak();
return; 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 started = time();
let ok = is_dns_working(); let ok = is_dns_working();
let elapsed = (time() - started) * 1000; let elapsed = (time() - started) * 1000;
@@ -703,13 +758,14 @@ function controller(bus, opts) {
// ── Probe: nftables table and QoS marks ─────────────────────────────────── // ── Probe: nftables table and QoS marks ───────────────────────────────────
function probe_nftables() { function probe_nftables() {
let ctx = current_tick_ctx || create_tick_context();
if (setting("recovery_bypass", "0") == "1") return; if (setting("recovery_bypass", "0") == "1") return;
if (fs.stat("/var/run/tachyon/native_internet_restored") != null) return; if (fs.stat("/var/run/tachyon/native_internet_restored") != null) return;
if (is_list_update_running()) return; if (ctx.list_update_running) return;
if (is_reload_in_progress()) return; if (ctx.reload_in_progress) return;
let nft_table = getenv("NFT_TABLE_NAME") || "TachyonTable"; 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", let out_nft = command_output_from_args(["sh", "-c",
"nft list table inet " + nft_table + " | grep -E 'chain|tproxy|priority_rules|dscp'; exit 0"]); "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 (qos_pref == "0") return;
if ((index(out_nft, "dscp set 0x2e") < 0 && index(out_nft, "dscp set ef") < 0) || 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)) (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 ─────────────────────────────────────────── // ── Probe: TPROXY port liveness ───────────────────────────────────────────
function probe_tproxy() { function probe_tproxy() {
let ctx = current_tick_ctx || create_tick_context();
if (setting("recovery_bypass", "0") == "1") return; if (setting("recovery_bypass", "0") == "1") return;
if (fs.stat("/var/run/tachyon/native_internet_restored") != null) 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(); let pid = ctx.singbox_pid;
if (pid == "" || !process_running(pid, "sing-box")) return; if (pid == "" || !ctx.singbox_running) return;
let port = tproxy_port(); let port = tproxy_port();
// /proc/net/tcp encodes the local port in hex; 0A is TCP_LISTEN. // /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 // that is in progress are not counted, and a healthy pass resets the
// streak so one transient gap cannot accumulate into a false trigger. // streak so one transient gap cannot accumulate into a false trigger.
function probe_wan() { 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; state.wan_fail_streak = 0;
return; return;
} }
@@ -973,12 +1031,13 @@ function controller(bus, opts) {
// ── Probe: community subnet nft sets ────────────────────────────────────── // ── Probe: community subnet nft sets ──────────────────────────────────────
function probe_subnet_sets() { function probe_subnet_sets() {
let ctx = current_tick_ctx || create_tick_context();
if (setting("recovery_bypass", "0") == "1") return; if (setting("recovery_bypass", "0") == "1") return;
if (is_reload_in_progress()) return; if (ctx.reload_in_progress) return;
if (is_list_update_running()) return; if (ctx.list_update_running) return;
let pid = get_sing_box_pid(); let pid = ctx.singbox_pid;
if (pid == "" || !process_running(pid, "sing-box")) return; if (pid == "" || !ctx.singbox_running) return;
let nft_table = getenv("NFT_TABLE_NAME") || "TachyonTable"; let nft_table = getenv("NFT_TABLE_NAME") || "TachyonTable";
let all_sections = uci_core.get_all(CONFIG_NAME); 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 ────── // ── Probe: proxy sections with subscriptions but no usable outbounds ──────
function probe_empty_sections() { function probe_empty_sections() {
let ctx = current_tick_ctx || create_tick_context();
if (!enabled("ai_section_failover_enabled", "1")) return; 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 connections = require("config.connections");
let sections = common.object_or_empty(uci_core.get_all(CONFIG_NAME)); 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 // Checks if nfqws/nfqws2 supervisor processes are alive by counting PID
// files. A mismatch means the DPI bypass engine crashed or was killed. // files. A mismatch means the DPI bypass engine crashed or was killed.
function probe_nfqueue() { function probe_nfqueue() {
let ctx = current_tick_ctx || create_tick_context();
if (!bus.has(EV.NFQUEUE_DOWN) && !bus.has(EV.NFQUEUE_UP)) return; 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 = [ let pid_dirs = [
"/var/run/tachyon/zapret/child-pid", "/var/run/tachyon/zapret/child-pid",
@@ -1186,17 +1247,21 @@ function controller(bus, opts) {
// event. // event.
self.handle_ubus_firewall_reload = function() { self.handle_ubus_firewall_reload = function() {
bus.emit(EV.FIREWALL_RELOADED, {}); bus.emit(EV.FIREWALL_RELOADED, {});
current_tick_ctx = create_tick_context();
run_probe(probe_nftables, "nftables"); run_probe(probe_nftables, "nftables");
run_probe(probe_subnet_sets, "subnet_sets"); run_probe(probe_subnet_sets, "subnet_sets");
return true; 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_singbox, "singbox");
run_probe(probe_proxy, "proxy"); 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_pause, "pause");
run_probe(probe_memory, "memory"); run_probe(probe_memory, "memory");
run_probe(probe_rpcd, "rpcd"); run_probe(probe_rpcd, "rpcd");
@@ -1204,7 +1269,8 @@ function controller(bus, opts) {
run_probe(probe_tproxy, "tproxy"); 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_subnet_sets, "subnet_sets");
run_probe(probe_wan, "wan"); run_probe(probe_wan, "wan");
run_probe(probe_subscription, "subscription"); 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 // their counter right after restarting, so that a restart-in-flight would
// not immediately trip the same threshold again on the next tick. // not immediately trip the same threshold again on the next tick.
self.reset_dns_streak = clear_dns_streak; self.reset_dns_streak = clear_dns_streak;
self.reset_dns_consecutive = function() { state.dns_consecutive_fails = 0; }; self.reset_dns_consecutive = function() {
self.reset_proxy_consecutive = function() { state.proxy_consecutive_fails = 0; }; 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 // Latency history and streak counters feed ai-status-full and the metrics
// export, which are still owned by the watchdog. // export, which are still owned by the watchdog.
+4 -5
View File
@@ -276,8 +276,8 @@ function tg_request_via(token, method, payload, proxy_args) {
// getUpdates long-poll on reboot or watchdog restart). // getUpdates long-poll on reboot or watchdog restart).
let body = sprintf("%J", payload); let body = sprintf("%J", payload);
let is_poll = (method == "getUpdates"); let is_poll = (method == "getUpdates");
let max_time = is_poll ? "35" : "12"; let max_time = is_poll ? "65" : "12";
let conn_timeout = is_poll ? "10" : "5"; let conn_timeout = is_poll ? "15" : "5";
let args = [ "curl", "-s", "-m", max_time, "--connect-timeout", conn_timeout, let args = [ "curl", "-s", "-m", max_time, "--connect-timeout", conn_timeout,
"-X", "POST", "-H", "Content-Type: application/json", "-X", "POST", "-H", "Content-Type: application/json",
"-d", body ]; "-d", body ];
@@ -2832,7 +2832,7 @@ function dispatch_command(token, chat_id, text, msg_id) {
function process_updates(token, admin_ids) { function process_updates(token, admin_ids) {
let offset = int(trim(fs.readfile(OFFSET_FILE) || "0")); 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 (!res || !res.ok || !res.result) return false;
if (length(res.result) == 0) return true; if (length(res.result) == 0) return true;
@@ -3317,9 +3317,8 @@ function worker() {
} catch (e) { } catch (e) {
consecutive_failures++; consecutive_failures++;
command_success_from_args(["logger", "-t", "tachyon-telegram", "[err] Worker loop error: " + as_string(e)]); 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; return 0;
} }
+16 -2
View File
@@ -646,6 +646,7 @@ function set_running_job_pid_file(path, pid) {
} }
function write_finished_action_state(path, success, message, exit_code) { 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())); 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"); 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() { function active_service_action_default() {
refresh_action_dirs(); refresh_action_dirs();
active_service_action(SERVICE_ACTION_DIR); active_service_action(SERVICE_ACTION_DIR);
@@ -755,7 +770,6 @@ function action_state_from_dir(dir) {
} }
function action_state_from_dirs() { function action_state_from_dirs() {
refresh_action_dirs();
return { return {
service: action_state_from_dir(SERVICE_ACTION_DIR), service: action_state_from_dir(SERVICE_ACTION_DIR),
latency: action_state_from_dir(LATENCY_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() { function current_ui_state_json() {
refresh_action_dirs(); maybe_refresh_action_dirs(false);
let capabilities = capability_flags(); let capabilities = capability_flags();
let tachyon_is_running = tachyon_running() ? 1 : 0; let tachyon_is_running = tachyon_running() ? 1 : 0;
+14 -4
View File
@@ -1885,16 +1885,17 @@ function worker() {
let log_pipe = setup_syslog_listener(); let log_pipe = setup_syslog_listener();
let ubus_conn = setup_ubus_listener(); let ubus_conn = setup_ubus_listener();
let current_ctx = null;
function perform_fast_checks() { 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 // After the probes, so a recovery observed on this very tick closes its
// watch before the deadline is tested against it. // watch before the deadline is tested against it.
safe_call(settle_expired_recoveries, "settle_expired_recoveries"); safe_call(settle_expired_recoveries, "settle_expired_recoveries");
} }
function perform_normal_checks() { function perform_normal_checks() {
controller.probe_normal(); controller.probe_normal(current_ctx);
safe_call(smart_detect_process_pending, "smart_detect_process_pending"); safe_call(smart_detect_process_pending, "smart_detect_process_pending");
safe_call(export_metrics, "export_metrics"); safe_call(export_metrics, "export_metrics");
safe_call(ai_export_status, "ai_export_status"); safe_call(ai_export_status, "ai_export_status");
@@ -2091,29 +2092,37 @@ function check_section_failover() {
} }
function perform_slow_checks() { function perform_slow_checks() {
controller.probe_slow(); controller.probe_slow(current_ctx);
safe_call(ai_heal_dns_loop, "ai_heal_dns_loop"); safe_call(ai_heal_dns_loop, "ai_heal_dns_loop");
safe_call(check_mixed_proxy_port, "check_mixed_proxy_port"); safe_call(check_mixed_proxy_port, "check_mixed_proxy_port");
safe_call(check_telegram_worker, "check_telegram_worker"); safe_call(check_telegram_worker, "check_telegram_worker");
safe_call(check_tailscale_worker, "check_tailscale_worker"); safe_call(check_tailscale_worker, "check_tailscale_worker");
safe_call(check_section_failover, "check_section_failover"); safe_call(check_section_failover, "check_section_failover");
} }
let last_keepalive_write = 0;
if (uloop) { if (uloop) {
let tick; let tick;
tick = function() { tick = function() {
let now = time(); let now = time();
try { 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) { if (now - last_fast_check >= 15) {
last_fast_check = now; last_fast_check = now;
current_ctx = current_ctx || (controller.create_tick_context ? controller.create_tick_context() : null);
perform_fast_checks(); perform_fast_checks();
} }
if (now - last_normal_check >= controller.adaptive_normal_interval()) { if (now - last_normal_check >= controller.adaptive_normal_interval()) {
last_normal_check = now; last_normal_check = now;
current_ctx = current_ctx || (controller.create_tick_context ? controller.create_tick_context() : null);
perform_normal_checks(); perform_normal_checks();
} }
if (now - last_slow_check >= 300) { if (now - last_slow_check >= 300) {
last_slow_check = now; last_slow_check = now;
current_ctx = current_ctx || (controller.create_tick_context ? controller.create_tick_context() : null);
perform_slow_checks(); perform_slow_checks();
} }
} catch (e) { } 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("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); }); signal("SIGINT", function(sig) { log_message("SIGINT received, shutting down", "info"); stop_runtime(); exit(0); });
while (true) { while (true) {
current_ctx = controller.create_tick_context ? controller.create_tick_context() : null;
perform_fast_checks(); perform_fast_checks();
perform_normal_checks(); perform_normal_checks();
perform_slow_checks(); perform_slow_checks();
@@ -682,16 +682,6 @@ function add_mixed_proxy_for_section(config, section, service_address) {
type(config.route.rules[0].inbound) == "array") type(config.route.rules[0].inbound) == "array")
push(config.route.rules[0].inbound, inbound.tag); 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, { push(config.route.rules, {
action: "route", action: "route",
inbound: inbound.tag, inbound: inbound.tag,
@@ -744,16 +734,6 @@ function add_direct_bypass_proxy(config, settings, service_address) {
type(config.route.rules[0].inbound) == "array") type(config.route.rules[0].inbound) == "array")
push(config.route.rules[0].inbound, inbound_tag); 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, { push(config.route.rules, {
action: "route", action: "route",
inbound: inbound_tag, 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") type(config.route.rules[0].inbound) == "array")
push(config.route.rules[0].inbound, tag_name); 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, { push(config.route.rules, {
action: "route", action: "route",
inbound: tag_name, inbound: tag_name,
+15 -1
View File
@@ -54,10 +54,24 @@ function dedicated_cgroup(path) {
} }
return found > 0; return found > 0;
} }
let cached_pid = "";
function discover() { 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")) { for (let cmdline_path in fs.glob("/proc/[0-9]*/cmdline")) {
let pid = numeric_pid(cmdline_path); let pid = numeric_pid(cmdline_path);
if (pid == "" || !is_torrserver_cmdline(read(cmdline_path))) continue; if (pid == "" || !is_torrserver_cmdline(read(cmdline_path))) continue;
cached_pid = pid;
let path = process_cgroup(pid); let path = process_cgroup(pid);
if (valid_cgroup(path) && dedicated_cgroup(path)) if (valid_cgroup(path) && dedicated_cgroup(path))
return { running: 1, available: 1, pid, cgroup: path }; return { running: 1, available: 1, pid, cgroup: path };
@@ -129,7 +143,7 @@ function worker() {
remove_rule(); remove_rule();
last_cgroup = ""; last_cgroup = "";
} }
system("sleep 60"); sleep(60000);
} }
remove_rule(); remove_rule();
} }
+1 -2
View File
@@ -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"); assert(outbound(matchers, "proxy-1-out").detour == "detour-out", "connection URL outbound detour");
let mixed = inbound(matchers, "proxy-mixed-in"); let mixed = inbound(matchers, "proxy-mixed-in");
assert(mixed && mixed.listen_port == 19090 && mixed.users[0].username == "user", "mixed inbound auth"); 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") == null, "mixed inbound must not hijack DNS");
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.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.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(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"); assert(dns_rule(matchers, r => contains(r.domain_suffix, "example.org")).server == "dns-server", "bypass domain real DNS rule");
+174
View File
@@ -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'