Files

711 lines
27 KiB
Python
Vendored
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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