Files
qinglong/back/app.ts
T
whyour 86c37f426a feat: ship preflight-verified online and offline platform upgrades
Build complete platform tar.gz bundles with production dependencies for 13
Linux architecture/libc targets, including ARMv6 glibc and musl. Share archive,
payload, runtime and dependency verification across online preparation and
manual offline installation while retaining the existing Node runtime.

Publish readiness only after an isolated backend starts successfully. Repair
verified dependency failures once with the frozen lockfile; offline repair
requires explicit opt-in. Preserve configured proxies and bound memory,
concurrency, process lifetimes and cleanup on small devices.

Verify the running build after explicit restart, restore prior application
files and dependencies on failure, and retain backups when the failed service
cannot stop. Complete cancellation recovery outside the aborted signal, reap
descendants as their supervisor exits, and keep operation-only upgrade
variables out of both Bash and CLI service startup environments.

Keep dialog/sidebar states consistent, hide completed notices, and retain
menu/user controls in narrow desktop viewports with long-name truncation.

Validation: 791 backend/frontend tests passed (8 platform skips), 294 CLI
tests passed (5 platform skips), backend/CLI type checks and standalone offline
installer build. On dev, 29 additional Linux fault regressions passed without
external networking, including real Node/PM2 SIGTERM rollback. Earlier full
bundle application and ARM1176/glibc prestart acceptance remain recorded.

Make the mirror notification timing regression respect Bash SECONDS integer
resolution: require two clock ticks and assert the logged retry threshold.
Validated with Node 20 full regression and 12 repeated boundary runs.
2026-10-18 17:28:18 +08:00

303 lines
9.6 KiB
TypeScript

import 'reflect-metadata';
import cluster, { type Worker } from 'cluster';
import type express from 'express';
import { Container } from 'typedi';
import config from './config';
import Logger from './loaders/logger';
import { errStack } from './shared/errors';
import { initializeLanguageSync } from './shared/languageSync';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
interface WorkerMetadata {
id: number;
pid: number;
serviceType: string;
startTime: Date;
}
class Application {
private httpServerService?: HttpServerService;
private grpcServerService?: GrpcServerService;
private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
async start() {
try {
if (cluster.isPrimary) {
await this.initializeDatabase();
}
await initializeLanguageSync();
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
await this.startWorkerProcess();
}
} catch (error) {
Logger.error(`Failed to start application:\n${errStack(error)}`);
process.exit(1);
}
}
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, readyTimeout)
.then(() => {
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
})
.catch((error) => {
Logger.error(`[boot] Failed to wait for gRPC worker:\n${errStack(error)}`);
process.exit(1);
});
cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) {
if (!this.isShuttingDown) {
Logger.error(
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`,
);
// If gRPC worker died, restart it and wait for it to be ready
if (metadata.serviceType === 'grpc') {
try {
this.httpWorker?.send('scheduler-unavailable');
} catch (error) {
Logger.warn('Unable to notify HTTP worker of scheduler exit');
}
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, readyTimeout)
.then(() => {
Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
if (this.httpWorker) {
try {
this.httpWorker.send('reregister-crons');
Logger.info('Sent reregister-crons message to HTTP worker');
} catch (error) {
Logger.error(`Failed to send reregister-crons message:\n${errStack(error)}`);
}
}
})
.catch((error) => {
Logger.error(`Failed to restart gRPC worker:\n${errStack(error)}`);
process.exit(1);
});
} else {
// For HTTP worker, just restart it
const newWorker = this.forkWorker(metadata.serviceType);
this.httpWorker = newWorker;
Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
}
}
this.workerMetadataMap.delete(worker.id);
}
});
this.setupMasterShutdown();
}
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const messageHandler = (msg: any) => {
if (msg === 'ready') {
worker.removeListener('message', messageHandler);
clearTimeout(timeoutId);
resolve();
}
};
worker.on('message', messageHandler);
// Timeout after specified milliseconds
const timeoutId = setTimeout(() => {
worker.removeListener('message', messageHandler);
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
}, timeoutMs);
});
}
private forkWorker(serviceType: string): Worker {
const workerEnv: NodeJS.ProcessEnv = { SERVICE_TYPE: serviceType };
// PM2's fork launcher is inherited by our own cluster workers. Their APM
// messages go to this primary, not PM2, and duplicate its sampling work.
// Keep primary monitoring and allow restoring the inherited worker APM.
if (process.env.pm_id !== undefined && process.env.QL_WORKER_APM !== 'true') {
workerEnv.pmx = 'false';
}
const worker = cluster.fork(workerEnv);
this.workerMetadataMap.set(worker.id, {
id: worker.id,
pid: worker.process.pid!,
serviceType,
startTime: new Date(),
});
return worker;
}
private async initializeDatabase() {
const { runStartupProcess } = await import('./shared/startupProcess');
await runStartupProcess(require.resolve('./bootstrap/database'));
}
private async setupMiddlewares(): Promise<express.Application> {
const { createHttpApplication } = await import('./loaders/httpApplication');
return createHttpApplication();
}
private setupMasterShutdown() {
const shutdown = async () => {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
const workers = Object.values(cluster.workers || {});
const workerPromises: Promise<void>[] = [];
workers.forEach((worker) => {
if (worker) {
const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => {
Logger.info(`Worker ${worker.process.pid} exited`);
resolve();
});
try {
worker.send('shutdown');
} catch (error) {
Logger.warn(`Failed to send shutdown to worker ${worker.process.pid}:\n${errStack(error)}`);
}
});
workerPromises.push(exitPromise);
}
});
try {
await Promise.race([
Promise.all(workerPromises),
new Promise<void>((resolve) => {
setTimeout(() => {
Logger.warn('Worker shutdown timeout reached');
resolve();
}, 10000);
}),
]);
process.exit(0);
} catch (error) {
Logger.error(`Error during worker shutdown:\n${errStack(error)}`);
process.exit(1);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType);
process.exit(1);
}
Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`);
try {
if (serviceType === 'http') {
await this.startHttpService();
} else {
await this.startGrpcService();
}
process.send?.('ready');
} catch (error) {
Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`);
process.exit(1);
}
}
private async startHttpService() {
// 在导入任何 gRPC 客户端模块之前初始化 mTLS 证书
const { initGrpcCerts } = await import('./config/grpcCerts');
await initGrpcCerts();
const app = await this.setupMiddlewares();
const { HttpServerService } = await import('./services/http');
this.httpServerService = Container.get(HttpServerService);
const appLoader = await import('./loaders/app');
await appLoader.default({ app });
const server = await this.httpServerService.initialize(
app,
config.port,
);
const serverLoader = await import('./loaders/server');
await (serverLoader.default as any)({ server });
this.setupWorkerShutdown('http');
}
private async startGrpcService() {
const { GrpcServerService } = await import('./services/grpc');
this.grpcServerService = Container.get(GrpcServerService);
await this.grpcServerService.initialize();
this.setupWorkerShutdown('grpc');
}
private setupWorkerShutdown(serviceType: string) {
process.on('message', async (msg) => {
if (msg === 'shutdown') {
this.gracefulShutdown(serviceType);
} else if (serviceType === 'http' &&
(msg === 'reregister-crons' || msg === 'scheduler-unavailable')) {
const { default: cronClient } = await import('./schedule/client');
cronClient.readiness.invalidate();
}
});
const shutdown = () => this.gracefulShutdown(serviceType);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async gracefulShutdown(serviceType: string) {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
try {
if (serviceType === 'http') {
await this.httpServerService?.shutdown();
} else {
await this.grpcServerService?.shutdown();
}
process.exit(0);
} catch (error) {
Logger.error(`[${serviceType}] Error during shutdown:\n${errStack(error)}`);
process.exit(1);
}
}
}
const app = new Application();
app.start().catch((error) => {
Logger.error(`🙅‍♀️ Application failed to start:\n${errStack(error)}`);
process.exit(1);
});