static VISITOR_REPLY_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
static VISITOR_CHANNELS: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<u64, std::sync::mpsc::SyncSender<Option<String>>>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
// Bound on how long a visitor callback waits for the Elixir-side handler to reply before
// giving up and falling back to the method's default result. Mirrors the trait-bridge
// watchdog (`arm_trait_call_timeout` in `trait_support_nifs.rs.jinja`): if the calling
// process has already exited, `send_and_clear` is a silent no-op in the BEAM and no reply
// ever arrives, so an unbounded `rx.recv()` would block the OS thread forever. ~keep
const VISITOR_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Spawn a watchdog that abandons `ref_id` if it is still pending after
// `VISITOR_CALL_TIMEOUT`. A no-op if the call already completed (`visitor_reply` removes
// the entry first). Dropping the sender makes the blocked `rx.recv()` in
// `visitor_send_and_wait` return `Err`, which `.ok().flatten()` already turns into `None`
// (the same "no reply" fallback used when the channel closes for any other reason). ~keep
fn arm_visitor_call_timeout(ref_id: u64) {
std::thread::spawn(move || {
std::thread::sleep(VISITOR_CALL_TIMEOUT);
if let Ok(mut channels) = VISITOR_CHANNELS.lock()
&& channels.remove(&ref_id).is_some()
{
tracing::warn!(
ref_id,
timeout = ?VISITOR_CALL_TIMEOUT,
"visitor callback timed out (host process may have exited without replying)"
);
}
});
}