This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

710 lines
27 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
"""
Ananta 注入器(只挂保龄球瓶 + 气球)—— 共享基础设施
★ 名字说明:这一份**不是**「统一加载器 AnantaUnifiedLoader」。
那份是全通道诊断版(A/B/C/D/E/F 都能开),在
C:\\Game\\Ananta_mainleakflow_keitaro_gg\\AnantaUnifiedLoader;
本目录是它的**最小副本**,日常跑服只挂 bowling + balloon 两个模块。
★ 由 AnantaTrainSeatLoader v5.0.0.4 的 loader_common.py 改写。保留它踩过的所有坑:
· 优雅停机三阶段(先让 agent 停手 → 等在途 RPC → 卸脚本)
· 卸载超时**绝不 detach**(半拆的会话会把游戏拖死)
· attach 走子线程 + 超时 detach(attach 本身可能永久阻塞)
· 单次 RPC 失败**不拆会话**,交给主循环打印真实原因 + 退避重连
★ 与单脚本版的唯一结构性差别:**一个 frida 会话里开多个 script**。
为什么必须这样(不是合并成一个大 JS):
- 每个 agent.js 顶层都是 `var Il2Cpp2;` + 一堆 IIFE,合并会撞全局命名空间;
- 更致命的是 world_mod.js / gadget_world.js / train_agent.js 都写
`rpc.exports = { ... }`(**整体覆盖**),合并后只有最后一个模块的导出会活下来;
- 分成多个 script:各自独立 realm,导出互不覆盖,且仍是**同一个会话**
(满足「同一时刻只能有一个 frida 会话」这条硬约束)。
"""
import sys, os, time, json, subprocess, atexit, ctypes, threading, re
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
_builtin_print = print
def print(*a, **k):
k["flush"] = True
try:
_builtin_print(*a, **k)
except Exception:
pass
STOP = {"ask": False, "done": False}
RPC_TIMEOUT = 25.0
ATTACH_TIMEOUT = 60.0
# ---- 超时治理(2026-09-29)----
# ⛑ frida 的同步 RPC 一旦卡住,Python 侧**没有任何办法**让那个调用返回:
# t.join(timeout) 只是「放弃等待」,线程仍卡在 _frida 的阻塞调用里,
# daemon 线程要到进程退出才回收 ⇒ 主循环每超时一次就永久多一个线程。
# 治理靠三层,见 Mod._run_guarded 的注释。
_STUCK_LOCK = threading.Lock()
_STUCK_THREADS = [] # 只保留**还活着**的卡住线程(每次统计前先清理)
# 同一个 script 连续超时这么多次 ⇒ 判定该 script 彻底废了,快速失败不再堆新调用
STUCK_MAX_CONSECUTIVE = 3
def stuck_thread_count():
"""当前卡在 frida 同步调用里、join 不回来的线程数(供上层观测/自检)。"""
with _STUCK_LOCK:
_STUCK_THREADS[:] = [x for x in _STUCK_THREADS if x.is_alive()]
return len(_STUCK_THREADS)
# 这些是「别的独立加载器 exe」。本统一加载器一开,它们就必须全部关闭,
# 否则同一个游戏进程上会有两个 frida 会话 —— 实测会把游戏带崩。
OTHER_EXES = [
"AnantaGymLoader.exe",
"AnantaTrainSeatLoader.exe",
"AnantaBowlingLoader.exe",
"AnantaBalloonLoader.exe",
"AnantaGadgetLoader.exe",
"AnantaWorldMod.exe",
"AnantaPropScanner.exe",
]
def _install_ctrl_handler():
if os.name != "nt":
return False
try:
k32 = ctypes.windll.kernel32
PROT = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_uint)
def handler(ev):
if ev not in (0, 1, 2, 5, 6):
return False
STOP["ask"] = True
try:
_atexit_close()
except Exception:
pass
for _ in range(280):
if STOP["done"]:
break
k32.Sleep(50)
return True
cb = PROT(handler)
handler._cb = cb
k32.SetConsoleCtrlHandler(cb, True)
return True
except Exception:
return False
HAS_CTRL = _install_ctrl_handler()
def sleep_slice(sec):
end = time.time() + sec
while time.time() < end:
if STOP["ask"]:
return
time.sleep(min(0.2, max(0.0, end - time.time())))
def ts():
return time.strftime("%H:%M:%S")
def _tasklist_rows():
"""全量 tasklist 一次,返回 [(进程名小写, pid)]。
⛑ 为什么不用 `tasklist /FI "IMAGENAME eq xxx"`:
实测(2026-09-28)游戏是提权启动的,`/FI` 过滤会直接回
「没有运行的任务匹配指定标准」,而**不带 filter 的全量列表里明明有**。
所以一律走全量解析,filter 那条路不可信。
"""
rows = []
try:
out = subprocess.check_output(
'tasklist /FO CSV /NH', shell=True,
text=True, encoding="mbcs", errors="replace")
except Exception:
return rows
for line in out.splitlines():
parts = [p.strip().strip('"') for p in line.split('","')]
if len(parts) < 2:
continue
try:
rows.append((parts[0].lower(), int(parts[1].replace(",", ""))))
except ValueError:
continue
return rows
def resolve_pid():
"""找 Ananta.exe 的 pid:全量 tasklist → frida 枚举(两道保险)。"""
for name, pid in _tasklist_rows():
if name in ("ananta.exe", "ananta"):
return pid
# 回退:连 tasklist 都看不到时(更严格的权限隔离),用 frida 自己枚举
try:
import frida
dev = frida.get_local_device()
for p in dev.enumerate_processes():
if p.name.lower() in ("ananta.exe", "ananta"):
return p.pid
except Exception:
pass
return None
def count_exe(name):
n = name.lower()
return len([1 for nm, _ in _tasklist_rows() if nm == n])
def _pid_alive_win(pid):
"""OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)。
⛑ 为什么不用 tasklist 做唯一判据:
tasklist 在某些环境(受限 shell / 沙箱)会返回**空列表**,
那时 pid_alive 恒 False ⇒ 互斥锁形同虚设(两份加载器照样起得来)。
PROCESS_QUERY_LIMITED_INFORMATION(0x1000) 自 Vista 起对**任何**进程都放行
(含提权进程、含别的会话),只要 pid 存在就拿得到句柄 —— 是最可靠的存在性判据。
"""
try:
k32 = ctypes.windll.kernel32
k32.OpenProcess.restype = ctypes.c_void_p
k32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_bool, ctypes.c_uint32]
h = k32.OpenProcess(0x1000, False, int(pid))
if not h:
return False
try:
# ⛑ 光拿到句柄**不能**算活着:只要还有人握着该进程的句柄,
# 进程对象就不会销毁,OpenProcess 照样成功(实测踩到)。
# 退出码 != STILL_ACTIVE(259) ⇒ 它已经死了。
code = ctypes.c_uint32()
ok = k32.GetExitCodeProcess(ctypes.c_void_p(h), ctypes.byref(code))
return bool(ok) and code.value == 259
finally:
k32.CloseHandle(ctypes.c_void_p(h))
except Exception:
pass
return False
def pid_alive(pid):
try:
pid = int(pid)
except Exception:
return False
if _pid_alive_win(pid):
return True
for _, p in _tasklist_rows(): # 兜底:万一 OpenProcess 被拦
if p == pid:
return True
return False
# 常驻加载器的互斥锁。⛑ 为什么必须有(2026-09-29):
# 两个 frida 会话同时挂在 Ananta.exe 上 = 游戏必崩(历史踩过 3 次)。
# 以前加载器是手动开的,人不容易开两份;现在改成「一键启动」随游戏自动起,
# 重复双击的概率大幅上升,所以必须由程序自己挡住。
# ⛑ 锁文件里存 pid 而不是单纯「文件存在」—— 崩溃退出会留下僵尸锁,
# 只判存在会永久卡死启动,必须校验 pid 是否真的活着。
LOCK_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".loader.lock")
def acquire_instance_lock():
"""拿常驻锁。返回 None = 拿到;返回占用者 pid = 已经有一份在跑,调用方应立刻退出。"""
try:
if os.path.exists(LOCK_PATH):
try:
old = int((open(LOCK_PATH, encoding="utf-8").read() or "0").strip() or 0)
except Exception:
old = 0
if old and pid_alive(old) and old != os.getpid():
return old
except Exception:
pass
try:
with open(LOCK_PATH, "w", encoding="utf-8") as fh:
fh.write(str(os.getpid()))
except Exception:
pass
atexit.register(release_instance_lock)
return None
def release_instance_lock():
"""只删**自己**写的锁,别把别人的删了。"""
try:
if not os.path.exists(LOCK_PATH):
return
try:
cur = int((open(LOCK_PATH, encoding="utf-8").read() or "0").strip() or 0)
except Exception:
cur = 0
if cur == os.getpid():
os.remove(LOCK_PATH)
except Exception:
pass
def other_loader_running():
"""有没有别的加载器在跑?返回名字,没有返回 None。必须**每一轮**复查。"""
for e in OTHER_EXES:
if count_exe(e) > 0:
return e
return None
def apply_placeholders(src, root):
"""把 agent JS 里「由 Python 端注入」的占位符替换掉。
⛑ 2026-09-28 才发现的大坑:`modules/world_mod.js:3376` 是
var CELLS_RAW = __CELLS_JSON__;
这是一个**原始占位符**,原版靠 `new-worldmod/inject.py` 在注入前
`src.replace("__CELLS_JSON__", json.dumps(cells))` 换掉。
我们这里是直接读文件 ⇒ 未定义的标识符 ⇒ 顶层 ReferenceError
⇒ **整个脚本加载即死**,连它自己的 `Il2Cpp.perform(...)` 引导都跑不到。
实测:所有历史会话里 `[worldmod]` 的 console 输出 **0 次** —— 通道 B 一直是死的。
"""
if "__CELLS_JSON__" not in src:
return src
p = os.path.join(root, "cells.json")
if not os.path.exists(p):
raise RuntimeError("JS 需要 __CELLS_JSON__,但找不到 %s" % p)
data = json.loads(open(p, encoding="utf-8").read())
rows = data.get("cells", data if isinstance(data, list) else [])
if not rows:
raise RuntimeError("%s 里没有 cells" % p)
payload = json.dumps(rows, ensure_ascii=False, separators=(",", ":"))
return src.replace("__CELLS_JSON__", payload)
# ★★ 气球 side-channel 路径(2026-09-29 修)
#
# 病根:`modules/balloon_fix.js:3417` 的默认值是作者自己开发机上的硬编码
# var PUSH_PATHS = ['D:\\Ananta\\gym_tools\\_balloon_push.json'];
# 该文件顶部 R146 注释写得很清楚:**「exe 加载器会把这一行整个改写成运行期算出来的
# 本机多路径」**。原版 `AnantaBalloonLoader.exe` 确实这么干(实机日志里有
# `agent PUSH_PATHS -> ["C:\\Users\\…\\AnantaBalloon\\balloon_push.json", …]`),
# 而我们这里是把 js 原样读进来 ⇒ 没有重写 ⇒ agent 永远去读 D:\Ananta\…
# ⇒ pushN 恒 0 ⇒ 一颗气球都不会被伪造出来。
#
# 服务端(GameRouter.Balloon.cs:1140-1159)按顺序写这三条,这里按**同一顺序**给候选:
# ① %LOCALAPPDATA%\AnantaBalloon\balloon_push.json ← 与安装位置无关,主用
# ② <服务端 base 目录>\config\balloon_push.json
# ③ D:\Ananta\gym_tools\_balloon_push.json ← 作者旧位置,兜底
_BALLOON_PUSH_RE = re.compile(r"var\s+PUSH_PATHS\s*=\s*\[[^\]]*\]\s*;")
def apply_balloon_paths(src, root):
"""把 balloon_fix.js 里写死的 PUSH_PATHS 换成本机真实候选列表。"""
if "PUSH_PATHS" not in src:
return src
shared = os.path.join(
os.environ.get("LOCALAPPDATA") or "", "AnantaBalloon", "balloon_push.json")
cands = []
for p in (
shared,
os.path.join(root, "config", "balloon_push.json"),
os.path.join(root, "Drmk.Server", "Drmk.App", "bin", "Debug", "net8.0",
"config", "balloon_push.json"),
r"D:\Ananta\gym_tools\_balloon_push.json",
):
if not p:
continue
# 统一成反斜杠(Windows File API 两种都吃,但 JS 字符串里反斜杠是转义符,
# 必须成对出现 —— 下面 _js_str 会再翻倍一次)
p = os.path.normpath(p).replace("/", "\\")
if p not in cands:
cands.append(p)
def _js_str(s):
# ⛑ 不能用 json.dumps 直接塞:`"C:\Users\..."` 里的 \U / \b 会被 JS 当转义吃
# 掉(实测 node 打印成 C:UsersNoiro...)。反斜杠必须翻倍。
return '"' + s.replace("\\", "\\\\") + '"'
arr = "[" + ", ".join(_js_str(p) for p in cands) + "]"
# ⛑⛑ 替换值**必须用函数**给:re.subn 的字符串 repl 会再做一次反斜杠转义处理,
# 把 `C:\\Users` 压回 `C:\Users`(实测踩到,JS 侧就又变成转义符了)。
new, n = _BALLOON_PUSH_RE.subn(lambda _m: "var PUSH_PATHS = %s;" % arr, src, count=1)
if n == 0:
return src
return new
class Mod(object):
"""一个模块 = 一个 frida script。"""
def __init__(self, name, path):
self.name = name
self.path = path
self.script = None
self.ready = False
self.note = ""
self.dead = False # 客户端崩了/重启后,旧 script 会永久失效
self.timeouts = 0 # 本 script 连续超时次数(load() 成功时清零)
def load(self, session, quiet=True):
try:
src = open(self.path, encoding="utf-8", errors="replace").read()
except Exception as e:
self.note = "读文件失败: %s" % e
return False
# ★ 占位符替换(__CELLS_JSON__ 等)—— 见 apply_placeholders 的说明
try:
src = apply_placeholders(
src, os.path.dirname(os.path.dirname(os.path.abspath(self.path))))
except Exception as e:
self.note = "占位符替换失败: %s" % str(e)[:160]
return False
# ★ 气球 side-channel:js 里的 PUSH_PATHS 是作者机器的硬编码,必须按本机改写
# root = 服务端根目录(…/DRMK-PS_09_HyNB-v0.3):
# <root>/tools/AnantaInjector/modules/balloon_fix.js → 往上 4 层
try:
src = apply_balloon_paths(
src, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.abspath(self.path))))))
except Exception as e:
self.note = "气球路径改写失败: %s" % str(e)[:160]
return False
try:
# runtime="v8" 是原加载器的写法(frida 17.18 仍接受该参数)。
# 万一某个 frida 版本把它拿掉了,退回默认运行时——别因为这一个参数整个模块挂掉。
try:
sc = session.create_script(src, runtime="v8")
except TypeError:
sc = session.create_script(src)
except Exception as e:
self.note = "create_script 失败: %s" % str(e)[:120]
return False
box = {"ready": False}
def on_message(msg, data):
try:
if msg.get("type") == "send":
p = msg.get("payload")
if isinstance(p, str):
try:
p = json.loads(p)
except Exception:
pass
if isinstance(p, dict) and (p.get("ready") or p.get("abReady")):
box["ready"] = True
elif not quiet:
print(" [%s] %s" % (self.name, str(p)[:200]))
except Exception:
pass
try:
if msg.get("type") == "error":
self.note = "script error: %s" % str(msg.get("description"))[:160]
if not quiet:
print(" [!] %s 脚本报错: %s" % (self.name, self.note))
except Exception:
pass
try:
sc.on("message", on_message)
sc.load()
except Exception as e:
self.note = "load 失败: %s" % str(e)[:160]
return False
# 有些模块不发 ready,给 3 秒宽限后也放行(它们可能只是没实现握手)
for _ in range(30):
if box["ready"]:
break
time.sleep(0.1)
self.script = sc
self.ready = True
self.dead = False # 重新加载 = 新 script,旧失效标记要清掉
self.timeouts = 0 # 同上:连续超时计数也归零
return True
def call3(self, name, arg=None, arg2=None, arg3=None, timeout=RPC_TIMEOUT):
"""三参 RPC(如 gadget_loader 的 findobj <关键字> <maxNodes> <maxDepth>)。
不想把 arg2/arg3 混进 call() 的可选语义里(None 与「不传」会分不清),
所以单独开一个方法,语义明确。
"""
if self.script is None:
raise RuntimeError("模块 %s 未加载" % self.name)
E = self.script.exports_sync
fn = getattr(E, name)
box = {}
def run():
try:
box["r"] = fn(arg, arg2, arg3)
except Exception as e:
box["e"] = e
return self._run_guarded(run, box, name, timeout)
def _run_guarded(self, run, box, name, timeout):
"""起线程跑 RPC + 收尾:超时判定 + 识别「客户端崩了」。
⛑ 客户端崩掉/重启后旧 script 会变成 'script has been destroyed',
不识别的话上层会一直假死在旧 session 上。
⛑ 线程在这里起(不在调用方),免得哪天又漏掉 t 的定义。
⛑⛑ 2026-09-29 修「超时线程泄漏」(原实现只 join、不回收):
Python 杀不掉线程,t.join(timeout) 超时后那个调用**仍然卡在 _frida 里**
⇒ 主循环每超时一次就永久多一个线程,跑久了线程数单调增长。
三层治理,从强到弱:
① **best-effort 真取消**:子线程里挂 frida.Cancellable(),超时后 cancel.cancel()。
只对 frida 内部会检查 cancellable 的同步调用生效(attach/load 这类稳生效,
exports_sync 的 RPC 不一定);**不生效也无害**,纯增强。
② **超时即判该 script 已废**:置 self.dead ⇒ 上层 `ctx.dead` 为真 ⇒ close()
→ script.unload() → 卡在等待中的 RPC 抛错 → 线程自然退出。
**这才是真正的回收路径**,也是原来漏掉的一环(原来超时不置 dead,
于是下一轮继续往同一个废 script 上发调用,每次再留一个线程)。
⛑ 本副本还有 armedGuard 兜底(bfStatus 连续 60s 拿不到就主动重挂),
不置 dead 也不会无限假死 —— 但要白等 60 秒,且这 60 秒里每次探测都再留
一个线程。置 dead 之后走 `unified_loader.py` 的 `ctx.dead` 分支,**立刻**重挂。
③ **快速失败**:连续超时达 STUCK_MAX_CONSECUTIVE ⇒ 抛错让上层重建,
不再往废 script 上堆新调用。
⛑ 日志按规则 110 —— 不打「只打前 N 条」,每次超时都打(量本来就小)。
"""
state = {}
def runner():
# cancellable 是线程局部的,必须在**调用线程**里 push 才有效。
cancel = None
try:
import frida as _frida
cancel = _frida.Cancellable()
except Exception:
cancel = None
if cancel is None:
run()
return
state["cancel"] = cancel
try:
with cancel:
run()
except Exception as e:
if "e" not in box:
box["e"] = e
t = threading.Thread(target=runner)
t.daemon = True
with _STUCK_LOCK:
_STUCK_THREADS.append(t)
t.start()
t.join(timeout)
if t.is_alive():
cancel = state.get("cancel")
if cancel is not None:
try:
cancel.cancel()
except Exception:
pass
self.timeouts += 1
self.dead = True # ② 交给上层 close() 回收
n = stuck_thread_count()
print("[%s] [!] rpc %s.%s 超时(>%ss)—— 判定该 script 已废,上层会重建;"
"卡住线程=%d 连续超时=%d" % (ts(), self.name, name, timeout, n, self.timeouts))
if self.timeouts >= STUCK_MAX_CONSECUTIVE:
raise RuntimeError("rpc %s.%s 连续超时 %d 次,放弃该 script,等待重建"
% (self.name, name, self.timeouts))
raise RuntimeError("rpc %s.%s 超时(>%ss)" % (self.name, name, timeout))
# 正常返回:线程已结束,从账本里摘掉
self.timeouts = 0
with _STUCK_LOCK:
if t in _STUCK_THREADS:
_STUCK_THREADS.remove(t)
if "e" in box:
msg = str(box["e"])
if ("has been destroyed" in msg or "is detached" in msg
or "invalid session" in msg or "Session is detached" in msg):
self.dead = True
raise RuntimeError("rpc %s.%s 失败: %s" % (self.name, name, msg[:160]))
return box.get("r")
def call(self, name, arg=None, arg2=None, timeout=RPC_TIMEOUT):
"""arg2 用于双参 RPC(如 addbtn 的 abTick(半径, 批量))。"""
if self.script is None:
raise RuntimeError("模块 %s 未加载" % self.name)
E = self.script.exports_sync
fn = getattr(E, name)
box = {}
def run():
try:
if arg2 is not None:
box["r"] = fn(arg, arg2)
elif arg is not None:
box["r"] = fn(arg)
else:
box["r"] = fn()
except Exception as e:
box["e"] = e
return self._run_guarded(run, box, name, timeout)
class Ctx(object):
def __init__(self, modules):
self.modules = modules # [Mod]
self.session = None
self.pid = None
self.closing = False
self.fail = 0
def attach(self):
box = {}
def run():
try:
box["r"] = self._attach_impl(box)
except Exception as e:
box["e"] = e
t = threading.Thread(target=run)
t.daemon = True
t.start()
t.join(ATTACH_TIMEOUT)
if t.is_alive():
sess = box.get("sess")
if sess is not None:
def _kill():
try:
sess.detach()
except Exception:
pass
k = threading.Thread(target=_kill)
k.daemon = True
k.start()
k.join(6)
return False
if "e" in box:
print("[%s] attach 异常:%s" % (ts(), str(box["e"])[:200]))
return False
return box.get("r", False)
def _attach_impl(self, box=None):
import frida
pid = resolve_pid()
if pid is None:
return False
self.pid = pid
session = frida.attach(pid)
if box is not None:
box["sess"] = session
ok = 0
for m in self.modules:
if m.load(session):
ok += 1
print("[%s] ✓ %s" % (ts(), m.name))
else:
print("[%s] ✗ %s —— %s" % (ts(), m.name, m.note or "未知原因"))
if ok == 0:
try:
session.detach()
except Exception:
pass
return False
self.session = session
self.closing = False
self.fail = 0
return True
@property
def dead(self):
"""客户端崩了/重启 ⇒ 任一模块的 script 失效,或 session 已断。"""
if self.session is None:
return True
for m in self.modules:
if m.script is not None and m.dead:
return True
return False
def mod(self, name):
for m in self.modules:
if m.name == name and m.script is not None:
return m
return None
def close(self):
"""优雅停机:① 请各 agent 停手 ② 卸脚本 ③ detach。卸载超时绝不 detach。"""
if self.closing or self.session is None:
STOP["done"] = True
return
self.closing = True
t0 = time.time()
for m in self.modules:
# ⛑ 2026-09-29:dead 的 script 再发 stop 只会再超时一次、再留一个卡住的线程
# (它的 RPC 通道已经废了,发什么都是白发)⇒ 直接跳过,交给 unload 回收。
if m.script is None or m.dead:
continue
# "stop" 是 AnantaGadgetLoader 的停机 RPC(gadget_world.py 的 finally 里调它)
for stop_name in ("shutdown", "bowlShutdown", "abShutdown", "stop"):
try:
m.call(stop_name, timeout=4)
break
except Exception:
continue
time.sleep(0.3)
sess = self.session
scrs = [m.script for m in self.modules if m.script is not None]
self.session = None
for m in self.modules:
m.script = None
def _unload():
for sc in scrs:
try:
sc.unload()
except Exception:
pass
t1 = threading.Thread(target=_unload)
t1.daemon = True
t1.start()
t1.join(8.0)
if t1.is_alive():
print("[!] 脚本卸载没在 8 秒内完成 —— 已跳过强制断开(避免把游戏拖死)。")
print(" 各 agent 已停机、不再做任何事;重启游戏即可彻底清掉。")
STOP["done"] = True
return
def _det():
try:
if sess:
sess.detach()
except Exception:
pass
t2 = threading.Thread(target=_det)
t2.daemon = True
t2.start()
t2.join(3.0)
print("[%s] 注入已安全卸载,用时 %.1fs" % (ts(), time.time() - t0))
STOP["done"] = True
_LIVE = []
_CLOSE_LOCK = threading.Lock()
def _atexit_close():
with _CLOSE_LOCK:
for c in list(_LIVE):
try:
c.close()
except Exception:
pass
STOP["done"] = True