Files
AstrBot/desktop/lib/backend-manager.js
T
エイカク a8dda20a30 fix: 提升打包版桌面端启动稳定性并优化插件依赖处理 (#5031)
* fix(desktop): rotate electron and backend logs

* refactor(desktop): centralize log rotation defaults and debug fs errors

* fix(desktop): harden rotation fs ops and buffer backend log writes

* refactor(desktop): extract buffered logger and reduce sync stat calls

* refactor(desktop): simplify rotation flow and harden logger config

* fix(desktop): make app logging async and flush-safe

* fix: harden app log path switching and debug-gated rotation errors

* fix: cap buffered log chunk size during path switch

* fix: avoid redundant plugin reinstall and upgrade electron

* fix: stop webchat tasks cleanly and bind packaged backend to localhost

* fix: unify platform shutdown and await webchat listener cleanup

* fix: improve startup logs for dashboard and onebot listeners

* fix: revert extra startup service logs

* fix: harden plugin import recovery and webchat listener cleanup

* fix: pin dashboard ci node version to 24.13.0

* fix: avoid duplicate webchat listener cleanup on terminate

* refactor: clarify platform task lifecycle management

* fix: continue platform shutdown when terminate fails
2026-02-12 01:04:04 +08:00

822 lines
22 KiB
JavaScript

'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { BufferedRotatingLogger } = require('./buffered-rotating-logger');
const {
delay,
ensureDir,
formatLogTimestamp,
normalizeUrl,
parseLogBackupCount,
parseLogMaxBytes,
waitForProcessExit,
} = require('./common');
const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000;
const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000;
const BACKEND_LOG_FLUSH_INTERVAL_MS = 120;
const BACKEND_LOG_MAX_BUFFER_BYTES = 128 * 1024;
function parseBackendTimeoutMs(app) {
const defaultTimeoutMs = app.isPackaged ? 0 : 20000;
const parsed = Number.parseInt(
process.env.ASTRBOT_BACKEND_TIMEOUT_MS || `${defaultTimeoutMs}`,
10,
);
if (Number.isFinite(parsed) && parsed >= 0) {
return parsed;
}
return defaultTimeoutMs;
}
class BackendManager {
constructor({ app, baseDir, log, shouldSkipStart }) {
this.app = app;
this.baseDir = baseDir;
this.log = typeof log === 'function' ? log : () => {};
this.shouldSkipStart =
typeof shouldSkipStart === 'function' ? shouldSkipStart : () => false;
this.backendUrl = normalizeUrl(
process.env.ASTRBOT_BACKEND_URL || 'http://127.0.0.1:6185/',
);
this.backendAutoStart = process.env.ASTRBOT_BACKEND_AUTO_START !== '0';
this.backendTimeoutMs = parseBackendTimeoutMs(app);
this.backendLogMaxBytes = parseLogMaxBytes(
process.env.ASTRBOT_BACKEND_LOG_MAX_MB,
);
this.backendLogBackupCount = parseLogBackupCount(
process.env.ASTRBOT_BACKEND_LOG_BACKUP_COUNT,
);
this.backendProcess = null;
this.backendConfig = null;
this.backendLogger = new BufferedRotatingLogger({
logPath: null,
maxBytes: this.backendLogMaxBytes,
backupCount: this.backendLogBackupCount,
flushIntervalMs: BACKEND_LOG_FLUSH_INTERVAL_MS,
maxBufferBytes: BACKEND_LOG_MAX_BUFFER_BYTES,
});
this.backendLastExitReason = null;
this.backendStartupFailureReason = null;
this.backendSpawning = false;
this.backendRestarting = false;
}
getBackendUrl() {
return this.backendUrl;
}
getBackendTimeoutMs() {
return this.backendTimeoutMs;
}
getRootDir() {
return (
process.env.ASTRBOT_ROOT ||
this.backendConfig?.rootDir ||
this.resolveBackendRoot()
);
}
getBackendLogPath() {
const rootDir = this.getRootDir();
if (!rootDir) {
return null;
}
return path.join(rootDir, 'logs', 'backend.log');
}
getStartupFailureReason() {
return this.backendStartupFailureReason;
}
isSpawning() {
return this.backendSpawning;
}
isRestarting() {
return this.backendRestarting;
}
resolveBackendRoot() {
if (!this.app.isPackaged) {
return null;
}
return path.join(os.homedir(), '.astrbot');
}
resolveBackendCwd() {
if (!this.app.isPackaged) {
return path.resolve(this.baseDir, '..');
}
return this.resolveBackendRoot();
}
resolveWebuiDir() {
if (process.env.ASTRBOT_WEBUI_DIR) {
return process.env.ASTRBOT_WEBUI_DIR;
}
if (!this.app.isPackaged) {
return null;
}
const candidate = path.join(process.resourcesPath, 'webui');
const indexPath = path.join(candidate, 'index.html');
return fs.existsSync(indexPath) ? candidate : null;
}
getPackagedBackendPath() {
if (!this.app.isPackaged) {
return null;
}
const filename =
process.platform === 'win32' ? 'astrbot-backend.exe' : 'astrbot-backend';
const candidate = path.join(process.resourcesPath, 'backend', filename);
return fs.existsSync(candidate) ? candidate : null;
}
buildDefaultBackendLaunch(webuiDir) {
if (this.app.isPackaged) {
const packagedBackend = this.getPackagedBackendPath();
if (!packagedBackend) {
return null;
}
const args = [];
if (webuiDir) {
args.push('--webui-dir', webuiDir);
}
return {
cmd: packagedBackend,
args,
shell: false,
};
}
const args = ['run', 'main.py'];
if (webuiDir) {
args.push('--webui-dir', webuiDir);
}
return {
cmd: 'uv',
args,
shell: process.platform === 'win32',
};
}
resolveBackendConfig() {
const webuiDir = this.resolveWebuiDir();
const customCmd = process.env.ASTRBOT_BACKEND_CMD;
const launch = customCmd
? {
cmd: customCmd,
args: [],
shell: true,
}
: this.buildDefaultBackendLaunch(webuiDir);
const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd();
const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot();
ensureDir(cwd);
if (rootDir) {
ensureDir(rootDir);
}
this.backendConfig = {
cmd: launch ? launch.cmd : null,
args: launch ? launch.args : [],
shell: launch ? launch.shell : true,
cwd,
webuiDir,
rootDir,
};
return this.backendConfig;
}
getBackendConfig() {
if (!this.backendConfig) {
return this.resolveBackendConfig();
}
return this.backendConfig;
}
getBackendPort() {
try {
const parsed = new URL(this.backendUrl);
if (parsed.port) {
const port = Number.parseInt(parsed.port, 10);
return Number.isFinite(port) ? port : null;
}
return parsed.protocol === 'https:' ? 443 : 80;
} catch {
return null;
}
}
canManageBackend() {
return Boolean(this.getBackendConfig().cmd);
}
async flushLogs() {
await this.backendLogger.flush();
}
async pingBackend(timeoutMs = 800) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
await fetch(this.backendUrl, {
signal: controller.signal,
redirect: 'manual',
});
return true;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
getEffectiveWaitMs(maxWaitMs = 0) {
if (maxWaitMs > 0) {
return maxWaitMs;
}
if (this.app.isPackaged) {
return PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS;
}
return 0;
}
async requestBackendJson(pathname, options = {}) {
const timeoutMs = options.timeoutMs || 2000;
const method = options.method || 'GET';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const requestUrl = new URL(pathname, this.backendUrl);
requestUrl.searchParams.set('_ts', `${Date.now()}`);
const authToken =
typeof options.authToken === 'string' && options.authToken
? options.authToken
: null;
try {
const response = await fetch(requestUrl.toString(), {
method,
signal: controller.signal,
redirect: 'manual',
headers: {
Accept: 'application/json',
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...(options.headers || {}),
},
});
if (!response.ok) {
return { ok: false, data: null };
}
const data = await response.json();
return { ok: true, data };
} catch {
return { ok: false, data: null };
} finally {
clearTimeout(timeout);
}
}
async getBackendStartTime() {
const result = await this.requestBackendJson('/api/stat/start-time', {
timeoutMs: 1800,
method: 'GET',
});
if (!result.ok || !result.data) {
return null;
}
const rawStartTime = result.data?.data?.start_time;
const numericStartTime = Number(rawStartTime);
return Number.isFinite(numericStartTime) ? numericStartTime : null;
}
async requestGracefulRestart(authToken = null) {
const result = await this.requestBackendJson('/api/stat/restart-core', {
timeoutMs: 2500,
method: 'POST',
authToken,
headers: {
'Content-Type': 'application/json',
},
});
return result.ok;
}
async waitForGracefulRestart(previousStartTime, maxWaitMs = 0) {
const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs);
const gracefulWaitMs =
effectiveMaxWaitMs > 0
? effectiveMaxWaitMs
: GRACEFUL_RESTART_WAIT_FALLBACK_MS;
const start = Date.now();
let sawBackendDown = false;
while (true) {
const reachable = await this.pingBackend(700);
if (!reachable) {
sawBackendDown = true;
} else {
const currentStartTime = await this.getBackendStartTime();
if (
previousStartTime !== null &&
currentStartTime !== null &&
currentStartTime !== previousStartTime
) {
return { ok: true, reason: null };
}
if (sawBackendDown && previousStartTime === null) {
return { ok: true, reason: null };
}
}
if (Date.now() - start >= gracefulWaitMs) {
return {
ok: false,
reason: `Timed out after ${gracefulWaitMs}ms waiting for graceful restart.`,
};
}
await delay(350);
}
}
async waitForBackend(maxWaitMs = 0, failOnProcessExit = false) {
const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs);
const start = Date.now();
while (true) {
if (await this.pingBackend()) {
return { ok: true, reason: null };
}
if (failOnProcessExit && !this.backendProcess) {
return {
ok: false,
reason:
this.backendLastExitReason ||
'Backend process exited before becoming reachable.',
};
}
if (effectiveMaxWaitMs > 0 && Date.now() - start >= effectiveMaxWaitMs) {
return {
ok: false,
reason: `Timed out after ${effectiveMaxWaitMs}ms waiting for backend startup.`,
};
}
await delay(600);
}
}
async startBackend() {
if (this.shouldSkipStart()) {
this.log('Skip backend start because app is quitting.');
return;
}
if (this.backendProcess) {
return;
}
const backendConfig = this.getBackendConfig();
if (!backendConfig.cmd) {
return;
}
this.backendLastExitReason = null;
const env = {
...process.env,
PYTHONUNBUFFERED: '1',
};
if (this.app.isPackaged) {
env.ASTRBOT_ELECTRON_CLIENT = '1';
const hasExplicitDashboardHost = Boolean(
process.env.DASHBOARD_HOST || process.env.ASTRBOT_DASHBOARD_HOST,
);
const hasExplicitDashboardPort = Boolean(
process.env.DASHBOARD_PORT || process.env.ASTRBOT_DASHBOARD_PORT,
);
if (!hasExplicitDashboardHost) {
env.DASHBOARD_HOST = '127.0.0.1';
}
if (!hasExplicitDashboardPort) {
env.DASHBOARD_PORT = '6185';
}
}
if (backendConfig.webuiDir) {
env.ASTRBOT_WEBUI_DIR = backendConfig.webuiDir;
}
let backendLogPath = null;
if (backendConfig.rootDir) {
env.ASTRBOT_ROOT = backendConfig.rootDir;
const logsDir = path.join(backendConfig.rootDir, 'logs');
ensureDir(logsDir);
backendLogPath = path.join(logsDir, 'backend.log');
}
await this.backendLogger.setLogPath(backendLogPath);
const usePipedLogging = Boolean(backendLogPath);
this.backendProcess = spawn(backendConfig.cmd, backendConfig.args || [], {
cwd: backendConfig.cwd,
env,
shell: backendConfig.shell,
stdio: usePipedLogging ? ['ignore', 'pipe', 'pipe'] : 'ignore',
windowsHide: true,
});
if (usePipedLogging) {
if (this.backendProcess.stdout) {
this.backendProcess.stdout.on('data', (chunk) => {
this.backendLogger.log(chunk);
});
}
if (this.backendProcess.stderr) {
this.backendProcess.stderr.on('data', (chunk) => {
this.backendLogger.log(chunk);
});
}
}
if (usePipedLogging) {
const launchLine = [backendConfig.cmd, ...(backendConfig.args || [])]
.map((item) => JSON.stringify(item))
.join(' ');
this.backendLogger.log(
`[${formatLogTimestamp()}] [Electron] Start backend ${launchLine}\n`,
);
}
this.backendProcess.on('error', (error) => {
this.backendLastExitReason =
error instanceof Error ? error.message : String(error);
this.backendLogger.log(
`[${formatLogTimestamp()}] [Electron] Backend spawn error: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
void this.backendLogger.flush();
this.backendProcess = null;
});
this.backendProcess.on('exit', (code, signal) => {
this.backendLastExitReason = `Backend process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`;
void this.backendLogger.flush();
this.backendProcess = null;
});
}
async startBackendAndWait(maxWaitMs = this.backendTimeoutMs) {
if (!this.canManageBackend()) {
return {
ok: false,
reason: 'Backend command is not configured.',
};
}
this.backendSpawning = true;
try {
await this.startBackend();
return await this.waitForBackend(maxWaitMs, true);
} finally {
this.backendSpawning = false;
}
}
async stopManagedBackend() {
if (!this.backendProcess) {
return;
}
const processToStop = this.backendProcess;
const pid = processToStop.pid;
this.backendProcess = null;
this.log(`Stop backend requested pid=${pid ?? 'unknown'}`);
if (process.platform === 'win32' && pid) {
try {
// Synchronous taskkill is acceptable here because stop/restart is
// already a control-path operation and not latency-sensitive.
const result = spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
if (result.status !== 0) {
this.log(
`taskkill failed pid=${pid} status=${result.status} signal=${result.signal ?? 'null'}`,
);
} else {
this.log(`taskkill completed pid=${pid}`);
}
} catch (error) {
this.log(
`taskkill threw for pid=${pid}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
await waitForProcessExit(processToStop, 5000);
} else {
if (!processToStop.killed) {
try {
processToStop.kill('SIGTERM');
} catch (error) {
this.log(
`SIGTERM failed for pid=${pid ?? 'unknown'}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
const exitResult = await waitForProcessExit(processToStop, 5000);
if (exitResult === 'timeout' && !processToStop.killed) {
try {
processToStop.kill('SIGKILL');
} catch {}
await waitForProcessExit(processToStop, 1500);
}
}
await this.backendLogger.flush();
}
findListeningPidsOnWindows(port) {
// Synchronous netstat parsing is acceptable here because this helper is
// used only during shutdown/restart cleanup paths.
const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
windowsHide: true,
});
if (result.status !== 0 || !result.stdout) {
return [];
}
const pids = new Set();
const lines = result.stdout.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.toUpperCase().startsWith('TCP')) {
continue;
}
const parts = trimmed.split(/\s+/);
if (parts.length < 5) {
continue;
}
const localAddress = parts[1] || '';
const state = (parts[3] || '').toUpperCase();
const pid = parts[parts.length - 1];
if (!/^\d+$/.test(pid)) {
continue;
}
if (state !== 'LISTENING') {
continue;
}
const cleanedLocalAddress = localAddress.replace(/\]$/, '');
const segments = cleanedLocalAddress.split(':');
const portStr = segments[segments.length - 1];
const portNum = Number(portStr);
if (Number.isInteger(portNum) && portNum === Number(port)) {
pids.add(pid);
}
}
return Array.from(pids);
}
getWindowsProcessInfo(pid) {
const result = spawnSync(
'tasklist',
['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'],
{
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
windowsHide: true,
},
);
if (result.status !== 0 || !result.stdout) {
return null;
}
const firstLine = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine || firstLine.startsWith('INFO:')) {
return null;
}
const fields = firstLine
.replace(/^"/, '')
.replace(/"$/, '')
.split('","');
const imageName = fields[0] || '';
const parsedPid = Number.parseInt(fields[1] || '', 10);
if (!imageName || !Number.isInteger(parsedPid) || parsedPid !== Number(pid)) {
return null;
}
return { imageName, pid: parsedPid };
}
async stopUnmanagedBackendByPort() {
if (!this.app.isPackaged || process.platform !== 'win32') {
return false;
}
const port = this.getBackendPort();
if (!port) {
return false;
}
const pids = this.findListeningPidsOnWindows(port);
if (!pids.length) {
return false;
}
this.log(
`Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`,
);
const expectedImageName = (
path.basename(this.getPackagedBackendPath() || '') || 'astrbot-backend.exe'
).toLowerCase();
for (const pid of pids) {
const processInfo = this.getWindowsProcessInfo(pid);
if (!processInfo) {
this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`);
continue;
}
const actualImageName = processInfo.imageName.toLowerCase();
if (actualImageName !== expectedImageName) {
this.log(
`Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`,
);
continue;
}
try {
// Synchronous taskkill is acceptable here because unmanaged cleanup
// is performed only during shutdown/restart control flows.
spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
await delay(500);
return !(await this.pingBackend(1200));
}
async stopAnyBackend() {
if (this.backendProcess) {
await this.stopManagedBackend();
const running = await this.pingBackend();
if (!running) {
return { ok: true, reason: null };
}
} else {
const running = await this.pingBackend();
if (!running) {
return { ok: true, reason: null };
}
}
const cleaned = await this.stopUnmanagedBackendByPort();
if (cleaned) {
return { ok: true, reason: null };
}
return {
ok: false,
reason: 'Backend is running but not managed by Electron.',
};
}
async ensureBackend() {
this.backendStartupFailureReason = null;
const running = await this.pingBackend();
if (running) {
return true;
}
if (!this.backendAutoStart || !this.canManageBackend()) {
this.backendStartupFailureReason =
'Backend auto-start is disabled or backend command is not configured.';
return false;
}
const waitResult = await this.startBackendAndWait(this.backendTimeoutMs);
if (!waitResult.ok) {
this.backendStartupFailureReason = waitResult.reason;
return false;
}
return true;
}
async getState() {
return {
running: await this.pingBackend(),
spawning: this.backendSpawning,
restarting: this.backendRestarting,
canManage: this.canManageBackend(),
};
}
async restartBackend(authToken = null) {
if (!this.canManageBackend()) {
return {
ok: false,
reason: 'Backend command is not configured.',
};
}
if (this.backendSpawning || this.backendRestarting) {
return {
ok: false,
reason: 'Backend action already in progress.',
};
}
this.backendRestarting = true;
try {
const backendRunning = await this.pingBackend(900);
if (backendRunning) {
const previousStartTime = await this.getBackendStartTime();
const gracefulRequested = await this.requestGracefulRestart(authToken);
if (gracefulRequested) {
const gracefulResult = await this.waitForGracefulRestart(
previousStartTime,
this.backendTimeoutMs,
);
if (gracefulResult.ok) {
return {
ok: true,
reason: null,
};
}
this.log(
`Graceful restart did not complete: ${gracefulResult.reason || 'unknown reason'}`,
);
} else {
this.log(
'Graceful restart request failed; falling back to managed restart.',
);
}
}
await this.stopManagedBackend();
const startResult = await this.startBackendAndWait(this.backendTimeoutMs);
if (!startResult.ok) {
return {
ok: false,
reason: startResult.reason || 'Failed to restart backend.',
};
}
return {
ok: true,
reason: null,
};
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
} finally {
this.backendRestarting = false;
}
}
async stopBackendForIpc() {
if (!this.canManageBackend()) {
return {
ok: false,
reason: 'Backend command is not configured.',
};
}
if (this.backendSpawning || this.backendRestarting) {
return {
ok: false,
reason: 'Backend action already in progress.',
};
}
try {
return await this.stopAnyBackend();
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
}
}
module.exports = {
BackendManager,
};