diff --git a/cli/src/internal/maintenance/panelUpgrade.cjs b/cli/src/internal/maintenance/panelUpgrade.cjs index 691f98d0..3ad52d05 100644 --- a/cli/src/internal/maintenance/panelUpgrade.cjs +++ b/cli/src/internal/maintenance/panelUpgrade.cjs @@ -6,6 +6,7 @@ const { spawn } = require('node:child_process'); const http = require('node:http'); const { setTimeout: delay } = require('node:timers/promises'); const artifacts = require('./upgradeArtifacts.cjs'); +const { nodeRuntimeArgs } = require('./platformBundle.cjs'); const active = new Set(['starting', 'running', 'restarting']); const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json'); @@ -443,11 +444,15 @@ async function launchWorker(id) { // detached creates a session but keeps the original parent. PM2 kills the // backend's entire descendant tree, so an intermediate launcher must exit // before the worker is allowed to stop that backend. - const child = spawn(process.execPath, [__filename, 'worker', id], { - env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) }, - detached: true, - stdio: 'inherit', - }); + const child = spawn( + process.execPath, + [...nodeRuntimeArgs(), __filename, 'worker', id], + { + env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) }, + detached: true, + stdio: 'inherit', + }, + ); await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); diff --git a/cli/src/internal/maintenance/platformBundle.cjs b/cli/src/internal/maintenance/platformBundle.cjs index e486671e..9b2720ed 100644 --- a/cli/src/internal/maintenance/platformBundle.cjs +++ b/cli/src/internal/maintenance/platformBundle.cjs @@ -7,6 +7,12 @@ const { execFile } = require('node:child_process'); const { promisify } = require('node:util'); const tar = require('tar'); const execute = promisify(execFile); +// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep +// upgrade workers interpreted without changing the installed Node or server. +const nodeRuntimeArgs = (runtime = process) => + runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6 + ? ['--jitless'] + : []; const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); const inside = (root, filename) => { @@ -198,7 +204,7 @@ async function smokeDependencies(source, signal) { db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)'); db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});}); `; - await execute(process.execPath, ['-e', probe], { + await execute(process.execPath, [...nodeRuntimeArgs(), '-e', probe], { cwd: source, env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' }, timeout: 30000, @@ -457,6 +463,7 @@ async function verifyStagedBundle(selected) { } module.exports = { + nodeRuntimeArgs, atLeast, runtimeTarget, targetKey, diff --git a/docker/Dockerfile.upgrade-bundle b/docker/Dockerfile.upgrade-bundle index 54f35da9..1e4b074b 100644 --- a/docker/Dockerfile.upgrade-bundle +++ b/docker/Dockerfile.upgrade-bundle @@ -7,7 +7,7 @@ ARG TARGETARCH ARG TARGETVARIANT USER root # The generic "arm" sqlite release is ARMv7. ARMv6 must compile its own addon. -RUN if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \ +RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \ apk add --no-cache --virtual .ql-bundle-build build-base binutils python3 nodejs-dev && \ cd "$(readlink -f /ql/node_modules/sqlite3)" && \ CFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \ @@ -18,7 +18,13 @@ RUN if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \ rm -rf build && \ apk del .ql-bundle-build; \ fi -RUN if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then export QEMU_CPU=arm1176; fi; \ - node /ql/scripts/build-upgrade-bundle.cjs /ql /out +RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \ + export QEMU_CPU=arm1176; \ + node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out && \ + for archive in /out/*.tar.gz; do \ + node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install && \ + rm -rf /tmp/ql-bundle-offline-smoke || exit 1; \ + done; \ + else node /ql/scripts/build-upgrade-bundle.cjs /ql /out; fi FROM scratch COPY --from=bundle /out/ / diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md index 81438bea..227f3043 100644 --- a/docs/development/platform-upgrade-bundles.md +++ b/docs/development/platform-upgrade-bundles.md @@ -42,6 +42,7 @@ QL_DIR=/opt/qinglong bash /opt/qinglong/shell/start-simplify.sh - 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。 - glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。 - ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。 +- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。依赖加载和 SQLite 读写仍由现有 Node 的独立进程验证。 - tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。 自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。 diff --git a/scripts/build-upgrade-bundle.cjs b/scripts/build-upgrade-bundle.cjs index 6abd9aed..034d77af 100644 --- a/scripts/build-upgrade-bundle.cjs +++ b/scripts/build-upgrade-bundle.cjs @@ -84,6 +84,7 @@ async function buildBundle(root, output) { path.join(payload, 'bundle.json'), JSON.stringify(manifest), ); + console.log('Bundle inventory verified; writing archive'); const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey( target, )}.tar.gz`; @@ -100,6 +101,7 @@ async function buildBundle(root, output) { // Verify the bytes users receive, not just the pre-archive build directory. const roundtrip = path.join(workspace, 'roundtrip'); await fs.mkdir(roundtrip); + console.log('Verifying archived paths, permissions and checksums'); await bundle.extractArchive(path.join(output, filename), roundtrip); const extracted = { source: path.join(roundtrip, 'qinglong/source'), @@ -108,6 +110,7 @@ async function buildBundle(root, output) { await bundle.verifyPayload(extracted, manifest, target); await verifyUpgrade(extracted.source, extracted.static); await bundle.smokeDependencies(extracted.source); + console.log('Archived production dependencies and SQLite verified'); const bytes = await fs.readFile(path.join(output, filename)); const record = { format: 2, diff --git a/shell/install-bundle.cjs b/shell/install-bundle.cjs index 086f9969..b50ea7d8 100644 --- a/shell/install-bundle.cjs +++ b/shell/install-bundle.cjs @@ -9,6 +9,36 @@ const execute = promisify(require('node:child_process').execFile); const { parseArgs } = require('node:util'); const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs'); const jobs = require('../cli/src/internal/maintenance/panelUpgrade.cjs'); +const { + nodeRuntimeArgs, +} = require('../cli/src/internal/maintenance/platformBundle.cjs'); + +async function runInstallerMain(argv = process.argv.slice(2)) { + const flags = nodeRuntimeArgs(); + if (!flags.length || process.execArgv.includes('--jitless')) + return installBundle(argv); + const child = require('node:child_process').spawn( + process.execPath, + [...flags, __filename, ...argv], + { stdio: 'inherit', env: process.env }, + ); + const interrupt = () => child.kill('SIGINT'); + const terminate = () => child.kill('SIGTERM'); + process.once('SIGINT', interrupt); + process.once('SIGTERM', terminate); + try { + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`Offline installer exited: ${signal || code}`)); + }); + }); + } finally { + process.removeListener('SIGINT', interrupt); + process.removeListener('SIGTERM', terminate); + } +} async function installBundle(argv = process.argv.slice(2), env = process.env) { const { values } = parseArgs({ @@ -274,9 +304,9 @@ async function installBundle(argv = process.argv.slice(2), env = process.env) { } } -module.exports = { installBundle }; +module.exports = { installBundle, runInstallerMain }; if (require.main === module) - installBundle().catch((error) => { + runInstallerMain().catch((error) => { console.error(error.message); process.exitCode = 1; }); diff --git a/test/back/platform-bundle.test.cjs b/test/back/platform-bundle.test.cjs index e02f96ff..b5ab5e03 100644 --- a/test/back/platform-bundle.test.cjs +++ b/test/back/platform-bundle.test.cjs @@ -6,6 +6,39 @@ const os = require('node:os'); const tar = require('tar'); const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs'); const artifacts = require('../../shell/upgrade.cjs'); + +test('ARMv6 offline entry re-executes with an interpreted Node while preserving CLI errors', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-armv6-entry-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const preload = path.join(directory, 'armv6.cjs'); + const observed = path.join(directory, 'child.json'); + await fs.writeFile( + preload, + `if(process.execArgv.includes('--jitless'))require('node:fs').writeFileSync(${JSON.stringify( + observed, + )},JSON.stringify({args:process.execArgv,execPath:process.execPath}));else{Object.defineProperty(process,'arch',{value:'arm'});Object.defineProperty(process,'config',{value:{variables:{arm_version:6}}});}`, + ); + const { execFile } = require('node:child_process'); + const result = await new Promise((resolve) => + execFile( + process.execPath, + [path.resolve(__dirname, '../../shell/install-bundle.cjs')], + { + timeout: 10000, + env: { + ...process.env, + NODE_OPTIONS: '--require ' + JSON.stringify(preload), + }, + }, + (error, stdout, stderr) => resolve({ error, stdout, stderr }), + ), + ); + assert.equal(result.error.code, 1); + const child = JSON.parse(await fs.readFile(observed)); + assert.ok(child.args.includes('--jitless')); + assert.equal(child.execPath, process.execPath); + assert.match(result.stderr, /Usage: node install-bundle.cjs/); +}); const { writeManifest, sourceCommit,