diff --git a/back/app.ts b/back/app.ts index 8a195c00..0851a195 100644 --- a/back/app.ts +++ b/back/app.ts @@ -41,11 +41,18 @@ class Application { } private startMasterProcess() { + const readyTimeout = + process.arch === 'arm' && + Number( + (process.config.variables as { arm_version?: string | number }).arm_version, + ) === 6 + ? 120000 + : 30000; // Fork gRPC worker first and wait for it to be ready const grpcWorker = this.forkWorker('grpc'); // Wait for gRPC worker to signal it's ready before starting HTTP worker - this.waitForWorkerReady(grpcWorker, 30000) + this.waitForWorkerReady(grpcWorker, readyTimeout) .then(() => { Logger.info('[boot] gRPC worker is ready, starting HTTP worker'); this.httpWorker = this.forkWorker('http'); @@ -71,7 +78,7 @@ class Application { Logger.warn('Unable to notify HTTP worker of scheduler exit'); } const newGrpcWorker = this.forkWorker('grpc'); - this.waitForWorkerReady(newGrpcWorker, 30000) + this.waitForWorkerReady(newGrpcWorker, readyTimeout) .then(() => { Logger.info('gRPC worker restarted and ready'); // Re-register cron jobs by notifying the HTTP worker diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md index f7703178..e2bc9d64 100644 --- a/docs/development/platform-upgrade-bundles.md +++ b/docs/development/platform-upgrade-bundles.md @@ -45,7 +45,7 @@ ARMv6 glibc 小设备(例如 Raspberry Pi 1/Zero 的 Raspbian/Raspberry Pi OS - glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。 - ARMv6 glibc 使用签名校验的 Raspbian bullseye(glibc 2.31)构建环境,以固定 SHA256 的 ARMv6 Node 20.18.1 头文件编译 SQLite,强制 `-march=armv6 -mfpu=vfp -mfloat-abi=hard`。CI 检查 ELF 的 ARMv6 和硬浮点标记,并在 QEMU ARM1176 下完成归档复验、SQLite 读写和空目录离线安装;验证辅助镜像按不可变摘要记录,不进入用户安装包。Debian ARMv7 镜像仅提供同次构建的应用文件,不复用其 Node、libc 或 SQLite 二进制。 - ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。 -- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。ARMv6 升级后的健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载和 SQLite 读写仍由现有 Node 的正常模式独立进程验证,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。 +- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载和 SQLite 读写仍由现有 Node 的正常模式独立进程验证,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。 - tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。 自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。 diff --git a/test/back/armv6-worker-readiness.test.cjs b/test/back/armv6-worker-readiness.test.cjs new file mode 100644 index 00000000..c9b2a7bd --- /dev/null +++ b/test/back/armv6-worker-readiness.test.cjs @@ -0,0 +1,101 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const { EventEmitter } = require('node:events'); +const ts = require('typescript'); + +const source = ts.createSourceFile( + 'app.ts', + fs.readFileSync('back/app.ts', 'utf8'), + ts.ScriptTarget.Latest, + true, +); +const application = source.statements.find( + (node) => ts.isClassDeclaration(node) && node.name.text === 'Application', +); +const method = application.members.find( + (node) => node.name?.getText(source) === 'startMasterProcess', +); +const compiled = ts.transpileModule( + `class Application { ${method.getText( + source, + )} }\nmodule.exports = Application;`, + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }, +).outputText; + +for (const [arch, armVersion, timeout] of [ + ['arm', 6, 120000], + ['arm', 7, 30000], + ['arm64', undefined, 30000], +]) { + test(`${arch} v${ + armVersion ?? '' + } waits for scheduler readiness before HTTP and job recovery`, async () => { + const cluster = new EventEmitter(); + const module = { exports: {} }; + const logger = { info() {}, warn() {}, error() {} }; + new Function( + 'module', + 'cluster', + 'Logger', + 'errStack', + 'process', + compiled, + )(module, cluster, logger, String, { + arch, + config: { variables: { arm_version: armVersion } }, + exit() { + assert.fail('unexpected application exit'); + }, + }); + const app = new module.exports(); + app.workerMetadataMap = new Map(); + const forks = [], + waits = [], + notifications = []; + let ready; + app.forkWorker = (serviceType) => { + const worker = { + id: forks.length + 1, + process: { pid: 100 + forks.length }, + send: (message) => notifications.push(message), + }; + forks.push({ serviceType, worker }); + app.workerMetadataMap.set(worker.id, { serviceType }); + return worker; + }; + app.waitForWorkerReady = (worker, milliseconds) => { + waits.push({ worker, milliseconds }); + return new Promise((resolve) => { + ready = resolve; + }); + }; + app.setupMasterShutdown = () => {}; + app.startMasterProcess(); + assert.deepEqual( + forks.map((fork) => fork.serviceType), + ['grpc'], + ); + assert.equal(waits[0].milliseconds, timeout); + ready(); + await Promise.resolve(); + assert.deepEqual( + forks.map((fork) => fork.serviceType), + ['grpc', 'http'], + ); + cluster.emit('exit', forks[0].worker, 1); + assert.equal(waits[1].milliseconds, timeout); + assert.deepEqual(notifications, ['scheduler-unavailable']); + ready(); + await Promise.resolve(); + assert.deepEqual(notifications, [ + 'scheduler-unavailable', + 'reregister-crons', + ]); + }); +}