mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
fix(setup): 按 Node ABI 校验并自动修复原生模块
换过 Node 大版本之后安装就废了,而且安装脚本还会报告成功。原生模块只能在 编译它的那个 Node ABI 上加载(Node 20 = 115、22 = 127、24 = 137),而 better-sqlite3 的 .node 放在与 ABI 无关的固定路径下,旧的 download-binaries 只检查「文件存在且大于 500KB」,于是给 Node 24 编译的 1.9MB 文件在 Node 22 下原样保留,跳过重新下载,机器人启动时死在 NODE_MODULE_VERSION 上。 (@discordjs/opus 的目录名里带 ABI,反而歪打正着没这个问题。) download-binaries.mjs 现在不看文件大小,而是在子进程里真的把每个包 load 一遍 ——子进程是必须的,Windows 上父进程加载过的 .node 会一直被映射,系统随后拒绝 删除或覆盖它。注意 better-sqlite3 的 addon 是在 Database 构造函数里惰性加载的, 所以光 require 这个包探测不出问题,得真的开一个内存库。 失败就按当前 ABI 重新安装,整个替换过程是先把旧文件挪到 node_modules/ .tsmusicbot-backup、下载解压到暂存目录、原子 rename 就位、再探测一次,任何 一步失败都把原文件还原回去——删掉不匹配的二进制却下载不下来,比原来的版本 更糟。备份特意放在包的 build/ 之外,因为源码编译回退会调 node-gyp 把 build/ 清空。被中断(比如下载到一半 Ctrl+C)遗留的备份,下一次运行会自动认领回来。 其他一并修掉的问题: - 版本号原本硬编码 12.8.0,实际锁的是 12.11.1,一旦真的触发下载就会 404; 改为从 node_modules 里读。 - 三个模块原本用 Promise.all 并发。源码编译走的是 execSync,会把事件循环整个 卡住几分钟,而 download() 的 120 秒超时是挂在同一个循环上的 socket 静默计时 器——循环一恢复,还在传输中的连接就会被判超时。这不是小概率竞态:npmmirror 上没有 ABI 137 的 opus,也没有 ABI 115 的 better-sqlite3,所以在 Node 24 和 Node 20 上必定有一个模块在 100ms 内 404 并开始编译,而 ffmpeg 的 80MB 下载 正在进行。ffmpeg 是可选模块,于是它被误杀后只记一条 WARN,脚本照样 exit 0, setup 打印「Setup Complete」,用户装完却没有 ffmpeg,放什么都放不出来。 改成严格串行执行。 - 必需模块(opus / better-sqlite3)失败才返回非零;ffmpeg 有系统 ffmpeg 兜底, 只警告。setup.bat 里原本形同虚设的 FAILED 标志接上了,必需模块失败会中止安装, 不再是「装完才发现」。 - 4b 步骤原本把全部输出重定向进 setup.log,用户盯着不动的窗口以为卡死;现在 进度走 stderr 实时显示,完整记录仍进日志。 新增 scripts/check-native.mjs:启动前预检,直接说清楚哪个模块对不上、分别是哪 个 ABI、怎么修,而不是抛一串 NODE_MODULE_VERSION 堆栈。scripts\start.bat、根目 录 start.bat(现在改为委托给前者,并且会先切到项目目录)和 npm start 的 prestart 都会跑它。Docker 运行镜像也补上这个文件,否则容器里执行 npm start 会因为找不到 脚本而失败。 Node 版本要求改为按依赖的真实下限判断(@honeybbq/teamspeak-client 要 >=20.19、 @sansenjian/qq-music-api 要 >=20.17/22.9,21 和 23 被 better-sqlite3 与 vitest 排除),package.json 补上对应的 engines;比 20/22 LTS 更新的大版本不阻止,只提 示可能要源码编译。README 相应更新,并补一条 NODE_MODULE_VERSION 的常见问题。 注意:批处理里新增的行全部保持纯 ASCII —— cmd.exe 在括号块里遇到多字节 UTF-8 会算错文件偏移,开始吃掉后续行的 echo 前缀,中文提示一律交给 Node 脚本输出。 Closes #140 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4e4354282d
commit
03ffd09d36
9 files changed
+968
-135
No files matched your search
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Preflight: can THIS Node build actually load the native modules that are
|
||||
* sitting in node_modules?
|
||||
*
|
||||
* A compiled addon is tied to one Node ABI (process.versions.modules:
|
||||
* Node 20 = 115, Node 22 = 127, Node 24 = 137). Install under one Node major,
|
||||
* launch under another, and the bot dies deep inside startup with a
|
||||
* `NODE_MODULE_VERSION ...` stack that says nothing about how to fix it.
|
||||
* This script turns that into one actionable sentence, before anything starts.
|
||||
*
|
||||
* Exit code:
|
||||
* 0 every required native module loads (or is simply not installed yet —
|
||||
* that is npm install's problem, not an ABI problem)
|
||||
* 1 a required native module definitively fails to load; the bot could not
|
||||
* have started anyway, so there is no false-positive risk here.
|
||||
*
|
||||
* Usage: node scripts/check-native.mjs
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const NODE_MODULES = join(ROOT, "node_modules");
|
||||
const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi");
|
||||
const NODE_ABI = process.versions.modules;
|
||||
|
||||
/** Only the modules the bot cannot start without. ffmpeg-static is optional
|
||||
* (a system ffmpeg on PATH works too), so it is not checked here. */
|
||||
const REQUIRED = ["@discordjs/opus", "better-sqlite3"];
|
||||
|
||||
function pkgDirOf(spec) {
|
||||
return join(NODE_MODULES, ...spec.split("/"));
|
||||
}
|
||||
|
||||
function summarizeError(text) {
|
||||
const lines = String(text || "")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l));
|
||||
return (interesting || lines[0] || "unknown error").slice(0, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* The snippet that actually forces each package's addon to be dlopen()ed.
|
||||
* NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor,
|
||||
* so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI
|
||||
* binary. Opening an in-memory database is the cheapest way to really load it.
|
||||
*/
|
||||
const PROBE_EXPR = {
|
||||
"@discordjs/opus": "require('@discordjs/opus')",
|
||||
"better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()",
|
||||
};
|
||||
|
||||
/**
|
||||
* Load-probe in a throwaway child process. Child process on purpose: requiring
|
||||
* an addon in this process would keep the DLL mapped, and Windows then refuses
|
||||
* to let setup.bat replace the file we just told the user to replace.
|
||||
*/
|
||||
function probeRequire(spec) {
|
||||
const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`;
|
||||
try {
|
||||
execFileSync(process.execPath, ["-e", expr], {
|
||||
cwd: ROOT,
|
||||
stdio: "pipe",
|
||||
timeout: 120000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
|
||||
// "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js
|
||||
// requires NODE_MODULE_VERSION 127..." -> first number is the build target.
|
||||
const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]);
|
||||
return {
|
||||
ok: false,
|
||||
abiMismatch: abis.length >= 2,
|
||||
compiledAbi: abis.length >= 2 ? abis[0] : null,
|
||||
error: summarizeError(text),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function readStamp() {
|
||||
try {
|
||||
return JSON.parse(readFileSync(STAMP_FILE, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const setupCmd = process.platform === "win32" ? "scripts\\setup.bat" : "bash scripts/setup.sh";
|
||||
|
||||
const broken = [];
|
||||
for (const spec of REQUIRED) {
|
||||
if (!existsSync(pkgDirOf(spec))) continue; // not installed yet -> npm install's job
|
||||
const probe = probeRequire(spec);
|
||||
if (!probe.ok) broken.push({ spec, ...probe });
|
||||
}
|
||||
|
||||
function report() {
|
||||
const stamp = readStamp();
|
||||
const mismatch = broken.find((b) => b.abiMismatch);
|
||||
const out = (line) => process.stderr.write(`${line}\n`);
|
||||
|
||||
out("");
|
||||
out("============================================================");
|
||||
if (mismatch) {
|
||||
out(" [ERROR] 原生模块与当前 Node 版本不匹配");
|
||||
out(" Native modules do not match this Node version");
|
||||
} else {
|
||||
out(" [ERROR] 原生模块无法加载 / native module failed to load");
|
||||
}
|
||||
out("============================================================");
|
||||
out(` 本机 Node / running Node : ${process.version} (ABI ${NODE_ABI})`);
|
||||
if (stamp && stamp.abi) {
|
||||
out(` 安装时 Node / built with : ${stamp.nodeVersion || "?"} (ABI ${stamp.abi})`);
|
||||
out(` ← node_modules/.tsmusicbot-abi, ${stamp.updatedAt || "?"}`);
|
||||
}
|
||||
out("");
|
||||
for (const b of broken) {
|
||||
if (b.abiMismatch) {
|
||||
out(` x ${b.spec}: 本机 Node ${process.version} (ABI ${NODE_ABI}),`);
|
||||
out(` 但 node_modules 里的原生模块是给 ABI ${b.compiledAbi} 编译的。`);
|
||||
out(` built for ABI ${b.compiledAbi}, this Node needs ABI ${NODE_ABI}.`);
|
||||
} else {
|
||||
out(` x ${b.spec}: ${b.error}`);
|
||||
}
|
||||
}
|
||||
out("");
|
||||
out(" 怎么修 / How to fix:");
|
||||
out(` 1) 重新运行安装脚本 / re-run setup: ${setupCmd}`);
|
||||
out(" (它会自动为当前 Node 版本重新安装原生模块)");
|
||||
out(" (setup now repairs the native modules for whatever Node you run)");
|
||||
out(" 2) 或者换回安装时用的 Node 版本 / or switch back to the Node version");
|
||||
out(" you installed with, then start again.");
|
||||
out("============================================================");
|
||||
out("");
|
||||
}
|
||||
|
||||
if (broken.length > 0) {
|
||||
report();
|
||||
// exitCode rather than exit(): lets the message flush when stderr is piped.
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in new issue
Block a user