#!/usr/bin/env ucode // // Event bus: publish/subscribe with per-subscriber cooldown, publish-side // deduplication, deterministic priority ordering and handler error isolation. // // The bus is deliberately domain-agnostic: it knows nothing about sing-box, // UCI or reload locks. Observation belongs to service/event_controller.uc, // remediation to service/watchdog.uc. Keeping this file free of I/O is what // makes it directly unit-testable (see tests/event_bus.sh). // // Timing uses clock(true) (CLOCK_MONOTONIC) rather than time(): cooldown and // dedup windows must not be skewed by an NTP step, which is a real event on // routers that boot before the network is up. // Monotonic milliseconds. Independent of wall-clock jumps. function now_ms() { let c = clock(true); if (type(c) != "array" || length(c) < 2) return 0; return c[0] * 1000 + int(c[1] / 1000000); } let common = require("core.common"); let as_string = common.as_string; function bus() { let handlers = {}; // type -> array of subscriber records let last_emit = {}; // dedup key -> monotonic ms of last accepted emit let counters = { emitted: 0, // events accepted onto the bus suppressed: 0, // emit_once calls dropped as duplicates delivered: 0, // handler invocations that ran to completion skipped: 0, // handler invocations skipped by cooldown failed: 0 // handler invocations that threw }; let error_sink = null; // optional function(name, type, err) let self = {}; // Registers a handler. Options: // name — label used in error reports and stats (defaults to type) // cooldown — minimum seconds between two invocations of THIS handler. // Replaces the hand-rolled last_*_time globals. // priority — lower runs first; ties keep registration order. self.on = function(event_type, handler, opts) { event_type = as_string(event_type); if (event_type == "" || type(handler) != "function") return false; let options = (type(opts) == "object") ? opts : {}; let record = { handler: handler, name: as_string(options.name || event_type), cooldown: options.cooldown != null ? int(options.cooldown) : 0, priority: options.priority != null ? int(options.priority) : 50, last_run: -1, runs: 0 }; if (type(handlers[event_type]) != "array") handlers[event_type] = []; push(handlers[event_type], record); // Stable sort keeps registration order within one priority level, so // subscriber ordering stays reproducible across runs. sort(handlers[event_type], function(a, b) { return a.priority - b.priority; }); return true; }; // Reports a handler failure without letting it abort the remaining // subscribers. This is the isolation that safe_call() used to provide // around every individual check in the old watchdog loop. function report_error(name, event_type, err) { counters.failed++; if (type(error_sink) == "function") { try { error_sink(name, event_type, err); } catch (nested) { } } } // Installs the sink used to log handler failures. Kept injectable so the // bus itself never depends on a logging backend. self.on_error = function(sink) { error_sink = (type(sink) == "function") ? sink : null; }; function dispatch(event_type, event) { let subscribers = handlers[event_type]; if (type(subscribers) != "array") return 0; let ran = 0; let stamp = now_ms(); for (let record in subscribers) { // Cooldown is per subscriber, not per event type: two handlers on // the same fact may legitimately want different pacing. if (record.cooldown > 0 && record.last_run >= 0 && (stamp - record.last_run) < record.cooldown * 1000) { counters.skipped++; continue; } record.last_run = stamp; record.runs++; try { record.handler(event); counters.delivered++; ran++; } catch (err) { report_error(record.name, event_type, err); } } return ran; } // Publishes a fact. Returns the number of handlers that completed. self.emit = function(event_type, payload) { event_type = as_string(event_type); if (event_type == "") return 0; counters.emitted++; return dispatch(event_type, { type: event_type, payload: (type(payload) == "object") ? payload : {}, ts: time(), ms: now_ms() }); }; // Publishes a fact at most once per window_seconds. The dedup key is the // event type plus an optional caller-supplied discriminator, so // "same problem" can be defined per source (e.g. one key per domain). // Returns -1 when the emit was suppressed as a duplicate. self.emit_once = function(event_type, payload, window_seconds, key) { event_type = as_string(event_type); if (event_type == "") return -1; let window = window_seconds != null ? int(window_seconds) : 0; let dedup_key = event_type + "" + as_string(key); let stamp = now_ms(); if (window > 0) { let previous = last_emit[dedup_key]; if (previous != null && (stamp - int(previous)) < window * 1000) { counters.suppressed++; return -1; } } last_emit[dedup_key] = stamp; return self.emit(event_type, payload); }; // True when at least one handler is registered for the type. Lets sources // skip an expensive probe nobody is listening to. self.has = function(event_type) { let subscribers = handlers[as_string(event_type)]; return type(subscribers) == "array" && length(subscribers) > 0; }; // Drops dedup and cooldown state without unsubscribing. Used when the // world changed under us (config reload) and past timing is meaningless. self.reset_timers = function() { last_emit = {}; for (let event_type in keys(handlers)) { for (let record in handlers[event_type]) record.last_run = -1; } }; // Counter snapshot for ai-status-full. self.stats = function() { let subscriber_count = 0; for (let event_type in keys(handlers)) subscriber_count += length(handlers[event_type]); return { emitted: counters.emitted, suppressed: counters.suppressed, delivered: counters.delivered, skipped: counters.skipped, failed: counters.failed, types: length(keys(handlers)), subscribers: subscriber_count }; }; // Per-handler run counts, ordered by event type. Diagnostic aid: shows at // a glance which subscriber is actually firing in production. self.subscriber_runs = function() { let out = {}; for (let event_type in keys(handlers)) { for (let record in handlers[event_type]) out[record.name] = record.runs; } return out; }; return self; } function module_exports() { return { bus, now_ms }; } if (sourcepath(1) != null && sourcepath(1) != "") return module_exports(); // CLI surface exists for the shell test suite, which has no way to import a // ucode module directly. let mode = ARGV[0] || ""; if (mode == "selftest") { let b = bus(); let seen = []; b.on("t", function(ev) { push(seen, ev.type); }, { name: "probe" }); b.emit("t", {}); print(length(seen) == 1 && seen[0] == "t" ? "ok\n" : "fail\n"); exit(length(seen) == 1 ? 0 : 1); } else { warn("Usage: core/events.uc \n"); exit(1); }