Files
saopig1andClaude Opus 5 5e9ae49f52 fix(setup): stop a failed console write from aborting setup (#152)
setup.bat runs `chcp 65001` and shows binary progress on stderr. On some
Windows consoles - the reporter's Windows Server 2012 R2 above all - that
code page cannot render non-ASCII text and the OS fails the write with
EIO. process.stderr is an ordinary stream, so the EIO arrived as an
'error' event, and with no listener attached Node rethrew it as an
uncaught exception:

    Error: write EIO { errno: -4070, code: 'EIO', syscall: 'write' }
        at log (scripts/download-binaries.mjs:84:18)
        at ensureFfmpeg (scripts/download-binaries.mjs:451:5)

Those two frames pin it exactly: line 84 is `process.stderr.write`, and
line 451 is the first log line of the whole run that contains Chinese.
The three lines before it are pure ASCII and printed fine. Nothing was
wrong with the download it was announcing - setup killed itself inside
its own progress logging and reported the native modules as unusable.

scripts/lib/console-log.mjs now wraps both streams: it listens for
'error' so the failure can never be fatal, then degrades that stream
rather than dying - first to an ASCII rendering that keeps the English
half of each bilingual line, then silent if the stream is really gone.
The streams degrade independently, so a console that gives up costs
setup.log nothing: that stdout is a redirected file. check-native.mjs
gets the same treatment, since the console that cannot print its Chinese
is exactly the one a user needs its English from.

Also report a 404 honestly. better-sqlite3 dropped its Node 20 (ABI 115)
prebuilds in 12.10.0 and @discordjs/opus 0.10.0 has none for Node 24, so
users on those majors fall through to the source build and are told to
install Python and a C++ toolchain - when switching Node major takes two
minutes. Nothing in the output said so, and the README recommended
Node 20 as if it still worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:59:31 +08:00

156 lines
5.9 KiB
JavaScript

#!/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";
import { createLineWriter } from "./lib/console-log.mjs";
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);
// Never let a failed console write become an uncaught error and replace this
// report with a stack trace - the console that cannot print the Chinese half
// of these lines is exactly the one a user needs the English half from.
// See scripts/lib/console-log.mjs and issue #152.
const out = createLineWriter(process.stderr);
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;
}